fix(scan): baseline-diff lfi evidence to stop static-content false positives (#303)

the lfi scan matched evidence regexes against each response with no
baseline, so a page whose static content matched a pattern (a blog
containing an /etc/passwd-shaped line, a code sample with <?php) was
reported as an lfi finding for every parameter and payload combination.
take one baseline get of the unmodified target and exclude, per
evidence class, any pattern already present in it, so only
payload-caused disclosures are counted.
This commit is contained in:
Tigah
2026-07-22 12:48:22 -07:00
committed by GitHub
parent 243e0aba16
commit 4f01345974
2 changed files with 82 additions and 2 deletions
+23 -2
View File
@@ -170,6 +170,25 @@ func LFI(targetURL string, timeout time.Duration, threads int, logdir string) (*
log.Info("Testing %d parameters with %d payloads", len(paramsToTest), len(lfiPayloads))
// baseline the unmodified target so evidence-like content already present
// on every response (a blog post about /etc/passwd, a "<?php" snippet) isn't
// reported as an LFI hit for every param/payload combination.
baselineEvidence := make(map[string]bool)
if baseReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody); err == nil {
if baseResp, err := client.Do(baseReq); err == nil {
baseBody, err := io.ReadAll(io.LimitReader(baseResp.Body, 1024*100))
baseResp.Body.Close()
if err == nil {
baseBodyStr := string(baseBody)
for _, evidence := range lfiEvidencePatterns {
if evidence.pattern.MatchString(baseBodyStr) {
baselineEvidence[evidence.description] = true
}
}
}
}
}
// create work items
type workItem struct {
param string
@@ -236,8 +255,10 @@ func LFI(targetURL string, timeout time.Duration, threads int, logdir string) (*
// check for evidence patterns
for _, evidence := range lfiEvidencePatterns {
match := evidence.pattern.FindString(bodyStr)
// our own payload echoed back isn't proof of inclusion
if match != "" && !strings.Contains(item.payload.payload, match) {
// our own payload echoed back isn't proof of inclusion, and
// evidence already present in the unmodified baseline means
// the content is static, not caused by this payload
if match != "" && !strings.Contains(item.payload.payload, match) && !baselineEvidence[evidence.description] {
key := item.param + "|" + item.payload.payload
mu.Lock()
if seen[key] {
+59
View File
@@ -337,6 +337,65 @@ func TestLFI_ReflectedPayloadIsNotEvidence(t *testing.T) {
}
}
func TestLFI_StaticPageContentIsNotEvidence(t *testing.T) {
// a page that always returns the same body regardless of query params,
// e.g. a blog post explaining /etc/passwd format. the content matches
// the evidence pattern for every single request, not because a payload
// caused a file to be included.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "<html>example: root:x:0:0:root:/root:/bin/bash explained here</html>")
}))
defer srv.Close()
result, err := LFI(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("LFI: %v", err)
}
if result != nil && len(result.Vulnerabilities) > 0 {
t.Errorf("static page content present regardless of payload should not be flagged as LFI, got %d hits (e.g. %+v)", len(result.Vulnerabilities), result.Vulnerabilities[0])
}
}
func TestLFI_BaselinePatternDoesNotPoisonOtherPatterns(t *testing.T) {
// baseline carries a static php snippet, but the passwd leak only appears
// under a traversal payload. suppression must be per evidence class, not global.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := "<html><?php // shared static snippet ?></html>"
for _, vs := range r.URL.Query() {
for _, v := range vs {
if strings.Contains(v, "etc/passwd") {
body += "\nroot:x:0:0:root:/root:/bin/bash"
}
}
}
fmt.Fprint(w, body)
}))
defer srv.Close()
result, err := LFI(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("LFI: %v", err)
}
if result == nil {
t.Fatal("passwd disclosure under payload should still be detected despite a static php baseline")
}
var sawPasswd, sawPHP bool
for _, v := range result.Vulnerabilities {
switch v.Evidence {
case "/etc/passwd content":
sawPasswd = true
case "PHP source code":
sawPHP = true
}
}
if !sawPasswd {
t.Errorf("passwd leak (evidence class absent from baseline) should still fire, got %+v", result.Vulnerabilities)
}
if sawPHP {
t.Errorf("static php present in baseline should be suppressed, got %+v", result.Vulnerabilities)
}
}
func TestLFI_GenuineBase64PHPStillDetected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Query().Get("file"), "convert.base64-encode") {