ONNX
security
malware-detection
Vigil / source /pkg /bundle /decode.go
turentomer's picture
Publish self-contained Vigil distribution
d2507b5 verified
Raw
History Blame Contribute Delete
12.4 kB
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
}