8 hops from a license plate to an unlocked car door
8 hops from a license plate to an unlocked car door Two researchers turned a license plate into a working door-unlock command for any connected car from two automakers β and both vendors fixed it within about two hours
8 hops from a license plate to an unlocked car door
Two researchers turned a license plate into a working door-unlock command for any connected car from two automakers β and both vendors fixed it within about two hours of disclosure. The whole chain is 8 hops long, and the only hop that needed real cleverness was a URL the WAF and the backend read differently.
The chain
Shubham Shah and Sam Curry published the writeups on November 7, 2026 β Shah covering the Australian leg, Curry the US, Canadian, and European leg. Same ForgeRock identity platform underneath, same disclosure, two posts. The Australian chain, start to finish:
license plate -> VIN -> subscriber GUID -> OpenIDM admin creds
-> WAF bypass -> MFA bypass -> app token -> door-unlock command
- Plate β VIN. Australian state registration lookups hand out a VIN for a plate. Not a vulnerability β that's a public lookup by design.
- VIN β subscriber GUID. The admin tool's API mapped a VIN to the subscriber record.
-
Subscriber GUID β account PII. The
/v1/accountendpoint returned the full account, including the email registered on the ForgeRock identity platform. - Admin credentials in a JavaScript bundle. A vendor JS bundle shipped with the admin tool embedded OpenIDM admin credentials. Meant for staging, worked in production.
-
WAF bypass. The edge blocked
/openidm/*outright β but a traversal hidden behind encoded slashes got through. - MFA bypass. The Australian environment's multi-factor step could be skipped entirely via a Resource Owner Password Credentials grant.
-
Mint an app token. Password reset via
PATCH /openidm/managed/user/<id>, then the ROPC flow, then a token exchange for a mobile-app access token. -
Send the command.
POST /v1/remote/route/commandwith{"command": "door-unlock"}. The doors unlocked β on cars they owned or had permission to touch.
Curry's companion post is the wilder half: the same ForgeRock deployment also gave them remote code execution on the production identity servers β via the Felix OSGi console at /system/console, default credentials admin:admin, and a Groovy script smuggled in through the OpenIDM scheduler. From there they could create a valid session for any customer account, which meant location, telemetry, lock/unlock, and remote engine start/stop for any connected vehicle on those deployments. Both automakers fixed it within about two hours and rotated the credentials. The researchers tested the full chain only on US accounts and vehicles they had permission for, and the automakers asked not to be named.
I re-ran the encoding trick locally
I couldn't test the automakers' edge β and wouldn't. But the core mechanism is a parser differential, and that I can measure. The writeups say the edge "decided whether to allow the incoming path" while "the servlet container decoded another form of it to select a servlet." So I fed the exact strings from the posts to two real parsers on my machine:
import posixpath
from urllib.parse import unquote
paths = [
"/openidm/endpoint/aaa",
"/XUI/..%2fopenidm/managed/user",
"/XUI%2f..%2fopenidm/managed/user",
"/static%2f..%2fopenidm/managed/user",
]
for p in paths:
print(repr(p), "->", repr(posixpath.normpath(unquote(p))))
'/openidm/endpoint/aaa' -> '/openidm/endpoint/aaa'
'/XUI/..%2fopenidm/managed/user' -> '/openidm/managed/user'
'/XUI%2f..%2fopenidm/managed/user' -> '/openidm/managed/user'
'/static%2f..%2fopenidm/managed/user' -> '/openidm/managed/user'
A layer that decodes %2f and normalizes .. sees the admin route in all three traversal strings. Now the same strings through Node's WHATWG URL parser, which keeps %2f encoded in pathname:
'/openidm/endpoint/aaa' -> '/openidm/endpoint/aaa'
'/XUI/..%2fopenidm/managed/user' -> '/XUI/..%2fopenidm/managed/user'
'/XUI%2f..%2fopenidm/managed/user' -> '/XUI%2f..%2fopenidm/managed/user'
'/static%2f..%2fopenidm/managed/user' -> '/static%2f..%2fopenidm/managed/user'
A layer that reads the path the WHATWG way sees an app page under /XUI/ or a static asset β never the admin route. Same bytes, two different routes. That is the whole vulnerability: not a bug in either parser, but the fact that the filter and the backend disagreed about what the URL meant.
Then I built the two layers for real: a toy WAF proxy on port 18080 that blocks the literal prefixes /openidm/ and /system/console on the raw request target, forwarding everything else to a backend on 18081 that decodes percent-encoding and normalizes dot segments before routing. Real HTTP, real status codes:
403 /openidm/managed/user -> WAF: blocked
403 /system/console/bundles -> WAF: blocked
200 /XUI/..%2fopenidm/managed/user -> OPENIDM ADMIN API
200 /XUI%2f..%2fopenidm/managed/user -> OPENIDM ADMIN API
200 /static%2f..%2fopenidm/managed/user -> OPENIDM ADMIN API
200 /static%2f..;/openidm/info/ping -> static asset (no ;param handling)
200 /static%2f..;/system%2fconsole;/bundles/.json -> static asset (no ;param handling)
Three of the five encoded variants reached the admin handler past my naive prefix-checking WAF. The real edge did better than my toy β it hard-403'd the /XUI%2f..%2f form β and still missed the /static%2f..%2f form, which is the one that mattered. The semicolon forms are the interesting miss in my model: the real Jetty stripped ; path parameters before servlet mapping, which is how /static%2f..;/system%2fconsole;/bundles/.json reached the Felix console in the US chain. My backend doesn't implement that, so it served a static asset. The lesson survives the mismatch: every layer in the stack β edge, servlet container, application router β decodes the URL on its own schedule, and the attacker only needs one variant where the edge's reading is benign and the backend's reading is the admin route.
The password hash didn't matter
One detail from the Australian writeup deserves a pause. The getUser endpoint handed back password hashes β PBKDF2-SHA512. That sounds like a cracking problem. I measured my own box's rate at a few iteration counts, single-threaded:
iters= 1000: 1095.4 hashes/s | 26^6 candidates: 78.3 h ( 3.3 d)
iters= 10000: 117.0 hashes/s | 26^6 candidates: 733.1 h ( 30.5 d)
iters= 100000: 11.9 hashes/s | 26^6 candidates: 7202.4 h (300.1 d)
(The writeup doesn't give the iteration count, so I measured three. A 6-character lowercase password is 26^6 = 308,915,776 candidates.)
But the hash was never the obstacle. The same admin access that returned the hash also allowed PATCH /openidm/managed/user/<id> with {"operation": "replace", "field": "/password", "value": "Testing123!"} β a 200 OK, and the password was simply theirs now. The PBKDF2-SHA512 hash was a souvenir, not a barrier. That's the pattern across the whole chain: every defensive layer was real β the WAF blocked the obvious paths, the passwords were properly hashed, MFA existed β and every one of them had a second door that was simply open. The WAF blocked /openidm/ but not /static%2f..%2fopenidm/. MFA existed but the ROPC flow skipped it. The hash was strong but the password was replaceable.
What actually failed
Count the distinct failures and notice what kind they are:
Admin credentials embedded in a shipped JS bundle ............ 1
Admin tool + identity platform reachable from the internet ... 1
WAF that reads URLs differently from the backend ............. 1
MFA step skippable via an alternate grant .................... 1
Felix console exposed with default admin:admin ............... 1
Scheduler that runs inline Groovy as the identity process .... 1
None of these is a zero-day. The traversal is a 25-year-old technique, default credentials are the oldest finding in the book, and shipping secrets in a JS bundle is a mistake every web team has made once. What made the chain work was that six ordinary mistakes were stacked in one deployment, and the outermost layer β the WAF β was the one the team had invested in most heavily. The writeups are explicit that the edge had serious WAF rules; the researchers spent days on the bypass. The WAF wasn't absent. It was the last line, and it was also the first thing that failed, because it was guarding a door the backend didn't think was a door.
The fix, per Curry's post, took about two hours. That's the other number worth sitting with. Not two weeks of re-architecture β two hours to close the exposure and rotate the credentials. The vulnerability wasn't hard to fix. It was hard to see, because each layer looked secure from its own side of the glass.
Sources: Shubham Shah, "Unlocking any car from X brand in most of the world," shubs.io, November 7, 2026; Sam Curry, "Hacking Two Automakers: ForgeRock Path Confusion, RCE, and Remote Vehicle Access," samcurry.net, November 2026. The parser table, the WAF-proxy results, and the PBKDF2 rates are my own measurements on this machine (4-core VM, Python 3.12, Node WHATWG URL). The automakers are unnamed at their request; the researchers tested only on vehicles they owned or had permission to access.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.