Skip to content
Back to Articles

The SSO Signing Oracle: How a WordPress Plugin’s Own Authentication Became the Attack Vector

Share:

The server’s security scanner was working perfectly. Every malicious file was detected and cleaned within minutes. But the malware kept coming back — new fake plugins appearing every few hours, each with a freshly generated backdoor. The scanner would catch one, and another would land. Something was uploading them faster than they could be removed.

There was no file-based persistence. No database payload. No cron job. No SSH key. Every traditional persistence mechanism came up empty. The attacker wasn’t hiding inside the server — they were walking through the front door, using the site’s own WordPress management plugin to authenticate as administrator without credentials.

The Vulnerability: A Signing Oracle in Plain Sight

The affected plugin provides a management dashboard for WordPress sites, including a Hub SSO (Single Sign-On) feature that lets administrators log into connected sites from a central portal without entering a password. The SSO works through two unauthenticated AJAX endpoints:

Step 1: GET /wp-admin/admin-ajax.php?action=sso_step1&redirect=
  → Returns a signed HMAC token (302 redirect)

Step 2: GET /wp-admin/admin-ajax.php?action=sso_step2&outgoing_hmac=...&token=...
  → Validates the HMAC, creates an admin session (302 redirect to wp-admin)

The vulnerability is an HMAC canonicalization confusion between these two endpoints. Step 1 is designed to be called by the Hub portal — it constructs an HMAC-SHA256 signature over certain request parameters. Step 2 validates that signature. But the two endpoints construct the HMAC message differently. Step 1 effectively becomes a signing oracle: an unauthenticated endpoint that produces a valid HMAC that can be replayed against Step 2 in a different logical context.

The attacker doesn’t need the site’s API key. They don’t need to compromise the Hub account. They just need to call Step 1, capture the signed response, and feed it into Step 2. The plugin does the rest — validates the HMAC it just produced, and creates an authenticated admin session.

The Attack: Six Waves in One Day

The access logs told the complete story. Over a single day, six different IP addresses exploited the SSO bypass, each following an identical automated sequence:

06:02:37  IP-A  GET  sso_step1              → 302 (signed token)
06:02:38  IP-A  GET  sso_step2&hmac=...     → 302 (admin session created)
06:02:42  IP-A  GET  plugin-install.php      → 200 (plugin upload page)
06:02:54  IP-A  POST update.php?action=upload-plugin  → 200 (malware uploaded)
06:02:54  IP-A  GET  starter-loader-one31/Nx123.php?v=1  → 200 (backdoor verified)

08:22:05  IP-B  [same pattern → starter-monitor-plus59/Nx783.php]
08:56:34  IP-C  [same pattern → starter-module-one72/Nx786.php]
12:19:35  IP-D  [different pattern — see below]
13:26:03  IP-E  sso_step1 → 403 (blocked)
14:22:35  IP-F  [same pattern → starter-control-plus37/Nx797.php]

Five IPs successfully authenticated. One was blocked (it used a slightly different request format that triggered a security rule). Each successful IP generated a unique HMAC and token pair — these weren’t replayed captures from a single session. The signing oracle was being called fresh each time.

Every attacker IP shared the same user agent (Chrome/120.0.0.0 — outdated by over a year), connected via HTTP/1.1 with no referer header, and completed the entire sequence in under 25 seconds. The legitimate administrator, working on the same site that day, used Chrome/152 via HTTP/2.0 with proper referer chains. The automated nature of the attack was unmistakable.

The Payload: Fake Plugins as Backdoor Delivery

Each attack wave uploaded a fake plugin through WordPress’s legitimate update.php?action=upload-plugin endpoint. The plugins followed a consistent naming pattern:

starter-loader-one31/
  ├── starter-loader-one31.php  (plugin header only, no functional code)
  ├── Nx123.php                 (backdoor — 0 bytes after cleanup)
  └── readme.txt                (46 bytes)

starter-monitor-plus59/
  ├── starter-monitor-plus59.php
  ├── Nx783.php
  └── readme.txt

starter-module-one72/   ...same structure...
starter-control-plus37/ ...same structure...

The naming convention — starter-{word}-{word}{number} — is designed to blend in among legitimate starter themes and utility plugins. The main plugin file contains only a standard WordPress plugin header (name, description, version) with no executable code, making it pass cursory inspection. The actual backdoor lives in a separate file with a random alphanumeric name (NxXXX.php).

After each upload, the attacker immediately sent a verification request to the backdoor: GET /wp-content/plugins/starter-control-plus37/Nx797.php?v=1. A 200 response with a small body (11–19 bytes) confirmed the backdoor was live. The ?v=1 parameter likely triggered a version or status check rather than full payload execution.

Beyond Plugin Uploads: Webshell and User Modification

Not every attacker IP followed the same playbook. The second IP (08:22) was the most active — after uploading a fake plugin, it also uploaded a fake theme containing the same backdoor plus a fully functional file manager webshell:

08:22:19  POST  update.php?action=upload-plugin  (fake plugin)
08:22:25  POST  update.php?action=upload-theme   (fake theme with webshell)
08:22:26  GET   themes/starter-starter-starter81/Nx783.php?v=1  (backdoor check)

09:04:44  GET   themes/.../wp-security-scanner.php  → 200/883b (webshell loaded)
09:04:50  POST  themes/.../wp-security-scanner.php  → 302 (authenticated)
09:04:51  GET   themes/.../wp-security-scanner.php  → 200/15573b (full UI rendered)
09:04:52  GET   ...?action=list&path=[base64]       (browsing filesystem)
09:04:54  GET   ...?action=list&path=[base64]       (navigating directories)
09:06:10  GET   ...?action=list&path=[base64]       (browsing .ssh directory)

The base64-encoded path parameters decoded to the server’s actual filesystem paths — the attacker was browsing document roots, theme directories, and critically, the SSH key directory. They were looking for private keys and other credentials to establish persistence beyond the web application layer.

A fourth IP (12:19) took yet another approach. Instead of uploading a plugin, it used the admin session to modify an existing user account via the WordPress REST API:

12:19:44  GET   /wp-admin/index.php              (dashboard)
12:19:45  GET   /wp-admin/users.php              (user list)
12:19:46  GET   /wp/v2/users?search=siteadmin     (found admin user)
12:19:47  POST  /wp/v2/users/64                  (modified the account)

This IP was running reconnaissance and modifying the admin account — potentially adding an application password, changing the email address, or injecting other persistence that would survive even if the SSO vulnerability were patched.

Why Traditional Investigation Missed It

This case highlights a blind spot in standard malware investigation workflows. The typical approach is:

  1. Check filesystem for malicious files → Found fake plugins, but the scanner had already cleaned them
  2. Check database for persistence → Clean. No injected options, no triggers, no rogue users
  3. Check crontabs, SSH keys, processes → All clean
  4. Conclude the cleanup is done

Every standard persistence check returned clean because the attacker wasn’t persisting at all. There was no need. Why plant a backdoor that might be detected when you can authenticate as admin through the front door any time you want? The management plugin’s SSO was the persistence mechanism — an always-open door that required zero maintenance.

The breakthrough came from correlating access log timestamps with the security scanner’s detection timeline. The fake plugin directory was created at 14:22. The access log at 14:22 showed the SSO bypass followed by the plugin upload. Once the pattern was identified, searching for all sso_step1 requests in the log revealed the full scope: six independent attack waves, five successful, all using the same automated sequence.

The SSO Configuration That Made It Work

For the attack to succeed, three conditions had to be true:

  1. The management plugin was installed and connected to the Hub. This is the default state for any site managed through the platform.
  2. Hub SSO was enabled. The database confirmed: sso_enabled = 1.
  3. SSO was mapped to an administrator account. The SSO configuration pointed to user ID 64 — a legitimate admin. Every SSO login, whether from the real Hub portal or from the attacker’s oracle exploit, would authenticate as this user.

Checking the active session tokens confirmed the damage. User 64 had two active sessions:

Session 1: IP 1xx.1xx.37.183  Chrome/120  Login: 14:22  Expires: ~Aug 30  ← ATTACKER
Session 2: IP 89.xxx.55.193   Chrome/152  Login: 14:40  Expires: ~Sep 3   ← Legitimate

The attacker’s session was still active and would remain valid for another 48 hours. Even if the vulnerability were patched immediately, that session token would continue to grant admin access until it expired or was explicitly revoked.

Detection: What to Look For

If you’re investigating a site that uses a WordPress management plugin with Hub SSO, here’s what indicates exploitation of this class of vulnerability:

1. Access Log Pattern

# SSO step1 → step2 → plugin upload within seconds, no referer
grep "sso_step1\|sso_step2\|upload-plugin" /path/to/access_log

# Red flags:
# - sso_step1 and sso_step2 from the same IP within 1-2 seconds
# - No referer header (legitimate Hub SSO has one)
# - HTTP/1.1 instead of HTTP/2 or HTTP/3
# - Outdated user agent
# - upload-plugin POST within 30 seconds of sso_step2

2. SSO Database State

# Check if SSO is enabled and which user it maps to
SELECT option_name, option_value FROM wp_options
WHERE option_name LIKE '%sso%';

# Check session tokens for the mapped user
SELECT meta_value FROM wp_usermeta
WHERE user_id = [SSO_USER_ID] AND meta_key = 'session_tokens';

Look for session tokens from IPs that don’t match the legitimate admin’s known locations.

3. Fake Plugin Signatures

# Find recently created plugin directories with minimal files
find /path/to/wp-content/plugins/ -maxdepth 2 -name "*.php" -newer /path/to/wp-config.php \
  -exec stat --format='%Y %n' {} \; 2>/dev/null | sort -rn

# Check for the starter-* naming pattern
ls -la /path/to/wp-content/plugins/ | grep -i "starter-"

# Look for NxXXX.php backdoor files
find /path/to/wp-content/ -name "Nx*.php" -ls 2>/dev/null

The Fix and Its Limitations

The plugin vendor released a patch four days before this attack, fixing the HMAC construction inconsistency between the two SSO endpoints. But patching alone isn’t sufficient:

  • Active sessions survive the patch. Any attacker sessions created before the update remain valid until they expire. Session tokens must be explicitly flushed.
  • User modifications may persist. If an attacker used the admin session to add application passwords, change email addresses, or modify user metadata, those changes survive both the plugin update and a session flush.
  • Uploaded plugins may still be on disk. The fake plugins are standard WordPress plugins — they don’t self-delete when the vulnerability is patched. Each must be identified and removed.
  • Secondary payloads may have been deployed. The attacker with the file manager webshell was browsing SSH directories. Any credentials or keys discovered during that reconnaissance could be used for access that’s completely independent of the WordPress vulnerability.

Key Takeaways

This case inverts the usual malware investigation narrative. There was no sophisticated obfuscation. No self-healing persistence. No database triggers or hidden cron jobs. The security scanner caught every payload. The attacker’s sophistication was in the entry vector, not the payload.

  • Legitimate plugin features can be the vulnerability. SSO exists to make life easier for administrators. The signing oracle turned that convenience into unauthenticated admin access for anyone.
  • Cleaning malware without closing the entry point is futile. The server’s scanner removed 16 malicious files in one day. And 16 more would have appeared the next day if the SSO endpoint remained exploitable.
  • Log correlation is the detective’s best tool. Every traditional persistence check returned clean. The attack was only visible in access logs, correlated with scanner detection timestamps. If you’re seeing malware that keeps returning with no persistence mechanism, check how the attacker is getting in, not how they’re staying in.
  • Automated exploitation moves fast. From SSO bypass to verified backdoor in 25 seconds, from six different IPs, with unique tokens each time. This wasn’t a human poking around — it was a botnet that had weaponized the CVE within days of disclosure.
  • Patching isn’t the end of remediation. Active sessions, modified users, uploaded files, and reconnaissance data all survive a plugin update. Post-patch verification is as important as the patch itself.

The attacker in this case didn’t need to be clever about persistence because the vulnerability gave them something better: the ability to come back any time they wanted through the site’s own authentication system. The most dangerous backdoor isn’t a hidden file or an encoded payload — it’s a legitimate feature that doesn’t check who’s using it.