ONNX
security
malware-detection
Vigil / source /pkg /bundle /analyzer_image.go
turentomer's picture
Publish self-contained Vigil distribution
d2507b5 verified
Raw
History Blame Contribute Delete
6.2 kB
package bundle
import (
"bytes"
"encoding/binary"
"strings"
)
// ImageAnalyzer inspects images (.png/.jpg/.jpeg/.gif/.webp). A multimodal agent
// reads instructions an analyzer cannot, so this extracts PNG tEXt/iTXt/zTXt
// chunk text, JPEG EXIF/COM comment segments, and printable runs, then scans
// them for imperative directives / exfil hosts / base64. Standalone hits are
// SevMedium 'image-embedded-directive'; IndirectionAnalyzer raises
// delegate-to-image to SevHigh when SKILL.md delegates to the image. Undecodable
// images degrade to SevLow Opaque (never panic).
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,
})
}
// base64 blob hidden in metadata.
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 {
// We could not extract any metadata text; flag as opaque (low) so the
// artifact is not silently treated as benign, but do not over-escalate.
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
}
// extractImageText pulls textual metadata from an image: PNG text chunks, JPEG
// EXIF/COM segments, and as a fallback the printable runs across the whole blob
// (which captures EXIF UserComment / iTXt regardless of exact container quirks).
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)...)
}
// Fallback: printable runs of >= 6 chars across the file frequently surface
// EXIF tags, iTXt, and embedded comments the container parsers miss.
for _, s := range printableStrings(data, 6) {
out = append(out, s)
}
return dedupeStrings(out)
}
// extractPNGText walks PNG chunks and lifts tEXt/iTXt/zTXt keyword+text. zTXt is
// zlib-compressed; we record its keyword and leave the compressed body to the
// printable-run fallback (avoids a decompress bomb).
func extractPNGText(data []byte) []string {
var out []string
// Skip the 8-byte signature.
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 // length + type + data + CRC
}
return out
}
// decodeLatin1KeywordText decodes a PNG tEXt chunk "keyword\0text".
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
}
// decodeITXt decodes a PNG iTXt chunk, returning keyword + (uncompressed) text.
// Format: keyword\0 compflag(1) compmethod(1) langtag\0 transkeyword\0 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:] // skip compflag + compmethod
// skip langtag\0
if j := bytes.IndexByte(rest, 0); j >= 0 {
rest = rest[j+1:]
}
// skip translated-keyword\0
if j := bytes.IndexByte(rest, 0); j >= 0 {
rest = rest[j+1:]
}
if compFlag != 0 {
// compressed text: leave body to the printable-run fallback.
return keyword
}
return keyword + ": " + string(rest)
}
// extractJPEGText scans JPEG markers for COM (comment) and APP1 (EXIF) segments
// and returns their printable content.
func extractJPEGText(data []byte) []string {
var out []string
pos := 2 // skip SOI
const maxSegs = 4096
segs := 0
for pos+4 <= len(data) && segs < maxSegs {
segs++
if data[pos] != 0xFF {
pos++
continue
}
marker := data[pos+1]
// Standalone markers without length.
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: // COM
out = append(out, "comment: "+string(seg))
case 0xE1: // APP1 (EXIF/XMP)
for _, s := range printableStrings(seg, 5) {
out = append(out, s)
}
}
if marker == 0xDA { // SOS: start of scan, stop parsing metadata.
break
}
pos += 2 + segLen
}
return out
}