Canal Street Press is a two-person local newspaper running WordPress 7.0.1. Nobody can register an account on it — the setting is right there, unticked. One anonymous POST later the site has a third account, and it is an administrator. The path between the two starts with a URL that fails to parse.
Every case takes a real Uphack lab and walks the exact steps that find its flaw, one at a time. The lab itself is waiting at the end.
It plays like a video, except you drive it: scroll, and the panel beside the text walks the screens, the traffic and the code down to the exact line that fails.
Every screen, request and line of code is taken from the running lab, down to the line numbers. Nothing here is illustrative.
Canal Street Press covers planning notices, market openings and towpath closures around the basin. Mina Patel writes it; one other account keeps the site running. She has published two pieces this week — the west towpath resurfacing, and three new pitches at the Saturday market.
Both are public, and both are available twice over: once as this page, and once through the REST API that WordPress has shipped switched on since 4.7. That API is what the block editor talks to, what the mobile app talks to, and what every integration anyone has ever bolted onto a WordPress site talks to. It is not an add-on. It is how the software works.
That is where this one lives. Not a plugin, not a theme — core, at a version that was current the week it was written.
Settings, General, two thirds of the way down. Membership: anyone can register. The box is empty.
That is deliberate, and it is the right call for a two-author newspaper. WordPress honours it everywhere registration is actually handled: wp-login.php?action=register bounces you back to the sign-in form, and the users route refuses an anonymous POST.
Immediately beneath it, the answer to the other half of the question. New User Default Role: Subscriber. Even in the world where the box above is ticked, what arrives from outside can leave a comment and change its own display name.
Between two rows the screen states a policy: nobody joins from outside, and if that ever changes, they join at the bottom. It is a clear, well-judged piece of interface design, and every administrator will read it exactly as intended.
The posts route takes filters. author_exclude is one of them: leave out anything written by the authors listed.
Mina is user 2 and she wrote both posts, so excluding her empties the list. 200, empty array. The filter does what its name says.
Now the obvious thing. Put SQL in it.
400, rest_invalid_param, and an error more useful than most: author_exclude[0] is not of type integer. WordPress publishes a schema for every argument of every route, and this one is declared as an array of integers. The request never reached a query — it was rejected while the server was still reading it.
That is a real boundary and it holds. It holds because this request was matched to this route, and this route’s schema is what it was checked against.
GET /wp-json/ needs no authentication and describes the entire API. This is two entries out of a hundred and twenty-nine: every route, every method, every argument, and the type each argument is checked against.
The schema that produced that 400 is published here, on the open internet, for anybody who asks.
And one of those routes takes a list of the others. /batch/v1 accepts an array of virtual requests — method, path, body — runs them in a single HTTP round trip, and answers with an array of responses in the same order. WordPress added it in 5.6 so that a block editor saving twenty things does not make twenty requests.
It carries no permission callback of its own, because the routes inside it do their own checks. Up to twenty-five members. path is a string.
None of this is a bug. It is a documented endpoint doing an ordinary job. The only thing worth writing down is its shape: one array in, one array out, matched by position.
Here is the handler behind it, from 7.0.1. It makes three passes over the same list — parse, validate, execute — and everything that follows is about the relationship between those three passes.
The first pass. Each member’s path goes through wp_parse_url(), and one that cannot be parsed becomes a WP_Error sitting in the $requests array.
Notice what that is. Somebody considered a malformed path, decided the right behaviour was to record the failure and keep going rather than abandon the whole batch, and wrote it. Partial success is the entire point of a batch endpoint. This is careful code, and it is the last place most readers would look.
The second pass builds two more arrays beside $requests. $matches holds the route and handler each member resolved to; $validation holds true, or the error that stopped it.
Read the error branch, then the normal path. A member that failed to parse appends to $validation and continues. A member that parsed appends to $matches and to $validation.
Two collections, filled in one loop, under two different conditions. From the first unparseable path onwards $matches is shorter than $requests, and everything left in it has slid up by one.
The third pass executes, indexing by position — and the line below is what shows you what the author believed. $validation[ $i ] sits directly under $matches[ $i ], and $validation genuinely is indexed by request: it gets an entry for every member, failures included. Getting one of the two right is most of why the other looks safe.
The assumption, in one sentence: every member of this batch has exactly one route match and exactly one validation result, in the order they arrived. Two of those three things are true.
Three members. The first is http://: — a scheme, an empty host, an empty port, which wp_parse_url() refuses. It asks for nothing. It exists to fail.
So $matches holds two entries where $requests holds three. Member two names /wp/v2/posts and is validated against it. Then it is executed with $matches[1] — member three’s match, which is /batch/v1, the route with no permission callback.
Member two’s body is a requests array. The batch handler has just been handed a batch.
Inside that nested batch the same shift runs again, and this time it carries something. An unparseable member, then a DELETE whose body holds author_exclude, then a GET /wp/v2/posts.
The DELETE is validated against the delete-a-post schema, which has no author_exclude in it — so nothing checks its type, and the schema that returned the 400 earlier never runs. Then it executes through the posts-list handler. DELETE is a method that carries a body, so when that handler reaches for author_exclude, it is there. As a string.
The reply, and you can read the bug off it without opening the source. The outer member asked for POST /wp/v2/posts, which returns a post object. What came back is a 207 with a responses array inside it.
The endpoint that was named and the endpoint that answered are different endpoints.
And the list inside it. author_exclude=2 asked honestly returned []. Through the batch it returns three items: both of Mina’s posts, and About Canal Street Press — which is a page. The posts route does not serve pages.
The predicate the server built was AND wp_posts.post_author NOT IN (2) OR 1=1 -- ). The -- comments out the remainder of the WHERE, including the clauses restricting the query to published posts. What comes back is whatever the table holds, minus the rows the controller strips afterwards.
That is a read. Turning it into a write takes four more moves, and they belong to the lab rather than to this page: the forged rows come back hydrated as trusted WP_Post objects, an oEmbed cache write turns three of them into real database rows, a forged Customizer changeset borrows user 1’s identity for the length of a publish, and a dynamic hook name assembled from a forged post_status and post_type replays the batch while it holds it. Every one of them is this same desynchronisation, applied again.
Here is what the final request answers. Five members, four of them errors — and a 201.
Users, before. Two accounts: siteadmin, who runs the site, and Mina, who writes it. That is the whole list, and both names were put on it deliberately.
And after. wp2shelllab, Administrator, sitting in the list exactly as the other two do — a role, an email, a post count, and nothing to distinguish it from an account somebody meant to make.
In the newspaper’s own terms: an anonymous visitor to a local blog now has an account that can install plugins, edit theme files and read every draft in the building. The Membership box is still empty, and it was never wrong — nothing registered. The site’s list of people who work here now has a third name on it, and that name did not come through any of the doors that page controls.
If you had to point at one line: $match = $matches[ $i ];.
The bug is not that line. That line is the only reasonable thing to write when $matches is parallel to $requests, and the author had every reason to think it was — they wrote the loop that fills it, in the same function.
The bug is that three facts about one request live in three separate collections, and one of the three is appended inside a branch the others skip. Nothing declares those arrays parallel. Nothing checks it. Nothing fails when it stops being true, because $requests and $validation stay in step — the only symptom is that some members run under somebody else’s route.
The rule worth carrying: when a loop appends to two collections and one of the appends sits after a continue, they are no longer the same length, and every index into them past that point is quietly wrong.
The SQL injection at the far end is the same idea in a different file. author__not_in is normalised with absint() — but only once it is already an array. A scalar skips the block entirely, and the next line casts it to an array anyway, so it reaches implode() unexamined and lands inside NOT IN ( … ) as text.
The guard asks is this the shape I expected? and then proceeds identically whichever way the answer comes out. That is the batch handler again: a branch that skips work, and code after the branch that behaves as though the work was done.
The evidence that this was an oversight rather than a habit is in the branch immediately below. author__in runs absint() inside the implode() as well as inside the is_array() block, so no shape gets through it unconverted. The corrected version of this code was already in the file, one elseif away.
WordPress 7.0.2. One line.
The failed member now contributes to $matches as well, so the arrays keep the same length and the same order. Nothing downstream needed changing: the execution loop already handled is_wp_error( $match ), and now that branch actually fires for the member it belongs to.
The boundary is not validate paths harder. It is: every member of the input contributes exactly one entry to every collection derived from it, on every path through the loop, including the paths that give up.
The other end, patched in the same release. wp_parse_id_list() takes a string, an array, or a comma-separated list and returns integers — every shape in, one type out. The is_array() question is gone, because the answer stopped mattering.
Normalise at the sink, not at whichever boundary happens to sit in front of it today. The REST schema was a real check in the right place. It was never the only way into WP_Query.
7.0.2 also added a guard that neither advisory asks for. serve_request() now refuses to begin a fresh top-level REST cycle while one is already in flight.
Nothing in either CVE requires this. The replay move does — it re-enters the REST server from inside a request the server is already serving. The fix closes that, and it would close the next chain that tried the same approach. Defence in depth is cheapest while the file is already open.
Note what the fix does not do. It does not remove /batch/v1, does not put it behind authentication, and does not add a check that the path a member names is the path it ends up running. None of those was the problem.
A batch endpoint that dispatches many routes from one anonymous request is a reasonable thing to ship, and it was reasonable here. The rule is narrower than the endpoint: a validation result has to travel with the thing it validated, not beside it. Once a member, its match and its verdict are one record, there is no index left to get wrong.