← Crucible

The check it had, and the three places it didn't

· disclosure authorization wordpress-internals

Wordfence published a CVE of mine on 2 October.

CVESoftwareClassFixed in
CVE-2026-93428Ultimate MemberUnauthenticated disclosure of privacy-restricted profile fields. CVSS 7.52.14.0

If you run Ultimate Member, update to 2.14.0 or later. I say that having read 2.14.0 rather than having taken the advisory's word for it, and I'll come back to exactly how far that goes.

The reason I think this one is worth a post is not the severity. It's the shape. The plugin's permission logic was never wrong, so there was nothing to find by reading it.

The plugin already had the check

Ultimate Member lets a site build member profiles and a directory, and it lets the owner mark individual profile fields as restricted: owner-only, members-only, or visible to particular roles. That setting is the product. Somebody choosing it is choosing who sees what.

The plugin enforces it with a function of its own, and that function is correct. It reads the field's privacy tier and answers whether the current viewer may see it. The codebase calls it in more than ten places.

Three output paths never call it.

One of those three renders two separate field lists, so there are four places in the code and three functions in the report. That distinction matters at the end.

Not a broken check. Not a check that was bypassed. A working, correct, locally available function that three specific pieces of rendering code don't consult before putting a field's value on the page. A field set to owner-only came back to a request carrying no cookies at all.

Why that shape is hard to find

Most authorization bugs are a defect in a decision. The guard is missing, or it asks the wrong question, or it can be satisfied by someone who shouldn't be able to satisfy it. You find those by reading the guard.

This was a defect in coverage. The decision function was right, and reading it more carefully would have told me nothing, because it had nothing to confess. What had to be found was the difference between the set of places that call it and the set of places that emit the thing it protects.

That difference doesn't live in a file. Each of the three renderers reads perfectly well on its own: it fetches some fields and prints them, which is what a renderer is for. Nothing is locally suspicious. There's no absent if where your eye expects one, because the surrounding code was never written to have one. The bug exists only in the relationship between two lists, and a file-at-a-time review never holds both lists at once.

It's also the kind of gap a codebase grows rather than ships. Ten-plus call sites is a function somebody retrofitted conscientiously across a large plugin. Three misses out of thirteen-ish is not carelessness, it's what retrofitting looks like when the renderers outnumber the reviewer.

The method that finds it is unglamorous:

  1. Grep for the privacy or permission function. Every call site. That's list one.
  2. Grep for the renderers, by whatever your codebase uses to emit a field value. That's list two.
  3. Diff the two lists by file and function.

Anything in list two and not in list one is a candidate. Most candidates are fine for a reason you'll see immediately, and the ones that aren't are your finding. The work is in building list two honestly, because the temptation is to grep for the shape you already found and stop, and I'll show you at the end that I made exactly that mistake on this very plugin.

One of them was reachable without logging in, and the nonce is why

One of the three paths is reached through an endpoint registered on WordPress's public, unauthenticated hook. That's correct and necessary, because a member directory is meant to work for visitors who aren't logged in. The endpoint required a valid nonce and checked it on every request.

The nonce was printed into the page for every anonymous visitor.

A nonce proves a request came from a page WordPress generated. It says nothing about who sent it, and the functions that check one are named in a way that actively encourages the confusion. check_admin_referer restricts nothing to administrators. check_ajax_referer performs no authorization at all. Both compare a token and kill the request if it doesn't match, and neither looks at a capability.

So the public nonce is why an unauthenticated visitor could reach that path. It isn't why the path was wrong, and the proof is what the vendor did: the fix adds calls to the plugin's own privacy function, not a capability check. If the nonce had been the defect, the fix would look different.

What I can't tell you

Which fields were exposed depends on the site. They're whichever ones the owner marked restricted, so in practice a phone number, an address, a date of birth, or nothing much. I don't know for any individual site and nor does the advisory. The score reflects a confidentiality impact and nothing else: nothing was writable, and nothing I found let anyone take over anything.

How many sites were affected, I also can't tell you. Each of the three paths needs a display option switched on before it renders anything, and the directory path additionally needs the directory's visibility open to visitors. Two of its four visibility tiers are. One is the plugin's own "Anyone" setting, whose admin screen carries a warning about roughly this; the other is "Guests only", which carries no warning and looks like a perfectly reasonable choice for a public directory. None of that is a misconfiguration. It's an admin using an advertised feature.

There may also be a route where no admin chose anything, on sites that upgraded rather than being built fresh. I've read the code and I think the relevant setting can be absent on an older directory, and that absent reads as open. I have not reproduced it, so I'm not asserting it. I'm flagging it rather than omitting it, because it's unconfirmed in the direction that would mean more sites affected rather than fewer, and that isn't a caveat I get to leave out of a post otherwise telling people not to panic.

I read the fix, and here is exactly how far that goes

A "fixed in" column makes a claim about work I didn't watch being done, so I pulled 2.14.0 and diffed it against the vulnerable release before writing that number down.

The fix is real and it's in the right place. The vendor added its own field-privacy test at each of the four places the report named, across three files.

Three of the four skip the field before its value is ever fetched. The fourth, the biography blurb, computes the verdict, fetches the value anyway, and withholds it at the point of output. That's the weaker shape. It isn't a defect, because the verdict does get acted on, but it was the exact distinction I went looking for, so I'd rather report that one site got the lesser version than round four sites up to a tidy sentence. A guard that computes the right answer and then ignores it is what I'd have had to report instead of this, and none of the four does that.

Here's where I made the mistake I warned you about three sections ago. My first pass searched the release diff for added privacy checks, found the same guard-clause shape twice, and stopped. The biography site adds a plain assignment rather than a guard clause, so it didn't match what I was searching for. My own report says in its title that there are three affected functions, which made the count checkable without reading any code at all. I had grepped for the shape I'd already found and then treated the search as complete, which is the same error as a renderer list built from the renderers you happen to remember.

Two limits on "I read the fix", because it's a smaller claim than it sounds. I read it; I did not re-run the original request against the patched release. Reading shows the check is present and on the path. Re-running additionally catches whatever nobody thought of. And I read the PHP only. The release also changes minified JavaScript that I did not diff, so anything on that side is unread rather than cleared. So "I found nothing else wrong with 2.14.0" is not something I'm entitled to say. What I can say is that the defect I reported is closed in it.

Worth knowing if you're deciding how urgently to update: 2.14.0 carries five other security fixes besides mine, credited to other researchers, and at least three of those five are reachable without logging in. I haven't read those advisories and I'm not ranking them against mine. So "update to 2.14.0" is better advice than my CVE alone would justify, and I wouldn't have known that without opening the release.

For completeness on the timeline: I reported it on 3 July 2026, it was validated on 18 September, and the record went public on 2 October. Only that last date is on the advisory; the other two are mine, and I am giving them rather than leaving the gap. It sat in a queue for most of that, the vendor shipped three releases while it did, and the finding survived because I re-checked it against each one rather than because it was left alone.

What I'd take from it

If you maintain a plugin with a permission or privacy function of your own, the interesting question isn't whether it's correct. It's whether every path that emits the thing it protects actually calls it. Grep for the function, grep for the renderers, and compare the two lists. The gap is the bug, and it won't look like a bug in any single file, because each renderer reads perfectly well alone.

Then check your own search the way you'd check somebody else's. The failure mode on both sides of this, mine and the vendor's, was the same one: a list built from the shape you already recognise.

And if a nonce has to be readable by anonymous visitors for a feature to work, which is an ordinary requirement, then that nonce is not a secret and nothing may be gated on it alone. That's true, it's worth knowing, and it wasn't this.


This work uses large language models throughout the toolchain, including in drafting this post. Every finding is checked by hand against the source before anything is submitted. The finding was reported through Wordfence, who coordinated disclosure, assigned the identifier, scored it and published the record. The fix release was read directly from the vendor's published source, the PHP only, with no end-to-end re-run of the original request.