CVE-2023-2812
a custom CSS box that prints into a style tag: stored XSS in Ultimate Dashboard
Ultimate Dashboard (<= 3.7.5) has a Custom Admin CSS box. Whatever you put in it is echoed raw inside a
a CSS box is an HTML sink
Ultimate Dashboard lets you customise the wp-admin area. One of its options is a Custom Admin CSS box: type CSS, it gets applied to the admin pages. The moment I read that feature description I wanted to see how the CSS reaches the page, because “we print your text inside a <style> tag” is one closing tag away from “we print your HTML”.
3.7.5, modules/setting/class-setting-output.php.
the sink
public function admin_custom_css() {
$settings = get_option( 'udb_settings' );
if ( ! isset( $settings['custom_admin_css'] ) || empty( $settings['custom_admin_css'] ) ) {
return;
}
?>
<style>
<?php echo $settings['custom_admin_css']; ?>
</style>
<?php
}
Raw echo between <style> and </style>. No escaping. CSS does not need escaping to be valid CSS, but an attribute or a tag inside it is not CSS, it is markup, and the browser will treat my injected </style> as the real end of the style block. Everything after that is parsed as HTML.
Now the part that turns this from annoying to serious, the hook:
add_action( 'admin_head', array( self::get_instance(), 'admin_custom_css' ), 200 );
admin_head fires on every admin page. Not the settings page, every page. Dashboard, Posts, Plugins, Users, all of them. So a payload stored here is not waiting on one specific screen. It detonates wherever the victim goes next.
the missing sanitize callback
The save side is the WordPress Settings API. Here is the registration:
register_setting( 'udb-settings-group', 'udb_settings' );
register_setting() takes a third argument where you put a sanitize_callback. There is no third argument. So when the settings form posts to options.php, WordPress stores the udb_settings array exactly as submitted. No filtering on the way in, no escaping on the way out. Both ends open.
The field that feeds the sink is the Custom Admin CSS textarea, udb_settings[custom_admin_css].
the payload, and a reproduction snag worth noting
The injection itself is plain. Close the style block, then run script through an image error handler:
</style><img src=x onerror=alert(document.domain)>
The interesting part was driving the save in a headless browser. The Custom Admin CSS field is not a bare textarea, it is backed by a CodeMirror editor. Setting the textarea’s .value directly looked like it worked, the DOM held my 292 characters, but on submit the field arrived empty. CodeMirror keeps its own copy of the content and syncs it over the textarea right before the form posts, so my raw value got overwritten with CodeMirror’s empty buffer.
The fix was to talk to the editor instead of the element behind it:
const ta = document.querySelector('textarea[name="udb_settings[custom_admin_css]"]');
let n = ta.nextElementSibling, cm = null;
while (n) { if (n.classList?.contains('CodeMirror') && n.CodeMirror) { cm = n.CodeMirror; break; } n = n.nextElementSibling; }
cm.setValue(payload);
cm.save(); // push CodeMirror's content back into the textarea
After that the POST carried the payload and options.php stored it. A human attacker pasting into the box never hits this because they are typing into CodeMirror in the first place. It only bites automation.
reproduction
Local WordPress 6.2, plugin pinned to 3.7.5, admin session.
Ultimate Dashboard → Settings → Custom CSS tab → Custom Admin CSS
Paste:
</style><img src=x onerror=alert(document.domain)>
Save, then navigate anywhere in wp-admin. The alert fired twice in my run: once when the settings page re-rendered after save, and again on the main Dashboard.
[DIALOG] type=alert message="localhost"
[DIALOG] type=alert message="localhost"

The screenshot is the Dashboard, not the settings page, which is the point. The welcome panel columns have collapsed into a single stack because my </style> cut the page’s own stylesheet short. The red bar is the injected element. The bug rides along to every admin screen.
impact
CVSS 4.4 by my vector (NVD 4.8, WPScan 3.5), authenticated. The “fires on every admin page” behaviour is what makes this one nastier than its score suggests. The payload does not need the super admin to visit a niche settings page. Any admin screen they open runs it, which makes session theft or a silent plugin-editor write very reachable on multisite and on sites with lower-trust admin roles.
the fix
Give the setting a sanitize callback and escape on output. For a CSS field, strip tags so a </style> can never appear:
register_setting( 'udb-settings-group', 'udb_settings', array(
'sanitize_callback' => function ( $input ) {
if ( isset( $input['custom_admin_css'] ) ) {
$input['custom_admin_css'] = wp_strip_all_tags( $input['custom_admin_css'] );
}
return $input;
},
) );
Later versions sanitize the CSS fields and the bug is gone.