| package bundle |
|
|
| import ( |
| "bytes" |
| "encoding/binary" |
| "strings" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| type ImageAnalyzer struct{} |
|
|
| func (ImageAnalyzer) Name() string { return "image" } |
|
|
| func (ImageAnalyzer) Handles(kind FileKind) bool { return kind == KindImage } |
|
|
| func (ImageAnalyzer) Analyze(f *File, b *Bundle) ([]Finding, error) { |
| if f == nil { |
| return nil, nil |
| } |
| texts := extractImageText(f.Sniff, f.Kind) |
| joined := strings.Join(texts, "\n") |
|
|
| var out []Finding |
|
|
| if exfilHostRe.MatchString(joined) { |
| out = append(out, Finding{ |
| Analyzer: "image", |
| File: f.RelPath, |
| Signal: "exfil-host-reference", |
| Severity: SevCritical, |
| Detail: "image metadata references known exfiltration host", |
| Corroborated: true, |
| }) |
| } |
|
|
| for _, dir := range scanImperativeDirectives(joined) { |
| out = append(out, Finding{ |
| Analyzer: "image", |
| File: f.RelPath, |
| Signal: "image-embedded-directive", |
| Severity: SevMedium, |
| Detail: "image embeds an imperative agent directive in metadata: " + dir, |
| }) |
| } |
|
|
| |
| if base64BlobRe.MatchString(joined) { |
| out = append(out, Finding{ |
| Analyzer: "image", |
| File: f.RelPath, |
| Signal: "image-embedded-blob", |
| Severity: SevMedium, |
| Detail: "image metadata contains a long base64 blob (possible hidden payload)", |
| }) |
| } |
|
|
| if len(texts) == 0 { |
| |
| |
| out = append(out, Finding{ |
| Analyzer: "image", |
| File: f.RelPath, |
| Signal: "opaque-image", |
| Severity: SevLow, |
| Detail: "no extractable metadata text (opaque image)", |
| Opaque: true, |
| Structural: true, |
| }) |
| } |
|
|
| return dedupeFindings(out), nil |
| } |
|
|
| |
| |
| |
| func extractImageText(data []byte, kind FileKind) []string { |
| var out []string |
| defer func() { _ = recover() }() |
|
|
| switch { |
| case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")): |
| out = append(out, extractPNGText(data)...) |
| case bytes.HasPrefix(data, []byte{0xFF, 0xD8}): |
| out = append(out, extractJPEGText(data)...) |
| } |
|
|
| |
| |
| for _, s := range printableStrings(data, 6) { |
| out = append(out, s) |
| } |
| return dedupeStrings(out) |
| } |
|
|
| |
| |
| |
| func extractPNGText(data []byte) []string { |
| var out []string |
| |
| pos := 8 |
| const maxChunks = 4096 |
| chunks := 0 |
| for pos+8 <= len(data) && chunks < maxChunks { |
| chunks++ |
| length := int(binary.BigEndian.Uint32(data[pos : pos+4])) |
| if length < 0 || pos+8+length+4 > len(data) { |
| break |
| } |
| ctype := string(data[pos+4 : pos+8]) |
| body := data[pos+8 : pos+8+length] |
| switch ctype { |
| case "tEXt": |
| if s := decodeLatin1KeywordText(body); s != "" { |
| out = append(out, s) |
| } |
| case "iTXt": |
| if s := decodeITXt(body); s != "" { |
| out = append(out, s) |
| } |
| case "zTXt": |
| if i := bytes.IndexByte(body, 0); i >= 0 { |
| out = append(out, "zTXt:"+string(body[:i])) |
| } |
| case "IEND": |
| return out |
| } |
| pos += 8 + length + 4 |
| } |
| return out |
| } |
|
|
| |
| func decodeLatin1KeywordText(body []byte) string { |
| i := bytes.IndexByte(body, 0) |
| if i < 0 { |
| return string(body) |
| } |
| keyword := string(body[:i]) |
| text := string(body[i+1:]) |
| return keyword + ": " + text |
| } |
|
|
| |
| |
| func decodeITXt(body []byte) string { |
| i := bytes.IndexByte(body, 0) |
| if i < 0 || i+3 > len(body) { |
| return "" |
| } |
| keyword := string(body[:i]) |
| rest := body[i+1:] |
| if len(rest) < 2 { |
| return keyword |
| } |
| compFlag := rest[0] |
| rest = rest[2:] |
| |
| if j := bytes.IndexByte(rest, 0); j >= 0 { |
| rest = rest[j+1:] |
| } |
| |
| if j := bytes.IndexByte(rest, 0); j >= 0 { |
| rest = rest[j+1:] |
| } |
| if compFlag != 0 { |
| |
| return keyword |
| } |
| return keyword + ": " + string(rest) |
| } |
|
|
| |
| |
| func extractJPEGText(data []byte) []string { |
| var out []string |
| pos := 2 |
| const maxSegs = 4096 |
| segs := 0 |
| for pos+4 <= len(data) && segs < maxSegs { |
| segs++ |
| if data[pos] != 0xFF { |
| pos++ |
| continue |
| } |
| marker := data[pos+1] |
| |
| if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) { |
| pos += 2 |
| continue |
| } |
| if pos+4 > len(data) { |
| break |
| } |
| segLen := int(binary.BigEndian.Uint16(data[pos+2 : pos+4])) |
| if segLen < 2 || pos+2+segLen > len(data) { |
| break |
| } |
| seg := data[pos+4 : pos+2+segLen] |
| switch marker { |
| case 0xFE: |
| out = append(out, "comment: "+string(seg)) |
| case 0xE1: |
| for _, s := range printableStrings(seg, 5) { |
| out = append(out, s) |
| } |
| } |
| if marker == 0xDA { |
| break |
| } |
| pos += 2 + segLen |
| } |
| return out |
| } |
|
|