Research/wpmgr-invite-accept-after-revoke
0-dayNo CVEHighPublic

WPMgr v0.61.199, invite accept after revoke

WPMgr v0.61.199 MarkInvitationAccepted updates WHERE id AND accepted_at IS NULL with no revoked_at or token_hash bind. A client invite revoke returns 204 and a later accept still returns 200, membership, and a session. Sequential, not a tight race. Labbed. No CVE yet.

Name
WPMgr v0.61.199, invite accept after revoke
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
mosamlife
Affected
WPMgr v0.61.199 (e1b3a163). Anonymous holder of a client invitation token. Org invites have no revoke on this pin.
Published
09 Nov 2026
Updated
09 Nov 2026
Tags
0-day, wpmgr, broken-revoke, cwe-284, cwe-367, remote

Cancel returned 204. The guest still walked in.

I went looking for a race on WPMgr invitations. Lookup, revoke, and the argon2 password work all sit outside the claim transaction. MarkInvitationAccepted is UPDATE … WHERE id = $1 AND accepted_at IS NULL. No revoked_at IS NULL. No token_hash bind. That is the textbook TOCTOU shape.

The lab did not need a tight race. Revoke first, 204. Accept second, 200. Both timestamps set, about sixteen milliseconds apart. client_members went to 2. Session cookie issued. Comments in Service.Accept say a revoked invite is dead even to the original link. The SQL does not agree.

If you sent a client invite and then revoked it, anyone who still has the token can join anyway. They do not need a session of their own.

This is WPMgr v0.61.199 (e1b3a163988c9f1c31bc93922ea34322fa188792). No CVE yet. CWE-284 / CWE-367. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N). Remote broken revoke. Pack: abraxas/wpmgr-invite-accept-after-revoke.

Why client invites

Org invitations have no revoke on this pin. Site-share revoke exists. Client invitations have DELETE /api/v1/clients/{id}/invitations/{invitation}. The control plane will issue a client invite without a WordPress agent attached. I almost blocked the lab on "need a site." I did not.

FIX 5 already made double-accept atomic. Second claim returns invitation_already_used. That is why trial 1 in the lab is a miss-late: accept won, revoke 409. Trial 2 flipped the order enough that revoke committed and accept still stamped accepted_at.

Enrollment pairing, autologin consume, and CP recovery codes are compare-and-set. I left them closed.

The lab

Compose wpmgrh12, loopback 18200-18204. Twenty trials, delay starting at 50ms. Trial 2 at 30ms: revoke 204 (revoked_at=22:05:33.944943Z), accept 200 (accepted_at=22:05:33.960446Z). Members 2. Session cookie 1.

Plain text
IOC trial=2 revoke=204 accept=200
accepted_at=2026-11-09 22:05:33.960446+00
revoked_at=2026-11-09 22:05:33.944943+00
members=2 session_cookie=1
SUCCESS WPMGR-H12-INVITE-RACE trial=2 delay_ms=30 ... WPMGR-H12-ACCEPT-AFTER-REVOKE

The Go checks for revoked_at happen after a plain SELECT with no FOR UPDATE, then argon2 runs, then a different transaction claims by id. Even a slow, sequential revoke loses.

What an attacker can do

Needs a client invitation token that was issued and then revoked. In ordinary words: you took the invitation back. They used it anyway and they are on the client now.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Accept after a committed revoke DELETE …/invitations/{id} 204 then POST /api/v1/invitations/accept 200 Integrity (membership) Lab-proven
Session as the new member accept Set-Cookie: wpmgr_session Integrity (authn) Lab-proven
Client membership row client_members count 2 Integrity Lab-proven
Negative control: revoke after a won accept trial 1 accept 200, revoke 409 n/a Lab-proven

Stays closed on this leftover

Axis Method / sink Why it is closed
Integrity Double-accept FIX 5 invitation_already_used
Integrity Enrollment pairing consume compare-and-set
Integrity Autologin consume compare-and-set
Integrity CP recovery codes compare-and-set
Integrity Org-invite revoke No revoke API on this pin

The fix

Put revoked_at IS NULL and the presented token_hash in the same UPDATE that stamps accepted_at. Look the row up FOR UPDATE inside that transaction. Treat a row with both timestamps as a failed revoke, not a member.

I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.