Skip to content
Hacking Two Automakers: ForgeRock Path Confusion, RCE, and Remote Vehicle Access

Hacking Two Automakers: ForgeRock Path Confusion, RCE, and Remote Vehicle Access

Samcurry • November 7, 2026

In early 2026, Shubham Shah and I found a vulnerability chain that gave us remote code execution on the production ForgeRock servers handling customer logins for two automakers, which we'll call Automaker #1 and Automaker #2 Obviously, they have asked not to be named, and while normally we would fight to actually include the company names, it's a busy time in the security world and I don't like the idea of them diverting resources because they are worried something with this disclosure. . From those servers, we could create a valid session for any customer account managed by the affected deployments. If the account had a connected vehicle, the session gave us the same remote access as the owner's app: its location and telemetry, door lock and unlock, and remote engine start and stop.

After we reported the vulnerability, both automakers fixed it within two hours and rotated the affected credentials. The affected systems served customers in multiple regions; we tested the complete chain only with US accounts and vehicles.

Investigating the Mobile Apps

In the past few years, we'd found ways to remotely control vehicles from Honda, Acura, Nissan, Infiniti, Hyundai, Genesis, Ford, Porsche , Kia , and Subaru , but we'd never found a vulnerability that enabled remote access to either an Automaker #1 or Automaker #2 vehicle.

Both apps exposed the normal connected-car functionality we expected. Once signed in, an owner could see every vehicle and VIN in their garage along with its location, mileage, fuel or battery range, tire pressure, door and window state, vehicle health, alerts, and subscription status. Depending on the model and subscription, the same app could lock or unlock the doors, start or stop the engine, sound the horn, flash the hazards, or locate the vehicle.

To understand how those features worked behind the interface, Shubham and I downloaded the Android versions of both automakers' apps, pulled them apart, and traced their authentication flows side by side. Both apps led back to ForgeRock, an identity platform both automakers used to manage customer accounts and logins.

We first documented the normal login flow. Automaker #1 authenticated its users through login.automaker1.example , while Automaker #2 used login.automaker2.example . The two flows were nearly identical, so we used the US Automaker #1 exchange as the representative example:

The request started Automaker #1's MobileAppSignIn journey inside the consumer-native realm. ForgeRock used Accept-API-Version to select the API behavior expected by the app, while X-Make , X-Market , and Accept-Language identified the brand, market, and locale. Those values selected the regional telematics services used by the app.

The empty JSON object did not contain a username or password. It asked the server which step should come first. ForgeRock responded with an opaque authId and a callback describing the value it wanted:

The app filled in the requested input and sent the entire document back to the same endpoint. ForgeRock then returned the callback, so the server controlled the journey through the account, device, verification, and OTP steps.

Successful completion returned an authenticated ForgeRock session, but not yet permission to call the vehicle APIs. The app exchanged that session for the mobile app tokens used by the rest of its account and vehicle requests.

Automaker #2 followed the same sequence on its branded hostname. It selected the AppSignIn_v2 tree instead of MobileAppSignIn , sent X-Make: B , and included its ForgeRock client-version header. In the US profiles Shubham and I traced, both apps otherwise used the same consumer-native realm, mobileappsdkclient OAuth client, callback URI, and server-driven callback model. The two apps looked like brand-specific entrances to the same identity platform.

Bypassing the ForgeRock WAF

Tracing the shared login flow led us through two parts of ForgeRock. ForgeRock Access Management, or AM, ran the callback journey and issued the login session. Identity Management, usually called IDM or OpenIDM, stored the customer records that AM used during that journey.

While following the shared login code, Shubham found an email-validation request backed by /openidm/endpoint/validateEmail . OpenIDM calls its customer identity records managed users and exposes them through ForgeRock's Common REST, or CREST, API under /openidm/managed/user . We began testing that managed-user route and the administrative paths normally found beside an OpenIDM deployment.

OpenIDM also runs its Java services as OSGi bundles inside Apache Felix. Felix's Web Console conventionally lives at /system/console ; its bundles plugin lists the code loaded into the process, and /system/console/configMgr edits live OSGi service configurations. ForgeRock's own production-hardening guide recommends removing or protecting the console and notes that it authenticates separately from IDM.

ConfigMgr was interesting because OpenIDM's scheduler can invoke inline scripts , and its script service supports Groovy . If ConfigMgr exposed the OpenIDM schedule factory, a console configuration could run Groovy inside the identity process.

Direct requests to /openidm/managed/user , /system/console , /system/console/bundles , and /system/console/configMgr were blocked. Repeating them caused our IP to be denied on those administrative paths, while the normal login and email-validation routes continued to work. Shubham varied the URL encoding, method, and path separators and received different responses, which suggested that the edge was matching dangerous path patterns before the Java application finished interpreting them.

Requests beneath the site's normal /static asset prefix continued to reach the backend, so Shubham began combining that prefix with encoded slashes, dot segments, and semicolon path parameters. We wanted the edge to keep seeing a static request while the Java backend normalized it into one of the blocked routes. The results suggested that the two layers were reading the same URL differently: the edge decided whether to allow the incoming path, the servlet container decoded another form of it to select an OSGi servlet, and the selected application could parse it again.

The first target was OpenIDM's read-only /openidm/info/ping health resource. The path /static%2f..%2fopenidm/info/ping packed the traversal and servlet prefix into one raw segment, but the US edge returned an AWS load balancer 403 . Changing it to /static%2f..;/openidm/info/ping passed the edge, reached OpenIDM's authentication filter, and returned 401 without credentials.

Felix provided another way to test the same path behavior. Requesting /static%2f..;/system%2fconsole;/bundles/.json without an Authorization header returned 401 with WWW-Authenticate: Basic realm="OSGi Management Console" . Invalid credentials produced the same response. Shubham then tried the documented admin:admin default and requested the bundle inventory:

The encoded route reached the hidden console and returned its live bundle inventory:

The default credentials worked. Bundle zero identified Apache Felix 7.0.1 , 343 of the 361 bundles were active, and the same console session gave us access to ConfigMgr.

To understand why it worked, Shubham and I traced how Jetty and Felix handled the path. With /static%2f..;/system%2fconsole;/bundles/.json , Jetty's canonical path used for servlet selection decoded the slashes, stripped the semicolon path parameters, and resolved the /static/.. dot segment. The servlet container mapped the resulting /system/console/bundles/.json path to the Felix Web Console. Felix then read the canonical path information supplied by the servlet container and used the first remaining segment as the plugin name , so it selected bundles .

OpenIDM handled the same trick differently. ForgeRock registers /openidm as the outer HTTP servlet , then routes the remainder internally through CREST. For a normal /openidm/info/ping request, the servlet container consumes /openidm and CREST receives info/ping . ForgeRock's CREST adapter rebuilt that resource from the original encoded URI . Its ResourcePath parser splits only on literal slashes . In /static%2f..;/openidm/info/ping , static%2f..; and openidm remained two raw elements. CREST removed one element for the matched servlet and tried to route openidm/info/ping instead of info/ping .

The single-segment /static%2f..%2fopenidm/info/ping form would have left CREST with the clean resource, but the edge rejected it with 403 . The accepted /static%2f..;/openidm/info/ping form selected the OpenIDM servlet but left the extra openidm segment for CREST. Moving /openidm inside the path parameter as /static%2f..;%2fopenidm/info/ping also failed because Jetty discarded that parameter before servlet mapping. Felix was the route that survived both interpretations.

Getting Code Execution Through the OpenIDM Scheduler

Inside ConfigMgr, Shubham found the org.forgerock.openidm.schedule factory. Its configuration could tell the scheduler to invoke the script service, select Groovy as the engine, and supply the source code inline. The scripts ran inside OpenIDM with access to the surrounding OSGi runtime. Running commands and retrieving their output through one-shot schedule configurations would have been awkward, so we used one to install a small OSGi servlet that provided a stable request and response channel.

The console response set an INGRESSCOOKIE , which mattered because the OpenIDM hostname was load balanced. ConfigMgr could create the schedule on one backend node while a later request reached another, and the installed bundle lived only in the OSGi runtime that ran the schedule. We carried the affinity cookie through the create, enable, polling, cleanup, and command requests to keep the sequence on the same OpenIDM process.

Each ConfigMgr factory configuration received a persistent identifier, or PID. Creating one meant posting to the literal placeholder [Temporary PID replaced by real PID upon save] , URL-encoded in the path below. In the form body, apply=true told ConfigMgr to save the submission, factoryPid selected OpenIDM's schedule factory, propertylist=jsonconfig declared the configuration property being submitted, and jsonconfig carried the schedule itself as URL-encoded JSON.

The request nested a Groovy program inside schedule JSON inside a ConfigMgr form submission. Its encoded jsonconfig value began like this:

Decoding jsonconfig produced a disabled, non-persistent schedule configured to run once. Its invokeService selected OpenIDM's script service, while type: "groovy" selected the engine. The source field contained the installer:

The first POST saved the disabled configuration under a real PID and returned that identifier in its redirect location. Keeping enabled set to false let us read every field back before anything executed. The persisted and recoverable values controlled how the scheduler treated the job, while the ConfigMgr entry itself remained until we deleted it. Once enabled, the installer decoded an embedded OSGi JAR, verified its hash, installed it into the current OpenIDM bundle context, and started it:

FrameworkUtil.getBundle(groovy.lang.Script.class) located the OSGi bundle that owned the Groovy runtime and exposed its live BundleContext . The script used Felix's existing ShellService to install and start the JAR, verified that exactly one bundle with the expected symbolic name appeared, and checked the embedded file's SHA-256 before running it. Its finally block removed the temporary file and released the service reference whether installation succeeded or failed.

The installed JAR contained an OSGi bundle activator and an inner HTTP servlet; its manifest declared the activator and imported only the servlet and OSGi packages it needed:

When the bundle started, its activator registered the servlet through OSGi's HttpService . Sending a normal GET returned OK as a readiness check, while the command route required a separate proof token and a cmd form parameter. The servlet passed that command to the host shell, captured standard output and error, waited for completion, and returned the exit code. Stopping the bundle unregistered the servlet.

We executed the installer by posting back to the PID assigned by ConfigMgr with enabled changed from false to true :

We sent the enable request once and polled the Felix bundle inventory until the proof bundle appeared in the active state. With the bundle running, the one-shot schedule had completed its only job, so we deleted the server-generated ConfigMgr entry:

Deleting the schedule removed the trigger and prevented the installer from running again, while the proof bundle remained active as a controlled command endpoint.

Getting a Shell on the SSO Servers

Shubham reproduced the chain on one production OpenIDM node behind Automaker #1's customer login platform and another behind Automaker #2's platform. Together, the affected deployments managed consumer identities for both automakers across multiple regions. The servlet appeared as another OSGi HTTP alias behind the same public edge, and the Automaker #1 node answered the following ls request from inside its identity process:

The response listed the OpenIDM installation directory and returned a successful exit code:

The output was the root of a live OpenIDM installation, with conf , script , connectors , audit , secrets , db , and felix-cache visible beside its startup files. The servlet inherited the working directory and execution context of the production identity process that read and modified the customer records used during both automakers' logins. With command execution confirmed, Shubham inspected the process environment and found idm.username and idm.password , which formed a valid idmadmin credential pair for the local administrative API.

The recovered credentials also confirmed the routing behavior from earlier. Through the public hostname, /static%2f..;/openidm/info/ping passed authentication but returned 404 Resource 'openidm/info/ping' not found , proving that CREST kept the extra openidm segment and that the external route still could not reach a clean administrative resource.

Reaching OpenIDM Through Localhost

The WAF still blocked managed-user reads, but the command servlet could call OpenIDM directly over localhost. We sent an outer POST to the proof endpoint with the curl command in its cmd form field:

The missing URL scheme was intentional. Including in the cmd value caused the public WAF to block the outer request before it reached our servlet. Curl treated the scheme-less localhost:8080 target as HTTP, and once the command ran inside the OpenIDM process, the connection went directly over loopback. The recovered idmadmin headers authenticated the inner request, while X-OpenIDM-NoSession: true kept it stateless.

OpenIDM returned the matching managed-user object and its current revision. Each automaker's directory could be searched by name, email address, phone number, or consumer GUID, and known OpenIDM record IDs could retrieve objects directly. The idmadmin identity could modify any matching managed user across those production directories.

Changing a Customer's Email and Password

The public WAF blocked the PATCH as well, so the writes used the same outer POST and scheme-less curl target as the query. OpenIDM required the current _rev value on every update so that one administrator could not silently overwrite another administrator's changes. Each successful patch returned a new revision for the If-Match header:

The email patch returned an updated object and a fresh _rev . We reused the same proof endpoint, Basic header, and affinity cookie for a second outer POST . Its decoded cmd value selected a new password and cleared both flags that would otherwise force another password change during login:

Logging In Through ForgeRock

Those replacement credentials worked in the normal mobile-app login flow. We returned to the MobileAppSignIn journey, and when it reached email verification, ForgeRock sent a six-digit code to the address stored in the modified OpenIDM record.

To automate that step, we built email.samcurry.io , a small authenticated mail receiver with an inbox API. Each run generated a fresh address such as [email protected] , recorded the time immediately before requesting the code, and polled the inbox using the address's local part:

Our PoC followed only message URLs beneath the same inbox, ignored anything received before the recorded cutoff, extracted the labeled six-digit code from the newest message, and submitted it to ForgeRock. Some profiles requested a second code, so the cutoff advanced after every accepted message instead of reusing the first one. Completing the journey produced a valid tokenId for the selected account. Shubham separately completed the same callback model through Automaker #2's branded host and its AppSignIn_v2 journey.

Exchanging the Session for Mobile App Tokens

The ForgeRock tokenId proved the login but could not reach the vehicle APIs by itself. Automaker #1's app bridged that gap by exchanging the Access Management session for an OAuth authorization code:

The app used plain for both the PKCE challenge and verifier. ForgeRock redirected to com.automaker1.mobileapp:/oauth2Callback?code=a381750f-9aad-49c8-97eb-b060dcb20d05 , and the app exchanged that code for the account's access, refresh, and ID tokens:

The exchange returned the same access, refresh, and ID tokens issued to the official apps. The access token authenticated mobile app requests, while the ID token identified the consumer and supplied the GUID needed to retrieve the account.

Changing the email address and password did not invalidate the account owner's existing app session. During our authorized test, the owner's app remained logged in while our new session worked alongside it. Once the OAuth exchange finished, the PoC reread the exact OpenIDM object by its immutable _id , used its latest _rev to restore the original email address, and verified the change before continuing. Restoring the email did not invalidate the mobile app tokens we had just received, so the visible account address was back to normal while our session remained usable. The original password could not be restored because we never knew it, so the owner chose a new one after the test.

Push notification registration was also controlled by the mobile client. The ForgeRock device-print callback let us supply a new hardware ID with no push token, and the app separately registered its Firebase token through /autoapi/v3/notification/pushtoken after login:

Because both values came from the client, our PoC could register a push token we controlled instead of the token belonging to the owner's phone. This prevented the corresponding notifications from reaching the owner's phone while their existing app session remained active.

Retrieving the Customer's Vehicles

With the access token and consumer GUID, we first called /autoapi/v6/account to confirm that they resolved to the selected account. The bearer token authenticated the request, the GUID selected the consumer, and the remaining headers supplied the same device, brand, version, and market context as the Automaker #1 app:

The response matched the selected consumer, so we used the same headers to request the account's garage:

The response returned the account's vehicles, VINs, model information, ownership role, connected-service subscription state, remote capabilities, and an internal generation value. Vehicle-specific requests added the discovered VIN header, while the generation value selected the family of status and command routes expected by that vehicle.

Sending Commands to the Vehicle

Before sending anything, Shubham checked the vehicle state with the owner standing beside it. For vehicles on the app's current global REST branch, the remote endpoint accepted a short command name. The request used the VIN returned for the owner's test vehicle, so an unlock looked like this:

Remote start on the global route changed only the command to "engine-start" , while Automaker #1 vehicles identified as generation GEN17 followed the app's legacy branch and sent an expanded command body:

On the legacy Automaker #1 route, remote start used the RES command with a value of 1 . Automaker #2 exposed parallel REST and GraphQL branches: most vehicles used a REST endpoint with the Automaker #2 brand profile, while generation GEN24 moved to GraphQL. For the non- GEN24 branch, an unlock request looked like this:

The Automaker #2 GEN24 generation used the DispatchVehicleAction GraphQL operation and required the GraphQL application key and resolver key used by the Automaker #2 app. The request supplied the selected command through variables ; its query field contains the same mutation formatted beneath the request:

Formatted, the mutation passed command and autoResolveActions into dispatchVehicleAction and requested the identifiers and status fields needed to track the result:

The API accepting a command did not prove that the car had acted on it. We refreshed its status after every request and confirmed the physical result with the owner beside the vehicle. Shubham unlocked and started it, then stopped the engine and locked it again.

Account and Vehicle Access

We had started by wondering how a tap in either automaker's app became a command to a car. Following that path took us through far more of the identity stack than we expected. The path the WAF treated as static reached the hidden Felix console, ConfigMgr created an OpenIDM schedule, and the schedule installed an OSGi servlet that gave us a shell. Curling localhost from that shell reached customer records that the public WAF kept blocking.

The rewritten consumer record fed directly into the vehicle side of the app. Together, we completed the normal ForgeRock login journey, received mobile app tokens, retrieved the account's garage, and used the same routes as the official app. For accounts with active connected services, the chain gave us the same remote access as the owner: vehicle location and telemetry, account and vehicle data, current state, and supported commands including door unlock and remote engine start. Production identity access extended across the affected directories for both automakers in multiple regions, and we validated the complete account-to-vehicle chain with live US accounts and vehicles.

Early 2026: Production identity access was validated across the affected directories for both automakers in multiple regions, and the account-to-vehicle chain was demonstrated with US accounts and vehicles.

Early 2026: The vulnerability was reported to and received by both automakers' product security teams.

Same day: Both automakers remediated the vulnerability approximately two hours after receiving the report. The proof bundle was removed and the affected credentials were rotated.

This research was done by: