
From an Email-Change IDOR to Zero-Click Account Takeover
How a body-supplied clientId, an attacker-directed OTP, and a login-error identifier leak chained into a zero-click account takeover.

How a body-supplied clientId, an attacker-directed OTP, and a login-error identifier leak chained into a zero-click account takeover.

recon since i can’t disclose the program let’s call it target.com. the app had a lot of functionality, and reading the js files was the move to understand those functions and find hidden endpoints. while analyzing the bundles i hit a really interesting block: // POST /api/posts/{id}/moderate — community "flag" (the BFLA endpoint), request schema only allows reason:"MATURE" aq=s.z.object({description:s.z.string().optional(),reason:s.z.literal("MATURE")}), aK=s.z.object({adult:s.z.boolean().optional(),message:s.z.string(),postId:s.z.string()}), aV=async(e,t,r)=>{let{data:a}=await e.post(`/api/posts/${t}/moderate`,aq,aK,r);return a}, // shared enums aG={NC17:"NC17",PG:"PG",R:"R"}, aH={BLOCKED:"BLOCKED",COMPLETE:"COMPLETE",DELETED:"DELETED",REVIEW:"REVIEW"}, // POST /api/posts/{id}/moderation — staff status/category path (correctly 403 for FREE) aY=s.z.object({category:s.z.enum(aG).optional().catch(void 0),reason:s.z.string().trim().min(1).max(500).optional(),status:s.z.enum(aH).optional().catch(void 0)}).refine(e=>void 0!==e.status||void 0!==e.category,{message:"At least one of status or category must be provided."}), aX=async(e,t,r)=>{let{data:a}=await e.post(`/api/posts/${t}/moderation`,aY,aY,r);return a}, // GET /api/posts/{id}/moderation — moderation decision log io=s.z.object({createdAt:s.z.string(),decision:s.z.string(),fromCat:s.z.enum(aG).nullable().catch(null),fromStatus:s.z.enum(aH).catch("REVIEW" ),id:s.z.string(),reason:s.z.string().nullable(),reviewerId:s.z.string().nullable(),toCat:s.z.enum(aG).nullable().catch(null),toStatus:s.z.enu m(aH).catch("REVIEW")}), is=s.z.object({data:s.z.array(io)}), il=async(e,t)=>{let{data:r}=await e.get(`/api/posts/${t}/moderation`,is);return r} the two endpoints (this is the whole bug) look closely — there are two near-identical moderation routes, one character apart: ...