June 8, 2026
How I Forced A/B Experiments on Any User via an Unauthenticated Endpoint (Missing Auth)
Hey Guys and Welcome Back π
httpzuz
6 min read
It's been a while since my last writeup, and this time I'm coming with something a little different β not an XSS and not a classic ATO, but a broken access control / missing authentication bug on a mobile target that got triaged as Medium (CWE-306 β Missing Authentication for Critical Function).
I love this one because it didn't come from a scanner or from fuzzing. It came from just reading the code and being curious about one single word i saw inside an endpoint => dev. That word was the whole thread i pulled. So grab a coffee and lets dig in
NOTE:_ the program is private and still Triaged (Open), so the target is REDACTED all over this writeup. Everything below i tested only on my own account._
My Testing Methodology
Before the steps, let me tell you how I approach any mobile program, because honestly the methodology is the real lesson here, not the bug itself.
Whenever i get a mobile target, I go static-first. I don't even open Burp on day one. The plan is always the same ( Also Claude help me in some cases):
- Pull the APK and merge the split files into one.
- Decompile it β
jadxfor the Kotlin/Java side, and since this app is React Native (Hermes), i also decompile theindex.android.bundlewithhermes-dec. - Read, don't guess. On a mobile app the Retrofit / API interfaces are a full map of the backend β every method is annotated with the exact HTTP verb and path. That's the goldmine. Why fuzz for endpoints when the app literally hands you the list?
- While reading, I'm hunting for 3 specific smells:
- dev/debug stuff left in production (paths with
dev,test,internal,adminβ¦) - missing authentication (an endpoint that does something sensitive without checking who you are)
- enumerable IDs (sequential integers instead of random tokens β IDOR/BOLA)
- Only after the static map is done, I move to dynamic testing on a real phone with
mitmproxy, to confirm what the code told me.
Keep these 3 smells in your head, because this bug is literally all 3 at once.
STEP 1 β Pulling the app apart
i grabbed the APK with apkeep, merged the splits with APKEditor, then threw it into jadx. The app is React Native + Kotlin, so I had two worlds to read β the Hermes bundle and the native Kotlin code.
i started grepping the Kotlin side for route definitions and anything that smelled like a feature toggle, an admin action, or a debug switch. And pretty fast, something caught my eye.
STEP 2 β A deeplink that talks about "REDACTED"
The app registers deeplinks (REDACTED://β¦ and https://www.REDACTED.com/β¦ App Links). There was a deeplink handler whose whole job was forcing READCTED, and the name alone made me stop. It parses a parameter straight off the link:
?REDACTED=REDACTED_KEY:VARIATION?REDACTED=REDACTED_KEY:VARIATIONIt splits the value on , (for multiple experiments) and then on : (key vs. variation), and applies them to the app's A/B testing system.
STEP 3 β Following it to the endpoint (the dev word)
Tracing the call chain from that handler led me to the Retrofit interface for the experiments backend. Two methods mattered:
@POST("dev/REDACTED/force-READCTED")
Object setVariations(@Query("visitorId") String visitorId,
@Query("customerId") String customerId,
@Body ChangeVariationRequest body, ...);
@GET("sdk/resolved-READCTED/{client}?visitorPlatform=android")
Object resolvedREADCTED(@Path("client") String client,
@Query("visitorId") String visitorId,
@Query("customerId") String customerId,
@Query("appVersion") String appVersion, ...);@POST("dev/REDACTED/force-READCTED")
Object setVariations(@Query("visitorId") String visitorId,
@Query("customerId") String customerId,
@Body ChangeVariationRequest body, ...);
@GET("sdk/resolved-READCTED/{client}?visitorPlatform=android")
Object resolvedREADCTED(@Path("client") String client,
@Query("visitorId") String visitorId,
@Query("customerId") String customerId,
@Query("appVersion") String appVersion, ...);There it is => dev/ sitting inside a path that ships in the production app. That single word is exactly the kind of thing that should never be reachable from the public internet.
And look at the shape of it:
force-variationstakes avisitorIdOR acustomerId+ a body listing experiments and which variation to pin them to.resolved-REDACTEDis the read side β give it the same identifier and it tells you which variation that user currently sees.
So now i had a clean hypothesis in my head: this is a QA / debug override endpoint, accidentally exposed in production, and it might not check authentication or ownership at all.
STEP 4β Confirming the app really calls it
i set up the dynamic lab => a non-rooted physical phone (MY own phone) a repackaged APK that trusts my user CA, and mitmproxy over adb reverse. Then, while logged out, i opened a crafted deeplink:
REDACTED://home?REDACTED=REDACTED-REDACTED-KEY:BREDACTED://home?REDACTED=REDACTED-REDACTED-KEY:BAnd the app fired this:
POST https://R.REDACTED.com/dev/REDACTED/force-variations?visitorId=<my-visitorId>
Body: {"forced-REDACTED":[{"REDACTEDKey":"REDACTED-REDACTED-KEY","variationKey":"B"}]}
β HTTP 204 No ContentPOST https://R.REDACTED.com/dev/REDACTED/force-variations?visitorId=<my-visitorId>
Body: {"forced-REDACTED":[{"REDACTEDKey":"REDACTED-REDACTED-KEY","variationKey":"B"}]}
β HTTP 204 No ContentA malformed link instead pops an "Invalid experiment link" snackbar, which confirmed the handler is really parsing my input. So the code told the truth.
STEP 5 β Is this thing even checking auth??
This is the part that turns a curiosity into a finding. i took that exact request into Burp and stripped it down => no Authorization header, no bearer token, no app session, and even a completely fake visitorId.
But a 204 means nothing until you read the state back, right? So i used the read endpoint. Here's a normal read of resolved experiments by visitorId:
Every experiment carries a discriminator field, and this is the key detail for later => some experiments are bucketed by visitorId (a random 32-char token) and some by customerId. You must read and force an experiment with the same discriminator it uses, otherwise it looks like nothing changed (this trips up a lot of people, it tripped me up at first too).
Forcing a visitorId-bucketed experiment with zero auth => clean 204:
AND BOOM β a live experiment flipped from B β A after my unauthenticated call, and the read endpoint started serving A. No token, no session, fake visitor id, and the server still did what i told it.
But hold on. A visitorId is a random 32-char token. The program (rightly) excludes IDOR when the identifier is an unguessable token, so on its own the visitorId path is basically "self-targeting." i needed the other discriminator to make this actually dangerousβ¦
STEP 6 β Making it target ANY user (the customerId)
Remember the endpoint also accepts a customerId? Well, my own account's customerId is a plain 9-digit number (mine ended in β¦651). Look at that for a second. It's not a random token β it's a sequential integer!!!!
That changes the whole game. An attacker doesn't need to leak a 32-char token, they can just count => β¦650, β¦651, β¦652β¦ and flip experiments on any user they want. And out of all the experiments that resolve for a user, 19 of them are customerId-bucketed.
So let me prove the full chain, end-to-end, on my own customerId.
BEFORE β my target experiment is at variation B
Reading by ?customerId=<my-customerId>, the experiment sits at B:
THE ATTACK β force it to A, fully unauthenticated
One single request, no auth token anywhere (just the program-mandated X-HackerOne-Research header), and the server replies 204 No Content:
POST /dev/REDACTED/REDACTED-variations?customerId=<my-customerId> HTTP/2
Host: R.REDACTED.com
Content-Type: application/json
X-HackerOne-Research: httpzuz
{"forced-REDACTED":[{"REDACTEDKey":"REDACTED-REDACTED-KEY","variationKey":"A"}]}POST /dev/REDACTED/REDACTED-variations?customerId=<my-customerId> HTTP/2
Host: R.REDACTED.com
Content-Type: application/json
X-HackerOne-Research: httpzuz
{"forced-REDACTED":[{"REDACTEDKey":"REDACTED-REDACTED-KEY","variationKey":"A"}]}AFTER β the same customerId now resolves to A
Reading it back, the experiment is now A (and its internal version jumped 77 β 78, which means the server actually saved my change):
Re-send the force with "variationKey":"B" and it flips right back. The served variation follows whatever i force => B β A β B, every request returning 204. Unauthenticated, deterministic, and keyed by an enumerable integer. Game over π―
So⦠how do you actually deliver this?
Two ways:
- Zero-click deeplink β send a victim
https://www.REDACTED.com/x?REDACTED=KEY:VARIATION(or theREDACTED://version) and their own app forces the experiment on their own identity. No interaction beyond opening the link. - Direct unauthenticated API β since the endpoint needs no auth and
customerIdis sequential, you can just hit the API for anycustomerIddirectly. No deeplink, no victim interaction at all.
All Screenshots of PoC :
Thanks for reading If you learned something new or got a fresh idea, share it with a friend and drop a clap. i'll see you in the next one