ONNX
security
malware-detection
Vigil / source /pkg /bundle /analyzer_archive.go
turentomer's picture
Publish self-contained Vigil distribution
d2507b5 verified
Raw
History Blame Contribute Delete
9.43 kB
package bundle
import (
"archive/zip"
"bytes"
"io"
"path"
"strings"
)
// ArchiveAnalyzer inspects .docx/.zip/.xlsx/.pptx and any PK-zip-sniffed file —
// the context-loader vector (a zip-of-XML hiding a sync script). It enumerates
// members via archive/zip, classifies each member by SNIFFING ITS BYTES (magic,
// never the member name/extension), scans member bytes with the matching
// analyzer logic, and RECURSES into nested archives up to maxArchiveDepth to
// unwrap a script buried two zips deep. All analysis is in-memory; members with
// ".." or absolute paths are rejected; total uncompressed size, member count,
// and depth are bounded against zip bombs. Corrupt/over-depth => SevMedium
// Opaque, never panic.
type ArchiveAnalyzer struct{}
func (ArchiveAnalyzer) Name() string { return "archive" }
func (ArchiveAnalyzer) Handles(kind FileKind) bool { return kind == KindArchive }
const (
maxArchiveMembers = 1024
maxArchiveUncompressed = 67108864 // 64 MiB
maxArchiveDepth = 3
maxMemberScanBytes = 1048576 // 1 MiB per member fed to analyzers
)
func (ArchiveAnalyzer) Analyze(f *File, b *Bundle) ([]Finding, error) {
if f == nil {
return nil, nil
}
return analyzeArchiveBytes(f.Sniff, f.RelPath, 0, b)
}
// analyzeArchiveBytes opens a zip from raw bytes and walks its members. originRel
// is the path used to label findings (the outer archive's RelPath, with nested
// member paths appended). depth guards recursion.
func analyzeArchiveBytes(data []byte, originRel string, depth int, b *Bundle) ([]Finding, error) {
if depth > maxArchiveDepth {
return []Finding{{
Analyzer: "archive",
File: originRel,
Signal: "archive-too-deep",
Severity: SevMedium,
Detail: "nested archive exceeds maximum recursion depth (possible burial evasion)",
Opaque: true,
Structural: true,
}}, nil
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return []Finding{{
Analyzer: "archive",
File: originRel,
Signal: "opaque-archive",
Severity: SevMedium,
Detail: "could not open archive (corrupt or unsupported): " + err.Error(),
Opaque: true,
}}, nil
}
var out []Finding
var totalUncompressed uint64
members := 0
for _, zf := range zr.File {
members++
if members > maxArchiveMembers {
out = append(out, Finding{
Analyzer: "archive",
File: originRel,
Signal: "archive-member-limit",
Severity: SevMedium,
Detail: "archive exceeds member count limit; remaining members not scanned",
Opaque: true,
})
break
}
name := zf.Name
// Reject zip-slip: absolute or parent-traversal member paths.
if isUnsafeMemberPath(name) {
out = append(out, Finding{
Analyzer: "archive",
File: joinMember(originRel, name),
Signal: "archive-path-traversal",
Severity: SevHigh,
Detail: "archive member uses an absolute or parent-traversal path (zip-slip)",
Corroborated: true,
})
continue
}
if zf.FileInfo().IsDir() {
continue
}
// Bound per-member read and total uncompressed budget.
memberData, readErr := readZipMember(zf, &totalUncompressed)
if readErr != nil {
out = append(out, Finding{
Analyzer: "archive",
File: joinMember(originRel, name),
Signal: "opaque-archive-member",
Severity: SevMedium,
Detail: "could not read archive member: " + readErr.Error(),
Opaque: true,
})
continue
}
if totalUncompressed > maxArchiveUncompressed {
out = append(out, Finding{
Analyzer: "archive",
File: originRel,
Signal: "archive-bomb-guard",
Severity: SevMedium,
Detail: "archive uncompressed size limit reached; remaining members not scanned",
Opaque: true,
})
break
}
memberRel := joinMember(originRel, name)
out = append(out, scanArchiveMember(memberData, memberRel, depth, b)...)
}
return dedupeFindings(out), nil
}
// scanArchiveMember sniffs a member's bytes by MAGIC (not name) and routes it to
// the appropriate analyzer logic, recursing into nested archives. An executable
// member with suspicious content yields 'archive-contains-executable'.
func scanArchiveMember(memberData []byte, memberRel string, depth int, b *Bundle) []Finding {
kind, _ := sniffMagicKind(memberData)
// Fall back to name-based classification only when magic is inconclusive.
if kind == KindUnknown {
kind = classifyKind(path.Base(memberRel), memberData)
}
var out []Finding
switch kind {
case KindArchive:
// Nested archive: recurse (magic-sniffed, depth-bounded).
nested, _ := analyzeArchiveBytes(truncateBytes(memberData), memberRel, depth+1, b)
if len(nested) > 0 {
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "archive-contains-executable",
Severity: SevHigh,
Detail: "archive member is a nested archive carrying suspicious content",
Corroborated: true,
})
out = append(out, nested...)
}
case KindShell, KindPythonSource, KindScriptOther:
sub := sharedIndicatorScan(string(truncateBytes(memberData)), memberRel, "archive")
if hasActionable(sub) {
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "archive-contains-executable",
Severity: SevHigh,
Detail: "archive bundles a script with suspicious content",
Corroborated: true,
})
}
out = append(out, sub...)
case KindPyc, KindNativeBinary, KindWasm:
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "archive-contains-executable",
Severity: SevHigh,
Detail: "archive bundles compiled bytecode or a native binary",
Opaque: true,
Structural: true,
Corroborated: exfilHostRe.Match(memberData),
})
// surface exfil-host hits inside the binary blob too
if exfilHostRe.Match(memberData) {
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "exfil-host-reference",
Severity: SevCritical,
Detail: "archived binary references known exfiltration host",
Corroborated: true,
})
}
case KindData, KindText:
// XML/relationships/text inside a docx can carry imperative directives
// or exfil hosts (the context-loader payload).
text := string(truncateBytes(memberData))
if exfilHostRe.MatchString(text) {
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "exfil-host-reference",
Severity: SevCritical,
Detail: "archived data member references known exfiltration host",
Corroborated: true,
})
}
for _, dir := range scanImperativeDirectives(text) {
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "data-embedded-directive",
Severity: SevMedium,
Detail: "archived data member embeds an imperative directive: " + dir,
})
}
out = append(out, sharedIndicatorScan(text, memberRel, "archive")...)
default:
// Unknown/opaque member: only worth flagging if it carries the exfil host.
if exfilHostRe.Match(memberData) {
out = append(out, Finding{
Analyzer: "archive",
File: memberRel,
Signal: "exfil-host-reference",
Severity: SevCritical,
Detail: "archived member references known exfiltration host",
Corroborated: true,
})
}
}
return out
}
// readZipMember reads a member with a hard per-member cap, updating the running
// uncompressed total. Returns at most maxMemberScanBytes.
func readZipMember(zf *zip.File, total *uint64) ([]byte, error) {
rc, err := zf.Open()
if err != nil {
return nil, err
}
defer rc.Close()
limited := io.LimitReader(rc, maxMemberScanBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return data, err
}
*total += uint64(len(data))
if len(data) > maxMemberScanBytes {
data = data[:maxMemberScanBytes]
}
return data, nil
}
// truncateBytes caps a byte slice at the per-member scan budget for downstream
// analyzers (defense against pathological members).
func truncateBytes(data []byte) []byte {
if len(data) > maxMemberScanBytes {
return data[:maxMemberScanBytes]
}
return data
}
// isUnsafeMemberPath rejects absolute paths and parent-traversal segments. It
// inspects the raw segments directly rather than path.Clean-ing against root,
// because cleaning against "/" silently absorbs leading ".." segments and would
// hide a zip-slip member like "../../etc/evil".
func isUnsafeMemberPath(name string) bool {
if name == "" {
return true
}
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") {
return true
}
// Windows drive-letter absolute path.
if len(name) >= 2 && name[1] == ':' {
return true
}
for _, seg := range strings.Split(strings.ReplaceAll(name, "\\", "/"), "/") {
if seg == ".." {
return true
}
}
return false
}
// joinMember labels a nested finding as "<archive>!/<member>".
func joinMember(origin, member string) string {
member = strings.ReplaceAll(member, "\\", "/")
return origin + "!/" + member
}
// hasActionable reports whether any finding is at least SevMedium (i.e. worth
// escalating the archive itself).
func hasActionable(fs []Finding) bool {
for _, f := range fs {
if f.Severity >= SevMedium {
return true
}
}
return false
}