Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion capabilities/web-security/capability.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema: 1
name: web-security
version: "1.11.0"
version: "1.12.0"
description: >
Web application penetration testing with 82 attack technique playbooks
covering HTTP desync/request smuggling, cache poisoning, SSRF, SSTI, DOM
Expand Down
148 changes: 147 additions & 1 deletion capabilities/web-security/skills/dom-vulnerability-detection/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ rg "eval\(|setTimeout\(|setInterval\(|new Function\(" --type js -n src/

# Search for postMessage handlers without origin checks
rg "addEventListener.*message" --type js -n src/

# ...but that grep alone MISSES most real-world handlers. Also search:
rg "onmessage\s*=" --type js -n src/ # setter assignment
rg "MessagePort|MessageChannel|\.port[12]?\." --type js -n src/ # MessageChannel
```

**A grep for `addEventListener("message")` is not a complete listener inventory.** See
*Listeners you will miss* below before concluding a page has no handlers.

### 2. Trace data flow
For each sink found, trace backwards: does attacker-controlled input reach it?
- Direct: `element.innerHTML = location.hash.slice(1)`
Expand Down Expand Up @@ -51,7 +58,97 @@ window.addEventListener('message', (e) => {

**targetOrigin bypass via IP normalization:** When `postMessage(data, targetOrigin)` uses regex validation like `/https?:\/\/[^.]+[.]target[.]com/`, the `[^.]+` class matches `/` -- so `http://2130706433/.target.com` passes the regex. The browser's URL parser then normalizes the integer IP to `127.0.0.1` and sends the message to `http://127.0.0.1` (attacker-controlled). Same technique works with hex (`0x7f000001`) and octal IP forms. Check: does the sender validate `targetOrigin` with regex rather than strict string equality? If yes, test integer IP + path injection.

**Checkpoint:** For each handler, verify: (1) strict `e.origin` equality check exists, (2) no `window.origin` comparison, (3) no `startsWith`/`endsWith` on origin, (4) data is not passed to dynamic execution (`window[data.func]`).
#### Origin validation anti-patterns

Anything other than strict equality against a fixed string is suspect. Full operator list:

| Pattern | Why it fails | Bypass |
|---|---|---|
| `origin.indexOf('example.com') !== -1` | substring match anywhere | `https://evil-example.com`, `https://example.com.evil.tld` |
| `origin.includes('example.com')` | same as above | same |
| `origin.startsWith('https://example.com')` | no end boundary | `https://example.com.evil.tld` |
| `origin.endsWith('example.com')` | no start boundary | `https://evilexample.com` |
| `origin.search('example.com')` | substring, and `.` is a regex wildcard | `https://exampleXcom` |
| `origin == x` / `origin != x` | loose equality | type-juggling edge cases |
| `e.origin.match(/re/)` without anchors | matches anywhere in string | see regex rules below |

**Regex quality — two bugs that look correct at a glance:**

1. **Unescaped dot.** `/^https:\/\/trusted.example\.com$/` — the first `.` is a wildcard, so
`https://trustedXexample.com` passes. Check every `.` between the scheme and TLD is `\.`.
2. **Missing end anchor.** `/^https:\/\/trusted\.example\.com/` (no `$`) allows any suffix, so
`https://trusted.example.com.evil.tld` passes.

Both are trivially missed in review. When you see an origin regex, read it character by character for
unescaped `.` and a terminating `$`.

**Also flag:** `postMessage(data, '*')` in the *reply* path. A handler may validate the inbound origin
correctly and then leak the response to any listener via a wildcard `targetOrigin`.

#### Listeners you will miss

A registered listener is often **not** the function the app author wrote. Error-monitoring and framework
libraries wrap handlers, so reading the registered function shows you the monitoring shim, not the logic.
Unwrap before reviewing:

| Library | Tell | Recover original from |
|---|---|---|
| Sentry | `fn.__sentry_original__` is a function | `fn.__sentry_original__` |
| New Relic | `fn["nr@original"]` present | `fn["nr@original"]` |
| Rollbar | `fn._isWrap`, `rollbarContext`/`rollbarWrappedError` in source | `fn._wrapped`, else `fn._rollbar_wrapped` |
| Raven | `.deep…apply…captureException` in source | the single function-valued own property |
| Bugsnag | `autoNotify`/`notifyException` in source, `fn.bugsnag` is a function | **not recoverable** — read the app handler from source instead |
| Bugsnag (alt) | `fn.__trace__` is a function | **not recoverable** — same |
| Zone.js / Vue / React | framework zone or error-boundary wrapper | varies; unwrap by inspecting own properties |

Unwrapping is recursive — a handler can be wrapped more than once (e.g. Sentry inside Zone.js).

Registration surfaces that never match an `addEventListener` grep:

- **`window.onmessage = fn`** — setter assignment, not `addEventListener`.
- **`MessagePort.prototype.addEventListener`** — MessageChannel/`port.onmessage` handlers are an entirely
separate channel, common in iframe/worker bridges and SDKs.

At runtime, capture what is actually registered rather than trusting a source grep. Hook the
registration paths **before** the app's own scripts run, then read back what was collected:

```javascript
// agent-browser eval, or a DevTools "run before page load" snippet.
// Must execute before app JS; otherwise earlier registrations are missed.
globalThis.__seen = [];
const realAEL = Window.prototype.addEventListener;
Window.prototype.addEventListener = function (type, fn, opts) {
if (type === 'message') {
globalThis.__seen.push({ via: 'addEventListener', src: String(fn).slice(0, 400) });
}
return realAEL.call(this, type, fn, opts);
};
const realPortAEL = MessagePort.prototype.addEventListener;
MessagePort.prototype.addEventListener = function (type, fn, opts) {
if (type === 'message') {
globalThis.__seen.push({ via: 'MessagePort', src: String(fn).slice(0, 400) });
}
return realPortAEL.call(this, type, fn, opts);
};
// window.onmessage = fn bypasses both of the above:
Object.defineProperty(window, 'onmessage', {
set(fn) { globalThis.__seen.push({ via: 'onmessage', src: String(fn).slice(0, 400) }); }
});
// ...load/interact with the page, then: globalThis.__seen
```

Detecting whether *something else* already hooked `addEventListener`:

```javascript
Window.prototype.addEventListener.toString().includes('native code')
// false => a wrapper is installed (an extension, or the app itself)
```

**This is a weak signal, not proof.** A wrapper can trivially spoof it by overriding `toString`, and a
false result may simply be your own tooling or another browser extension. Treat it as a hint that the
registration path is instrumented, not as evidence about the application.

**Checkpoint:** For each handler, verify: (1) strict `e.origin` equality check exists, (2) no `window.origin` comparison, (3) no `indexOf`/`includes`/`startsWith`/`endsWith`/loose-equality on origin, (4) any origin regex has escaped dots and a `$` anchor, (5) data is not passed to dynamic execution (`window[data.func]`), (6) the reply path does not use `postMessage(..., '*')`, (7) you have unwrapped monitoring wrappers and checked `onmessage`/`MessagePort` surfaces.

### 5. Test CSTI (Client-Side Template Injection)
- **AngularJS**: `{{constructor.constructor('alert(1)')()}}`
Expand All @@ -70,5 +167,54 @@ https://target.com/page#<img src=x onerror=alert(document.domain)>
```
**Checkpoint:** Confirm payload executes (not just reflected). Check CSP -- if blocked, see `csp-bypass` skill.

## Optional: bulk-triage listener bodies with FransyTracker's ruleset

When you have harvested many listener bodies (jxscout, `agent-browser eval`, source review), you can
machine-triage them before reading each by hand. [FransyTracker](https://gitlab.com/joaxcar/fransytracker)'s
rules engine is a self-contained module with no Chrome or DOM dependency, so it runs standalone:

```bash
git clone https://gitlab.com/joaxcar/fransytracker && cd fransytracker && npm install
npx tsx -e "
import './src/shared/findings.ts';
const F = (globalThis as any).FransyTrackerFindings;
console.log(JSON.stringify(F.evaluateListener({
listener: 'function(e){ var d = e.data; document.body.innerHTML = d.html; }'
}), null, 1));"
# => findings: missing-origin-check, tainted-data-to-sink (details: "innerHTML = d")
```

Ten rules: `missing-origin-check`, `weak-origin-check`, `origin-regex-unescaped-dot`,
`origin-regex-missing-anchor`, `eval-on-message-data`, `xss-sink-on-message-data`,
`location-assignment-from-data`, `tainted-data-to-sink`, `postmessage-wildcard-target`,
`missing-data-type-guard`.

**Treat a clean result as "not yet triaged", never as "safe".** Measured blind spots — each of these is a
real sink the engine does not flag:

| Pattern | Engine result |
|---|---|
| `e.source.postMessage(x, targetOrigin)` | missed entirely |
| `const {html} = e.data; el.innerHTML = html` | sink missed (destructuring) |
| `var a = e.data; var b = a; el.innerHTML = b` | sink missed (two-hop alias) |
| `$('#x').html(e.data)` | sink missed (jQuery) |
| `setTimeout(e.data.code, 0)` | sink missed |

It is strong on the origin class and weaker on the sink class, so use it to *prioritise* reading order,
not to decide what to skip. Its rules are regex over source text — minification and unusual aliasing
degrade it further.

**Opsec if you run the browser extension instead of the standalone module:** it requests
`host_permissions: *://*/*` and hooks page prototypes on every site you visit. Use a dedicated browser
profile, never your engagement-authenticated one.

## Credits
The origin anti-pattern table, wrapper-unwrapping tells, and hidden-listener surfaces above are distilled
from [FransyTracker](https://gitlab.com/joaxcar/fransytracker) (Johan Carlsson), itself an MV3 adaptation of
[postMessage-tracker](https://github.com/fransr/postMessage-tracker) by Frans Rosén and
[FancyTracker](https://github.com/Zeetaz/FancyTracker) by Erik Zettergren.

## Chain With
- `csp-bypass` (CSP blocks execution), `dompurify-mxss-bypass` (DOMPurify present), `custom-sanitizer-audit` (homegrown sanitizer), `self-xss-escalation` (payload only fires in own session)
- `cspt-xss` (Gadget 8 chains a CSPT-injected response into a postMessage listener that trusts `*.target.com`)
- `dom-vulnerability-static-analysis` (same source/sink model, applied to a repo rather than a live page)
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ rg 'location\.(href|assign|replace)\s*=|window\.open\(' \
rg 'location\.(hash|search|href)|document\.URL|document\.referrer|window\.name|postMessage' \
--type js --type ts -n src/

# Weak origin validation (substring/prefix/suffix/loose equality).
# The [^=] at the end excludes safe strict === / !== comparisons.
rg 'origin\s*\.\s*(indexOf|includes|search|startsWith|endsWith)|origin\s*[!=]=[^=]' \
--type js --type ts -n src/

# Framework-specific sinks
rg 'dangerouslySetInnerHTML|v-html|ng-bind-html|\[innerHTML\]|hx-get|hx-post' \
--type js --type html -g "*.vue" -g "*.tsx" -n src/
Expand Down Expand Up @@ -89,6 +94,10 @@ ast-grep -p 'window.open($TARGET)' -l js src/
# postMessage handlers (multi-line aware)
ast-grep -p 'window.addEventListener("message", $HANDLER)' -l js src/

# ...plus the two registration surfaces that pattern does NOT match
ast-grep -p 'window.onmessage = $HANDLER' -l js src/
ast-grep -p '$PORT.onmessage = $HANDLER' -l js src/ # MessageChannel / Worker

# React dangerouslySetInnerHTML
ast-grep -p 'dangerouslySetInnerHTML={{$VAR}}' -l tsx src/

Expand Down
Loading
Loading