Skip to content

Commit e3d1089

Browse files
committed
External sites see past bot checks, follow redirects, and keep blank pages
A site behind Imperva showed its screenshot as a flat white page and about seventy open ports. The stripped down headless browser was stopped by Imperva's bot check, which answers the first request with a small page whose script sets a cookie and reloads, and the ports were Imperva's edge accepting every connection. Screenshots now come from Playwright driving full Chromium in its newer headless mode, with the automation flag hidden and an ordinary window, language and time zone. It waits for a bot check to move on to the site and follows HTTP, meta refresh and script redirects, so the page it ends on is the one recorded, with its title and address as the site's final URL. A check counts as passed when the page moved on, since Imperva puts its script on real pages too. Every request the browser makes is still held to public addresses unless private ones are allowed. The old capture remains as the fallback, and the image now carries full Chromium as well. A page that loads and is genuinely empty keeps its white screenshot, marked blank, instead of being thrown away as a failure. The CDN in front and any bot check are named on the site, and when nearly every port answers, open ports are shown as the CDN and not reported as findings.
1 parent 4d2d58b commit e3d1089

11 files changed

Lines changed: 397 additions & 22 deletions

File tree

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ RUN python3 -m venv /opt/gcr/venv \
234234
&& ln -sf /opt/gcr/dast-venv/bin/wapiti /usr/local/bin/wapiti \
235235
&& ln -sf /opt/gcr/dast-venv/bin/sqlmap /usr/local/bin/sqlmap \
236236
&& PLAYWRIGHT_BROWSERS_PATH=/opt/gcr/browsers \
237-
/opt/gcr/dast-venv/bin/python -m playwright install chromium-headless-shell \
237+
/opt/gcr/dast-venv/bin/python -m playwright install chromium chromium-headless-shell \
238238
&& chmod -R a+rX /opt/gcr/browsers \
239239
&& wapiti --version && sqlmap --version \
240240
&& rm -rf /wheels /tmp/requirements.txt /tmp/requirements-scanner.txt \

‎app/core/browser_shot.py‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
"""Load one page in a real browser engine and photograph it.
3+
4+
Run as its own process with the DAST environment's Python, where Playwright
5+
lives; nothing from the app is imported. The answer is one line of JSON on
6+
standard output.
7+
8+
Why a real browser and not a single request: many sites sit behind a bot
9+
check (Imperva, Cloudflare and others) that answers the first request with a
10+
small page whose script sets a cookie and reloads into the real site. curl
11+
sees that small page and stops. A stripped down headless browser is stopped
12+
by it and paints nothing. Full Chromium in its newer headless mode, with the
13+
automation flag hidden and an ordinary window, language and time zone, gets
14+
through most of them, so this waits for the page to replace itself before
15+
taking the picture.
16+
17+
Every request the page makes is checked against private addresses unless
18+
they are allowed, so a page cannot steer the browser into the network this
19+
server sits in.
20+
21+
browser_shot.py URL OUT.png --user-agent UA [--timeout 45] [--allow-private]
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import ipaddress
28+
import json
29+
import os
30+
import re
31+
import socket
32+
import sys
33+
import time
34+
from urllib.parse import urlparse
35+
36+
BOT_CHECKS = [
37+
("Imperva", r"_Incapsula_Resource|Incapsula incident ID"),
38+
("Cloudflare", r"/cdn-cgi/challenge-platform|<title>Just a moment\.\.\.</title>|cf-chl-"),
39+
("Akamai", r"bm-verify|akamai-bot"),
40+
("PerimeterX", r"px-captcha|_pxAppId"),
41+
("DataDome", r"captcha-delivery\.com"),
42+
]
43+
HIDE_AUTOMATION = """
44+
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
45+
Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
46+
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
47+
window.chrome = window.chrome || {runtime: {}};
48+
"""
49+
50+
51+
def bot_check(html: str) -> str:
52+
if len(html or "") > 30000:
53+
return ""
54+
for name, pattern in BOT_CHECKS:
55+
if re.search(pattern, html or "", re.I):
56+
return name
57+
return ""
58+
59+
60+
_public: dict = {}
61+
62+
63+
def public(host: str) -> bool:
64+
if not host:
65+
return False
66+
if host in _public:
67+
return _public[host]
68+
try:
69+
addrs = {i[4][0] for i in socket.getaddrinfo(host, None)}
70+
ok = bool(addrs) and all(ipaddress.ip_address(a.split("%")[0]).is_global for a in addrs)
71+
except (OSError, ValueError):
72+
ok = False
73+
_public[host] = ok
74+
return ok
75+
76+
77+
def main() -> int:
78+
ap = argparse.ArgumentParser()
79+
ap.add_argument("url")
80+
ap.add_argument("out")
81+
ap.add_argument("--user-agent", required=True)
82+
ap.add_argument("--timeout", type=int, default=45)
83+
ap.add_argument("--allow-private", action="store_true")
84+
args = ap.parse_args()
85+
out = {"ok": False, "title": "", "url": args.url, "status": 0, "bot_check": "",
86+
"bot_check_seen": "", "engine": "", "error": ""}
87+
deadline = time.monotonic() + args.timeout
88+
try:
89+
from playwright.sync_api import sync_playwright
90+
except ImportError:
91+
out["error"] = "playwright is not installed"
92+
print(json.dumps(out))
93+
return 0
94+
95+
proxy = (os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
96+
or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY"))
97+
launch = {"args": ["--disable-blink-features=AutomationControlled", "--no-sandbox",
98+
"--disable-dev-shm-usage", "--disable-gpu"]}
99+
if proxy:
100+
launch["proxy"] = {"server": proxy}
101+
try:
102+
with sync_playwright() as p:
103+
try:
104+
# Full Chromium in the new headless mode looks like a browser.
105+
browser = p.chromium.launch(channel="chromium", **launch)
106+
out["engine"] = "chromium"
107+
except Exception: # noqa: BLE001 - only the headless shell is there
108+
browser = p.chromium.launch(**launch)
109+
out["engine"] = "headless shell"
110+
ctx = browser.new_context(user_agent=args.user_agent,
111+
viewport={"width": 1280, "height": 800},
112+
locale="en-US", timezone_id="America/New_York",
113+
ignore_https_errors=True)
114+
ctx.add_init_script(HIDE_AUTOMATION)
115+
if not args.allow_private:
116+
ctx.route("**/*", lambda route: route.continue_()
117+
if public(urlparse(route.request.url).hostname or "")
118+
else route.abort("addressunreachable"))
119+
page = ctx.new_page()
120+
# A check that passes reloads or redirects into the site. Imperva
121+
# also puts its script on the real pages, so the script being on
122+
# the final page says nothing; the page having moved on does.
123+
moves = {"n": 0}
124+
page.on("framenavigated",
125+
lambda frame: moves.__setitem__("n", moves["n"] + 1)
126+
if frame == page.main_frame else None)
127+
resp = page.goto(args.url, wait_until="domcontentloaded",
128+
timeout=max(5000, int((deadline - time.monotonic()) * 1000)))
129+
out["status"] = resp.status if resp else 0
130+
# A bot check replaces itself with the site. Wait for that, then
131+
# for the site to settle, but never past the deadline.
132+
while time.monotonic() < deadline - 5:
133+
try:
134+
name = bot_check(page.content())
135+
except Exception: # noqa: BLE001 - mid navigation
136+
name = "?"
137+
if not name or moves["n"] >= 2:
138+
break
139+
if name != "?":
140+
out["bot_check_seen"] = name
141+
page.wait_for_timeout(1500)
142+
try:
143+
page.wait_for_load_state("networkidle",
144+
timeout=max(1000, int(min(10, deadline - time.monotonic() - 4) * 1000)))
145+
except Exception: # noqa: BLE001 - a page that keeps polling never idles
146+
pass
147+
html = page.content()
148+
out["bot_check"] = bot_check(html)
149+
if out["bot_check"]:
150+
out["bot_check_seen"] = out["bot_check"]
151+
if moves["n"] >= 2:
152+
out["bot_check"] = ""
153+
out["navigations"] = moves["n"]
154+
out["title"] = (page.title() or "")[:200]
155+
out["url"] = page.url
156+
page.screenshot(path=args.out, full_page=False)
157+
out["ok"] = True
158+
browser.close()
159+
except Exception as exc: # noqa: BLE001 - every failure is an answer
160+
out["error"] = f"{type(exc).__name__}: {str(exc).splitlines()[0][:200]}"
161+
print(json.dumps(out))
162+
return 0
163+
164+
165+
if __name__ == "__main__":
166+
sys.exit(main())

‎app/core/dast_report.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,14 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
302302
limitations.append("Signing in to this site goes through an outside service. A "
303303
"session cookie set in the site's sign in settings lets a scan "
304304
"get past it.")
305+
tele_now = external.telemetry_of(asset)
306+
if tele_now.get("bot_check"):
307+
limitations.append(f"{tele_now['bot_check']}'s bot check answers in front of the site. "
308+
"Unless this server's address is let through it, the engines saw the "
309+
"check instead of the site, and an empty result means little.")
310+
if tele_now.get("ports_unreliable"):
311+
limitations.append("Nearly every port answers, because a CDN or firewall in front accepts "
312+
"every connection. Open ports were not reported as findings.")
305313
if asset.whitelist_needed:
306314
limitations.append("The site needs the scanner's address let through a firewall. If "
307315
"it was not, parts of the site were blocked from the scan.")

‎app/core/external.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,7 @@ def combined_risk(asset: ExternalAsset, counts: dict) -> dict:
806806
("probe_url", "Probe URL"), ("final_url", "Final URL"),
807807
("redirect_target", "Redirect target"), ("title", "Page title"),
808808
("server", "Server header"), ("powered_by", "X-Powered-By"),
809+
("cdn", "CDN or firewall"), ("bot_check", "Bot check"),
809810
("waf_block", "Firewall block page"), ("origin_down", "CDN could not reach the site"),
810811
("http_to_https", "HTTP redirects to HTTPS"),
811812
("grade", "Grade"), ("score", "Score"),
@@ -874,19 +875,29 @@ def flatten(rec: dict) -> dict:
874875
"category": external_probe.CATEGORY_NAMES.get(rec.get("category"), rec.get("category") or ""),
875876
"status_code": rec.get("status") or "",
876877
"probe_url": f"https://{rec['host']}/" if rec.get("host") else "",
877-
"final_url": rec.get("final_url") if rec.get("status") else "",
878+
"final_url": (rec.get("rendered_url") or rec.get("final_url")) if rec.get("status") else "",
878879
"redirect_target": (rec.get("redirects") or [""])[-1],
879-
"title": rec.get("title"), "server": rec.get("server"),
880+
# What a browser shows beats what the first answer said: behind a
881+
# bot check the first answer is the check, with no title at all.
882+
"title": rec.get("rendered_title") or rec.get("title"), "server": rec.get("server"),
880883
"powered_by": rec.get("powered_by"),
881884
"waf_block": rec.get("waf_block") if rec.get("status") else None,
882885
"origin_down": rec.get("origin_down") if rec.get("status") else None,
886+
"cdn": rec.get("cdn"),
887+
"bot_check": (f"{rec['bot_check']}'s, passed by a real browser; scanners still meet it"
888+
if rec.get("bot_check") and rec.get("bot_check_passed") else
889+
f"{rec['bot_check']}'s bot check answers instead of the page"
890+
if rec.get("bot_check") else ("none seen" if rec.get("status") else "")),
883891
"http_to_https": rec.get("http_to_https"),
884892
"grade": score.get("grade") if score.get("grade") != "N/A" else "",
885893
"score": score.get("score") if score.get("grade") not in (None, "N/A") else "",
886894
"ip": rec.get("ip"), "ips": rec.get("ips"), "ipv6": dns.get("ipv6"),
887895
"ptr": dns.get("ptr"), "isp": rec.get("isp"), "asn": who.get("asn"),
888896
"netblock": who.get("netblock"),
889-
"open_ports": [str(p) for p in rec.get("ports") or []],
897+
"open_ports": ([f"{len(rec.get('ports') or [])} of the ports checked answer, which is "
898+
"the CDN or firewall in front accepting everything, not the site's "
899+
"own services"] if rec.get("ports_unreliable")
900+
else [str(p) for p in rec.get("ports") or []]),
890901
"nameservers": dns.get("nameservers"), "cname": dns.get("cname"),
891902
"mx": dns.get("mx"), "registrar": who.get("registrar"),
892903
"domain_created": who.get("domain_created"),
@@ -1041,7 +1052,7 @@ def asset_dict(asset: ExternalAsset, links: list = None, counts: dict = None,
10411052
"waf_block": bool(tele.get("waf_block")),
10421053
"screenshot": asset.screenshot,
10431054
"screenshot_url": (f"{base_url}/external/{asset.id}/screenshot.png"
1044-
if asset.screenshot == "ok" else ""),
1055+
if asset.screenshot in ("ok", "blank") else ""),
10451056
},
10461057
"fields": {row["key"]: {"value": row["value"], "scan": row["scan"],
10471058
"manual": row["manual"]}
@@ -1169,7 +1180,7 @@ def save_telemetry(asset_id: int, rec: dict) -> None:
11691180
asset.category = rec.get("category") or ""
11701181
if rec.get("shot"):
11711182
asset.screenshot = rec["shot"][:16]
1172-
if rec["shot"] != "ok":
1183+
if rec["shot"] not in ("ok", "blank"):
11731184
shot_path(asset_id).unlink(missing_ok=True)
11741185

11751186

‎app/core/external_dast.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,10 @@ def get(url, **kw):
159159
count += 1
160160
return probe_mod.fetch(url, allow_private=allow_private, **kw)
161161

162-
# ---- ports that should not face the internet
163-
for port in rec.get("ports") or []:
162+
# ---- ports that should not face the internet. Not when nearly every
163+
# port answers: that is a CDN or firewall accepting everything, and a
164+
# finding for each would be a list of things that are not there.
165+
for port in ([] if rec.get("ports_unreliable") else rec.get("ports") or []):
164166
if port in probe_mod.RISKY_PORTS:
165167
what = probe_mod.RISKY_PORTS[port]
166168
findings.append(_finding(

0 commit comments

Comments
 (0)