Skip to content

Commit f09052e

Browse files
committed
Say what a cross account finding saw, and drop protected paths from ffuf
A broken object access finding read "confirmed against a control", which said nothing about what was actually seen. It now spells out the proof: the address, that it came from the second account's own pages, the status each account got, whether the first account saw the same page as the second (so it read the other account's data), and whether a signed out request was turned away. It also says how it was reached: fetch the address as each account and signed out, and compare. Content discovery was reporting a protected path as a find. A 401 or 403 is the server refusing the path, not an exposed file, so it is no longer reported as a finding, only kept as coverage. Only a path the server actually serves is a lead worth showing.
1 parent 2b9dffc commit f09052e

2 files changed

Lines changed: 47 additions & 6 deletions

File tree

‎app/core/dast_checks/engine.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,11 +545,47 @@ def _run_two_account(spec: dict, ctx: dict, domains, budget: Budget) -> list:
545545
else:
546546
break
547547
if confirmations == retest and confirmations > 0:
548-
findings.append(_finding(spec, a, resp_a, url))
548+
f = _finding(spec, a, resp_a, url)
549+
f["evidence"] = _two_account_evidence(url, resp_a, resp_b, resp_anon)
550+
f["reproduce"] = _two_account_reproduce(url)
551+
findings.append(f)
549552
break # one solid finding per rule
550553
return findings
551554

552555

556+
def _two_account_reproduce(url) -> str:
557+
"""How the finding was reached, with the real address: fetch it as each
558+
account and signed out, and compare the pages."""
559+
return (f"Fetch {url} three ways and compare the responses: signed in as the "
560+
f"second account (it returns the object, so the object is the second "
561+
f"account's), signed in as the first account (it returns the same page, "
562+
f"so the first account read the second account's data), and signed out "
563+
f"(it is turned away). This is how the finding was confirmed.")
564+
565+
566+
def _two_account_evidence(url, resp_a, resp_b, resp_anon) -> str:
567+
"""Spell out what the two account check actually saw, so the finding is
568+
specific: the address, the status each account got, whether the first
569+
account saw the same page as the second, and whether a signed out visitor
570+
was turned away. These are the facts the differential fired on."""
571+
a_st = (resp_a or {}).get("status")
572+
b_st = (resp_b or {}).get("status")
573+
an_st = (resp_anon or {}).get("status")
574+
same_body = (resp_a and resp_b
575+
and _normalize(resp_a.get("body") or "") == _normalize(resp_b.get("body") or ""))
576+
bits = [f"The address {url} was found in the second account's own pages.",
577+
f"Signed in as the first account it answered HTTP {a_st}, and the second "
578+
f"account's own session got HTTP {b_st}"
579+
+ ("; both accounts saw the same page, so the first account read the "
580+
"second account's data" if same_body else "") + "."]
581+
if resp_anon is not None:
582+
bits.append(f"A signed out request got HTTP {an_st}"
583+
+ (", so the page is not simply public." if an_st != 200
584+
else ", so a signed out visitor sees it too."))
585+
bits.append("Confirmed and re-tested.")
586+
return " ".join(bits)[:800]
587+
588+
553589
def _run_probe(spec: dict, client: Client, route: str, oob_value: str) -> dict:
554590
"""Send the probe steps; the last response is what the differential reads.
555591

‎app/core/dast_engines.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -601,20 +601,25 @@ def parse_dalfox(report: Path, url: str):
601601

602602

603603
def parse_ffuf(report: Path, url: str):
604-
"""Paths it found. Only the ones whose names mean something become
605-
findings, and as info: a path existing is a lead, not a problem. Every
606-
path is handed on to nuclei."""
604+
"""Paths it found. Only the ones whose names mean something AND that are
605+
actually served become findings, and as info: a path existing is a lead,
606+
not a problem. A 401 or 403 means the path is protected, not exposed, so
607+
it is not a finding, only coverage. Every path is handed on to nuclei."""
607608
data = _read_json(report)
608609
if data is None:
609610
return None
610611
out, paths = [], []
611612
for row in data.get("results") or []:
612613
found = row.get("url") or ""
613614
paths.append(found)
615+
status = int(row.get("status") or 0)
614616
word = (row.get("input") or {}).get("FUZZ", "")
615-
if _FFUF_INTERESTING.search(word):
617+
# Only a page the server actually serves (a 200) is a lead worth
618+
# reporting. A 401/403 is the server refusing it, a 3xx is a redirect
619+
# away from it; none of those is an exposed file.
620+
if _FFUF_INTERESTING.search(word) and status in (200, 206):
616621
out.append(_finding("ffuf", f"Found {word}", "info", found,
617-
f"HTTP {row.get('status')}, {row.get('length')} bytes",
622+
f"HTTP {status}, {row.get('length')} bytes",
618623
"Check this path is meant to be public."))
619624
return out, paths
620625

0 commit comments

Comments
 (0)