Skip to content

Commit 40e7b6c

Browse files
committed
Refine output UX and scoring heuristics
1 parent 8783316 commit 40e7b6c

6 files changed

Lines changed: 1206 additions & 296 deletions

File tree

README.md

Lines changed: 41 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ If you install with `go install`, the `payloads/` directory is not installed aut
6464
- Go 1.24 or later to build from source
6565
- `curl` available in `PATH` for techniques that depend on it, such as:
6666
- `http-versions`
67+
- `http-parser`
6768
- `absolute-uri`
6869

6970
Most techniques work without `curl`.
@@ -109,36 +110,25 @@ Write machine-readable output:
109110
## Example Output
110111

111112
```text
112-
━━━━━━━━━━━━━━━━━━━━━━ NOMORE403 ━━━━━━━━━━━━━━━━━━━━━━━
113-
Target: https://target.tld/admin
114-
Method: GET User-Agent: nomore403
115-
Timeout: 6000ms Delay: 0ms
116-
Proxy: - Bypass IP: -
117-
Flags: -
118-
Frontend: AWS ELB/ALB
119-
Techniques: headers, absolute-uri, path-normalization, ...
120-
Payloads: payloads
121-
122-
━━━━━━━━━━━━━━━ AUTO-CALIBRATION RESULTS ━━━━━━━━━━━━━━━
123-
[✔] Calibration samples: 3
124-
[✔] Status Code: 404
125-
[✔] Avg Content Length: 1245 bytes (tolerance: ±50)
126-
[✔] Fragment baseline: 703 bytes
127-
128-
━━━━━━━━━━━━━━━ DEFAULT REQUEST ━━━━━━━━━━━━━━
129-
[ 5 LOW] Default request 403->403 520 bytes https://target.tld/admin
130-
131-
━━━━━━━━━━━━━━━━━━ HEADERS ━━━━━━━━━━━━━━━━━━━
132-
[ 82 HIGH] Header injection 403=>200 2048 bytes X-Original-URL -> path
133-
[ 61 MED] Header injection 403=>302 128 bytes X-Rewrite-URL -> path
134-
... and 4 more collapsed as similar parser behavior
135-
136-
━━━━━━━━━━━ INTERESTING VARIATIONS ━━━━━━━━━━━
137-
[26 LOW] Absolute URI 403->403 236b
138-
why: len Δ284
139-
item: request-target: https://target.tld/admin
140-
curl: curl --request-target 'https://target.tld/admin' \
141-
https://target.tld/admin
113+
target: https://target.tld/admin method: GET frontend: AWS ELB/ALB payloads: payloads
114+
115+
calib: 404 | 1245b | ±50 | frag 703b
116+
117+
BASELINE
118+
default 403 520 bytes https://target.tld/admin
119+
120+
FINDINGS
121+
hdr-ip 100! 200 2048 bytes X-Original-URL: /
122+
abs-uri 26. 403 236 bytes request-target: https://target.tld/admin
123+
http 18. 400 122 bytes HTTP/2
124+
125+
no visible results: 17 techniques
126+
127+
━━━━━━━━━━━━━━ LIKELY BYPASS ━━━━━━━━━━━━━━━━━
128+
[!100 HIGH] Header injection (IP) 403=>200 2048b
129+
why: status 403->200, len Δ1528, body changed, type changed
130+
item: X-Original-URL: /
131+
curl: curl -i -sS -k -H 'User-Agent: nomore403' -H 'X-Original-URL: /' 'https://target.tld/admin'
142132
```
143133

144134
## How to Read the Output
@@ -149,19 +139,18 @@ Each visible line is a response that differed enough from the baseline to surviv
149139

150140
Typical fields:
151141

152-
- `score`: `0-100`
153-
- `likelihood`: `LOW`, `MED`, or `HIGH`
154-
- technique name
155-
- baseline transition, for example `403=>200`
142+
- technique alias, for example `hdr-ip`, `abs-uri`, or `parser`
143+
- compact score marker such as `18.`, `61+`, or `100!`
144+
- final response status
156145
- response size
157146
- item or payload used
158147

159-
### Status transitions
148+
### Summary transitions
160149

161-
The transition shows how a technique changed the baseline response:
150+
The final summaries show baseline-to-result transitions:
162151

163152
- `403=>200` usually deserves immediate attention
164-
- `403=>302` is often interesting, especially for internal redirects or auth gateways
153+
- `403=>302` can be interesting, but may still resolve back into an auth barrier
165154
- `403->400` or `403->404` usually indicate parser or routing differences rather than a bypass
166155

167156
### Summaries
@@ -170,11 +159,11 @@ At the end of the run, `nomore403` prints:
170159

171160
- `LIKELY BYPASS`
172161
- highest-scoring results
173-
- includes replay status and reproducible `curl`
162+
- includes reproducible `curl`
174163
- `INTERESTING VARIATIONS`
175164
- meaningful parser or routing differences that are worth manual review
176-
- `NO VISIBLE RESULTS`
177-
- techniques that ran but produced no output after filtering
165+
- `no visible results`
166+
- count of techniques that ran but produced no visible output after filtering
178167

179168
## Scoring Model
180169

@@ -187,13 +176,15 @@ The tool generally rewards:
187176
- large body-length changes
188177
- body hash changes
189178
- `Location` changes
179+
- anomalous redirects that do not appear to resolve into a login or denied flow
190180
- differences that survive replay
191181

192182
The tool generally down-ranks:
193183

194184
- near-identical responses
195185
- repeated parser noise
196186
- unstable replay results
187+
- empty-body redirects that appear to lead back into access control
197188
- many `400` and `404` cases unless the response also changes substantially
198189

199190
Recommended interpretation:
@@ -231,7 +222,10 @@ The tool runs all techniques by default unless you specify `-k`.
231222
### Header- and trust-based mutations
232223

233224
- `headers`
234-
- IP trust headers, simple headers, and Host variations
225+
- umbrella technique covering:
226+
- IP trust headers
227+
- simple headers
228+
- Host variations
235229
- `hop-by-hop`
236230
- hop-by-hop stripping tricks using `Connection`
237231
- `header-confusion`
@@ -267,7 +261,9 @@ The tool runs all techniques by default unless you specify `-k`.
267261
### Frontend and wire-format mutations
268262

269263
- `http-versions`
270-
- compares `HTTP/1.0` and `HTTP/2`
264+
- compares the same request across `HTTP/1.0` and `HTTP/2`
265+
- `http-parser`
266+
- sends a deliberately minimal `curl` request to expose client/frontend parser differences separately from `http-versions`
271267
- `absolute-uri`
272268
- uses absolute-form request targets through `curl --request-target`
273269
- `raw-duplicates`
@@ -437,6 +433,8 @@ Key flags:
437433
- minimum score for `LIKELY BYPASS`
438434
- `--variation-score-min`
439435
- minimum score for `INTERESTING VARIATIONS`
436+
- `--top`
437+
- maximum number of entries per summary section, or `0` to disable summaries
440438

441439
## Output Formats
442440

@@ -476,6 +474,7 @@ You can customize these files to fit your targets or workflow.
476474

477475
- raw HTTP techniques do not currently support upstream proxies
478476
- scoring is heuristic and can produce false positives or false negatives
477+
- redirect scoring currently uses heuristics on the immediate redirect response, not a fully followed redirect chain
479478
- some techniques depend on target-specific behavior and may appear noisy on heavily normalized stacks
480479
- `curl`-based techniques require `curl` in `PATH`
481480

cmd/api.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -388,13 +388,11 @@ func runAutocalibrate(options RequestOptions) (int, int) {
388388
}
389389
}
390390

391-
fmt.Println(color.MagentaString("\n━━━━━━━━━━━━━━━ AUTO-CALIBRATION RESULTS ━━━━━━━━━━━━━━━"))
392-
fmt.Printf("[✔] Calibration samples: %d\n", len(samples))
393-
fmt.Printf("[✔] Status Code: %d\n", lastStatusCode)
394-
fmt.Printf("[✔] Avg Content Length: %d bytes (tolerance: ±%d)\n", avgCl, tolerance)
391+
summary := fmt.Sprintf("%d | %db | ±%d", lastStatusCode, avgCl, tolerance)
395392
if getFragmentCl() > 0 {
396-
fmt.Printf("[✔] Fragment baseline: %d bytes\n", getFragmentCl())
393+
summary += fmt.Sprintf(" | frag %db", getFragmentCl())
397394
}
395+
fmt.Printf("\n%s %s\n", color.New(color.FgHiBlack, color.Bold).Sprint("calib:"), color.New(color.FgHiBlack).Sprint(summary))
398396

399397
return avgCl, tolerance
400398
}

cmd/bypass_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,122 @@ func TestScoreResultKeepsStrong400AsVariation(t *testing.T) {
13211321
}
13221322
}
13231323

1324+
func TestScoreResultRewardsSameStatusBodyDelta(t *testing.T) {
1325+
resetTestState()
1326+
setGlobalBaseline(ResponseInfo{
1327+
statusCode: 403,
1328+
contentLength: 520,
1329+
bodyHash: "aaa",
1330+
contentType: "text/html",
1331+
server: "nginx",
1332+
})
1333+
setTechniqueBaseline("headers-ip", globalBaseline())
1334+
1335+
result := Result{
1336+
line: "X-Forwarded-For: 127.0.0.1",
1337+
statusCode: 403,
1338+
contentLength: 1911,
1339+
bodyHash: "bbb",
1340+
contentType: "application/json",
1341+
server: "envoy",
1342+
technique: "headers-ip",
1343+
}
1344+
1345+
score := scoreResult(result)
1346+
if score < 55 {
1347+
t.Fatalf("expected strong same-status anomaly to reach medium/high territory, got %d", score)
1348+
}
1349+
}
1350+
1351+
func TestScoreReasonFlagsRedirectAnomaly(t *testing.T) {
1352+
resetTestState()
1353+
setGlobalBaseline(ResponseInfo{
1354+
statusCode: 403,
1355+
contentLength: 520,
1356+
bodyHash: "aaa",
1357+
contentType: "text/html",
1358+
})
1359+
setTechniqueBaseline("absolute-uri", globalBaseline())
1360+
1361+
result := Result{
1362+
line: "request-target: https://example.com/admin",
1363+
statusCode: 302,
1364+
contentLength: 30,
1365+
bodyHash: "bbb",
1366+
location: "/dashboard",
1367+
contentType: "text/html",
1368+
technique: "absolute-uri",
1369+
}
1370+
1371+
reason := scoreReason(result)
1372+
if !strings.Contains(reason, "redirect anomaly") {
1373+
t.Fatalf("expected redirect anomaly in score reason, got %q", reason)
1374+
}
1375+
1376+
score := scoreResult(result)
1377+
if score < 55 {
1378+
t.Fatalf("expected redirect anomaly to produce a meaningful score, got %d", score)
1379+
}
1380+
}
1381+
1382+
func TestScoreResultPenalizesSameStatusEmptyBody(t *testing.T) {
1383+
resetTestState()
1384+
setGlobalBaseline(ResponseInfo{
1385+
statusCode: 403,
1386+
contentLength: 118,
1387+
bodyHash: "aaa",
1388+
contentType: "text/html",
1389+
})
1390+
setTechniqueBaseline("verb-tampering", globalBaseline())
1391+
1392+
result := Result{
1393+
line: "HEAD",
1394+
statusCode: 403,
1395+
contentLength: 0,
1396+
bodyHash: "bbb",
1397+
contentType: "",
1398+
technique: "verb-tampering",
1399+
}
1400+
1401+
score := scoreResult(result)
1402+
if score >= 25 {
1403+
t.Fatalf("expected same-status empty-body response to stay low-score, got %d", score)
1404+
}
1405+
}
1406+
1407+
func TestScoreResultPenalizesAccessControlRedirects(t *testing.T) {
1408+
resetTestState()
1409+
setGlobalBaseline(ResponseInfo{
1410+
statusCode: 403,
1411+
contentLength: 118,
1412+
bodyHash: "aaa",
1413+
contentType: "text/html",
1414+
})
1415+
setTechniqueBaseline("endpaths", globalBaseline())
1416+
1417+
result := Result{
1418+
line: "https://example.com/admin/.",
1419+
statusCode: 302,
1420+
contentLength: 0,
1421+
bodyHash: "",
1422+
location: "/403",
1423+
contentType: "",
1424+
technique: "endpaths",
1425+
}
1426+
1427+
if strings.Contains(scoreReason(result), "redirect anomaly") {
1428+
t.Fatalf("did not expect access-control redirect to be tagged as anomaly")
1429+
}
1430+
1431+
score := scoreResult(result)
1432+
if score >= 25 {
1433+
t.Fatalf("expected access-control redirect to stay low-score, got %d", score)
1434+
}
1435+
if !strings.Contains(scoreReason(result), "redirect to access control") {
1436+
t.Fatalf("expected access-control redirect reason, got %q", scoreReason(result))
1437+
}
1438+
}
1439+
13241440
func TestForwardedTrustSendsForwardedHeaders(t *testing.T) {
13251441
resetTestState()
13261442

0 commit comments

Comments
 (0)