Skip to content

Commit 1188453

Browse files
h0tak88rclaude
andcommitted
feat(zerodays): surface PoC + tag each sub-check (RCE/DoS/source-exposure/MongoDB)
The zerodays module probes several distinct bugs but the dashboard only showed a generic "potential exploitation path" with no proof. Now: - next88's request/response/status evidence is carried through (was discarded) into zerodays-results.json as cve/status_code/request/response (size-capped). - Each React2Shell sub-check is tagged in the finding text: RCE exploitation path, DoS (denial-of-service), source-code/source-map exposure, WAF-bypass. - MongoDB CVE-2025-14847 findings tagged as a memory leak with a safe printable preview of the leaked bytes as PoC. - Dashboard detail panel (module-registry default schema) now renders the CVE ("Vulnerability"), variant, response status, and PoC request/response code blocks — only when a finding carries that evidence, so other modules are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e7b6a44 commit 1188453

3 files changed

Lines changed: 234 additions & 27 deletions

File tree

internal/api/ui/pages/module-registry.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -509,13 +509,27 @@
509509
},
510510
detail(r) {
511511
const target = s(r.host || r.target || '');
512-
return buildFields([
513-
['Target', target, { isLink: true }],
514-
['Finding', s(r.title || r.finding || ''), { full: true }],
515-
['Module', s(r.module || '')],
516-
['Severity', s(r.severity)],
517-
['URL', s(r.url || r.link || ''), { isLink: true }],
518-
]);
512+
const raw = (r.raw && typeof r.raw === 'object') ? r.raw : {};
513+
const fields = [
514+
['Target', target, { isLink: true }],
515+
['Finding', s(r.title || r.finding || ''), { full: true }],
516+
['Module', s(r.module || '')],
517+
['Severity', s(r.severity)],
518+
['URL', s(r.url || r.link || ''), { isLink: true }],
519+
];
520+
// Enriched findings (e.g. zerodays React2Shell / MongoDB) carry an explicit
521+
// CVE plus the request/response PoC. Show which bug it is + the proof.
522+
const cve = s(raw.cve || '');
523+
const req = s(raw.request || '');
524+
const resp = s(raw.response || '');
525+
if (cve || req || resp) {
526+
fields.splice(1, 0, ['Vulnerability', s(cve || raw['template-id'] || ''), { full: true }]);
527+
if (raw.type) fields.push(['Variant', s(raw.type)]);
528+
if (raw.status_code != null && s(raw.status_code) !== '') fields.push(['Response status', s(raw.status_code)]);
529+
if (req) fields.push(['PoC — Request', req, { full: true, code: true }]);
530+
if (resp) fields.push(['PoC — Response', resp, { full: true, code: true }]);
531+
}
532+
return buildFields(fields);
519533
},
520534
},
521535

internal/scanner/zerodays/zerodays.go

Lines changed: 132 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ type React2ShellFinding struct {
5858
URL string
5959
Type string // e.g., "normal", "waf-bypass", "vercel-waf", "dos", "source-exposure"
6060
Severity string
61+
// PoC evidence captured by next88 (the proof, previously discarded).
62+
Request string // exact request next88 sent
63+
Response string // response that matched the vulnerable signature
64+
StatusCode int // HTTP status of the matched response
6165
}
6266

6367
// MongoDBFinding represents a MongoDB CVE-2025-14847 vulnerability finding
@@ -157,6 +161,10 @@ func Run(opts Options) (*Result, error) {
157161
Module string `json:"module"`
158162
Type string `json:"type,omitempty"`
159163
Finding string `json:"finding"`
164+
CVE string `json:"cve,omitempty"` // which bug this finding is
165+
StatusCode int `json:"status_code,omitempty"` // PoC: response status
166+
Request string `json:"request,omitempty"` // PoC: exact request sent
167+
Response string `json:"response,omitempty"` // PoC: matched response
160168
}
161169
findings := make([]zerodayFinding, 0, result.TotalVulnerable)
162170
seen := make(map[string]struct{})
@@ -185,7 +193,11 @@ func Run(opts Options) (*Result, error) {
185193
Severity: sev,
186194
Module: "zerodays",
187195
Type: vType,
188-
Finding: "Potential React2Shell exploitation path detected",
196+
Finding: react2ShellFindingText(vType),
197+
CVE: "CVE-2025-55182",
198+
StatusCode: v.StatusCode,
199+
Request: capPoC(v.Request),
200+
Response: capPoC(v.Response),
189201
})
190202
}
191203

@@ -206,7 +218,10 @@ func Run(opts Options) (*Result, error) {
206218
Severity: "high",
207219
Module: "zerodays",
208220
Type: "mongodb-memory-leak",
209-
Finding: fmt.Sprintf("Leaked %d bytes from MongoDB response", len(v.LeakedData)),
221+
Finding: fmt.Sprintf("MongoDB memory leak (CVE-2025-14847) — leaked %d bytes", len(v.LeakedData)),
222+
CVE: "CVE-2025-14847",
223+
Request: fmt.Sprintf("OP_MSG BSON probe with an oversized memory-leak request → %s", targetAddr),
224+
Response: previewLeak(v.LeakedData),
210225
})
211226
}
212227

@@ -373,12 +388,15 @@ func checkReact2Shell(opts Options) ([]React2ShellFinding, int, error) {
373388
return nil, 0, fmt.Errorf("next88 scan failed: %w", err)
374389
}
375390

376-
// Convert results to findings
377-
for url, vulnType := range results {
391+
// Convert results to findings (carrying the request/response PoC evidence)
392+
for url, res := range results {
378393
findings = append(findings, React2ShellFinding{
379-
URL: url,
380-
Type: vulnType,
381-
Severity: "HIGH", // React2Shell is a high severity RCE
394+
URL: url,
395+
Type: res.VulnType,
396+
Severity: "HIGH", // React2Shell is a high severity RCE
397+
Request: next88Request(res),
398+
Response: next88Response(res),
399+
StatusCode: next88StatusCode(res),
382400
})
383401
}
384402

@@ -389,7 +407,8 @@ func checkReact2Shell(opts Options) ([]React2ShellFinding, int, error) {
389407
dosResults, err := runNext88Scan(dosCtx, hosts, []string{"-dos-test", "-dos-requests", "100"}, opts.Threads, opts.Silent)
390408
dosCancel()
391409
if err == nil {
392-
for url, vulnType := range dosResults {
410+
for url, res := range dosResults {
411+
vulnType := res.VulnType
393412
// Check if already exists
394413
found := false
395414
for i := range findings {
@@ -403,9 +422,12 @@ func checkReact2Shell(opts Options) ([]React2ShellFinding, int, error) {
403422
}
404423
if !found {
405424
findings = append(findings, React2ShellFinding{
406-
URL: url,
407-
Type: vulnType,
408-
Severity: "HIGH",
425+
URL: url,
426+
Type: vulnType,
427+
Severity: "HIGH",
428+
Request: next88Request(res),
429+
Response: next88Response(res),
430+
StatusCode: next88StatusCode(res),
409431
})
410432
}
411433
}
@@ -419,7 +441,8 @@ func checkReact2Shell(opts Options) ([]React2ShellFinding, int, error) {
419441
sourceResults, err := runNext88Scan(sourceCtx, hosts, []string{"-check-source-exposure"}, opts.Threads, opts.Silent)
420442
sourceCancel()
421443
if err == nil {
422-
for url, vulnType := range sourceResults {
444+
for url, res := range sourceResults {
445+
vulnType := res.VulnType
423446
found := false
424447
for i := range findings {
425448
if findings[i].URL == url {
@@ -432,9 +455,12 @@ func checkReact2Shell(opts Options) ([]React2ShellFinding, int, error) {
432455
}
433456
if !found {
434457
findings = append(findings, React2ShellFinding{
435-
URL: url,
436-
Type: vulnType,
437-
Severity: "MEDIUM",
458+
URL: url,
459+
Type: vulnType,
460+
Severity: "MEDIUM",
461+
Request: next88Request(res),
462+
Response: next88Response(res),
463+
StatusCode: next88StatusCode(res),
438464
})
439465
}
440466
}
@@ -720,9 +746,10 @@ func buildMalformedMongoDBPacket(leakSize int) ([]byte, error) {
720746
return append(header, opCompressed...), nil
721747
}
722748

723-
// runNext88Scan runs next88 scan and returns results with threading support
724-
func runNext88Scan(ctx context.Context, hosts []string, args []string, requestedThreads int, silent bool) (map[string]string, error) {
725-
results := make(map[string]string)
749+
// runNext88Scan runs next88 scan and returns the full per-host results (including
750+
// the request/response PoC evidence) with threading support.
751+
func runNext88Scan(ctx context.Context, hosts []string, args []string, requestedThreads int, silent bool) (map[string]next88.ScanResult, error) {
752+
results := make(map[string]next88.ScanResult)
726753
var mu sync.Mutex
727754

728755
// Determine thread count
@@ -774,18 +801,103 @@ func runNext88Scan(ctx context.Context, hosts []string, args []string, requested
774801
return nil, fmt.Errorf("next88 scan failed: %w", err)
775802
}
776803

777-
// Convert results to map
804+
// Convert results to map, keeping the full result (request/response PoC).
778805
for _, result := range scanResults {
779806
if result.Vulnerable != nil && *result.Vulnerable {
780807
mu.Lock()
781-
results[result.Host] = result.VulnType
808+
results[result.Host] = result
782809
mu.Unlock()
783810
}
784811
}
785812

786813
return results, nil
787814
}
788815

816+
// next88StatusCode safely dereferences the optional status code.
817+
func next88StatusCode(r next88.ScanResult) int {
818+
if r.StatusCode != nil {
819+
return *r.StatusCode
820+
}
821+
return 0
822+
}
823+
824+
// next88Request/next88Response prefer the full request/response, falling back to the body-only capture.
825+
func next88Request(r next88.ScanResult) string {
826+
if strings.TrimSpace(r.Request) != "" {
827+
return r.Request
828+
}
829+
return r.RequestBody
830+
}
831+
832+
func next88Response(r next88.ScanResult) string {
833+
if strings.TrimSpace(r.Response) != "" {
834+
return r.Response
835+
}
836+
return r.ResponseBody
837+
}
838+
839+
// react2ShellFindingText builds a finding string that names the specific
840+
// sub-check(s) that fired (RCE exploitation, DoS, source-code exposure), so the
841+
// dashboard clearly tags which React2Shell issue was detected. vType may be a
842+
// comma-joined list when a single host matched multiple checks.
843+
func react2ShellFindingText(vType string) string {
844+
v := strings.ToLower(vType)
845+
var issues []string
846+
847+
hasRCE := strings.Contains(v, "normal") || strings.Contains(v, "waf") || strings.Contains(v, "vercel")
848+
if hasRCE || v == "" {
849+
rce := "RCE exploitation path"
850+
if strings.Contains(v, "waf") || strings.Contains(v, "vercel") {
851+
rce += " (WAF bypass)"
852+
}
853+
issues = append(issues, rce)
854+
}
855+
if strings.Contains(v, "dos") {
856+
issues = append(issues, "denial-of-service path")
857+
}
858+
if strings.Contains(v, "source") {
859+
issues = append(issues, "source-code / source-map exposure")
860+
}
861+
if len(issues) == 0 {
862+
issues = append(issues, "exploitation path")
863+
}
864+
return "React2Shell (CVE-2025-55182) — " + strings.Join(issues, " + ") + " detected"
865+
}
866+
867+
// capPoC trims and truncates a request/response body so the results JSON stays
868+
// small even when a host returns a large HTML error page.
869+
func capPoC(s string) string {
870+
const max = 8000
871+
s = strings.TrimSpace(s)
872+
if len(s) > max {
873+
return s[:max] + fmt.Sprintf("\n… [truncated, %d total bytes]", len(s))
874+
}
875+
return s
876+
}
877+
878+
// previewLeak renders leaked MongoDB bytes as a safe, capped printable preview
879+
// (non-printable bytes shown as '.') for use as PoC evidence.
880+
func previewLeak(b []byte) string {
881+
const max = 2048
882+
n := len(b)
883+
if n > max {
884+
b = b[:max]
885+
}
886+
var sb strings.Builder
887+
for _, c := range b {
888+
if c == '\n' || c == '\t' || (c >= 0x20 && c < 0x7f) {
889+
sb.WriteByte(c)
890+
} else {
891+
sb.WriteByte('.')
892+
}
893+
}
894+
out := sb.String()
895+
if n > max {
896+
out += fmt.Sprintf("\n… [truncated, %d total bytes leaked]", n)
897+
}
898+
return out
899+
}
900+
789901
// contains checks if a string slice contains a value
790902
func contains(slice []string, value string) bool {
791903
for _, v := range slice {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package zerodays
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/h0tak88r/AutoAR/internal/tools/next88"
8+
)
9+
10+
func TestReact2ShellFindingText(t *testing.T) {
11+
cases := map[string]string{
12+
"normal": "RCE exploitation path",
13+
"": "RCE exploitation path",
14+
"waf-bypass": "WAF bypass",
15+
"vercel-waf-bypass": "WAF bypass",
16+
"dos-test": "denial-of-service path",
17+
"source-exposure": "source-code / source-map exposure",
18+
"normal,dos-test": "RCE exploitation path + denial-of-service path",
19+
"normal,source-exposure": "source-code / source-map exposure",
20+
}
21+
for vtype, want := range cases {
22+
got := react2ShellFindingText(vtype)
23+
if !strings.Contains(got, "CVE-2025-55182") {
24+
t.Errorf("%q: missing CVE in %q", vtype, got)
25+
}
26+
if !strings.Contains(got, want) {
27+
t.Errorf("react2ShellFindingText(%q) = %q, want substring %q", vtype, got, want)
28+
}
29+
}
30+
// dos-only must NOT claim an RCE path
31+
if strings.Contains(react2ShellFindingText("dos-test"), "RCE") {
32+
t.Errorf("dos-only finding should not mention RCE")
33+
}
34+
}
35+
36+
func TestCapPoC(t *testing.T) {
37+
if got := capPoC(" hello "); got != "hello" {
38+
t.Fatalf("capPoC trim: got %q", got)
39+
}
40+
big := strings.Repeat("a", 9000)
41+
out := capPoC(big)
42+
if !strings.Contains(out, "truncated") {
43+
t.Fatalf("capPoC should mark truncation for oversized input")
44+
}
45+
if len(out) > 8100 {
46+
t.Fatalf("capPoC did not cap length: got %d", len(out))
47+
}
48+
}
49+
50+
func TestPreviewLeak(t *testing.T) {
51+
// 'A', NUL, 'B', newline, tab → printable kept, non-printable shown as '.'
52+
out := previewLeak([]byte{0x41, 0x00, 0x42, 0x0a, 0x09})
53+
if out != "A.B\n\t" {
54+
t.Fatalf("previewLeak: got %q want %q", out, "A.B\n\t")
55+
}
56+
}
57+
58+
func TestNext88EvidenceHelpers(t *testing.T) {
59+
code := 500
60+
r := next88.ScanResult{
61+
StatusCode: &code,
62+
Request: "POST / HTTP/1.1",
63+
Response: "HTTP/1.1 500",
64+
RequestBody: "fallback-req",
65+
ResponseBody: "fallback-resp",
66+
}
67+
if next88StatusCode(r) != 500 {
68+
t.Fatalf("status code: got %d", next88StatusCode(r))
69+
}
70+
if next88Request(r) != "POST / HTTP/1.1" {
71+
t.Fatalf("request prefers full Request: got %q", next88Request(r))
72+
}
73+
// Falls back to *Body when the full capture is empty.
74+
r2 := next88.ScanResult{RequestBody: "only-body", ResponseBody: "only-resp"}
75+
if next88Request(r2) != "only-body" || next88Response(r2) != "only-resp" {
76+
t.Fatalf("fallback to body failed: req=%q resp=%q", next88Request(r2), next88Response(r2))
77+
}
78+
if next88StatusCode(next88.ScanResult{}) != 0 {
79+
t.Fatalf("nil status code should be 0")
80+
}
81+
}

0 commit comments

Comments
 (0)