From c2b3d153d474522fbd9a90c617fc328393fb5676 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 16 Aug 2026 22:25:47 +0100 Subject: [PATCH] Detect native PHAR archives --- README.md | 9 +- fuzz_test.go | 1 + magic.go | 12 ++- magic_test.go | 1 + phar.go | 152 +++++++++++++++++++++++++++ phar_test.go | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++ signatures.go | 52 ++++++---- 7 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 phar.go create mode 100644 phar_test.go diff --git a/README.md b/README.md index 4dac7de..8512010 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ the exported `Format*` constants rather than string literals. The format registry contains: -- ZIP, TAR, ar, gzip, bzip2, xz, zstd, PDF, CFBF, PNG, JPEG, and GIF +- ZIP, TAR, native PHAR, ar, gzip, bzip2, xz, zstd, PDF, CFBF, PNG, JPEG, and GIF - ELF, Mach-O (thin and universal), PE/COFF, and WebAssembly - plain text, HTML, XML, and SVG @@ -89,9 +89,10 @@ Run the package benchmarks on the target machine: go test -run '^$' -bench . -benchmem ``` -The implementation scans at most 512 bytes for registered signatures. Text -validation is linear in the supplied byte count and uses fixed auxiliary -memory. +Fixed signatures inspect at most 512 bytes, while native PHAR detection +searches for the end of the PHP stub and validates the manifest and stored +payload bounds. Text validation is linear in the supplied byte count and uses +fixed auxiliary memory. ## Provenance diff --git a/fuzz_test.go b/fuzz_test.go index db2fefc..90be752 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -31,6 +31,7 @@ func FuzzDetect(f *testing.F) { f.Add(seed) } f.Add(makeTAR(f)) + f.Add(makeNativePHAR(pharTestStub, "", nil, pharTestEntry{name: "file", content: []byte("data")})) f.Fuzz(func(t *testing.T, data []byte) { first := Detect(data) diff --git a/magic.go b/magic.go index 4b265ae..f9abaeb 100644 --- a/magic.go +++ b/magic.go @@ -48,6 +48,7 @@ const ( FormatSVG = "svg" FormatZIP = "zip" FormatTAR = "tar" + FormatPHAR = "phar" FormatGZIP = "gzip" FormatBZIP2 = "bzip2" FormatXZ = "xz" @@ -71,6 +72,7 @@ const ( mimeSVG = "image/svg+xml" mimeZIP = "application/zip" mimeTAR = "application/x-tar" + mimePHAR = "application/x-phar" mimeGZIP = "application/gzip" mimeBZIP2 = "application/x-bzip2" mimeXZ = "application/x-xz" @@ -108,7 +110,8 @@ func DetectPrefix(prefix []byte) Result { } func detect(data []byte, prefix bool) Result { - if format, mime := binaryFormat(data); format != "" { + format, mime, binaryNeedsMore := binaryFormatState(data) + if format != "" { return Result{ Kind: KindBinary, MIME: mime, @@ -116,7 +119,7 @@ func detect(data []byte, prefix bool) Result { } } - format, mime := textFormat(data) + format, mime = textFormat(data) result := classifyText(data) if format != "" { result.Format = format @@ -126,7 +129,7 @@ func detect(data []byte, prefix bool) Result { result.MIME = mimeText } - if prefix && prefixResultCanChange(result, len(data)) { + if prefix && (binaryNeedsMore || prefixResultCanChange(result, len(data))) { result.Reason = ReasonNeedMore } @@ -135,7 +138,8 @@ func detect(data []byte, prefix bool) Result { func prefixResultCanChange(result Result, inputLength int) bool { if result.Kind == KindBinary { - // sniffLength is also the furthest offset read by a binary signature. + // Fixed-offset binary signatures are final once the sniff window is + // present. Incomplete PHAR validation is handled before this function. return inputLength < sniffLength } return true diff --git a/magic_test.go b/magic_test.go index 39c7bf0..f832414 100644 --- a/magic_test.go +++ b/magic_test.go @@ -135,6 +135,7 @@ func TestDetectAllocations(t *testing.T) { []byte("package magic\n"), []byte("\xff\xfeh\x00i\x00"), []byte("\x89PNG\r\n\x1a\n"), + makeNativePHAR(pharTestStub, "", nil, pharTestEntry{name: "file", content: []byte("data")}), } for _, input := range inputs { if allocations := testing.AllocsPerRun(1000, func() { diff --git a/phar.go b/phar.go new file mode 100644 index 0000000..7edf529 --- /dev/null +++ b/phar.go @@ -0,0 +1,152 @@ +package magic + +import ( + "bytes" + "encoding/binary" +) + +const ( + pharHaltCompiler = "__HALT_COMPILER();" + pharManifestFixedLen = 18 + pharEntryFixedLen = 28 + pharEntryMinLen = pharEntryFixedLen + 1 + pharManifestMaxLen = 100 << 20 + pharAPIVersionMask = 0xfff0 + pharMinimumAPIVersion = 0x1000 + pharManifestLengthSize = 4 + pharClosingTagSize = 3 +) + +type pharStatus uint8 + +const ( + pharNotFound pharStatus = iota + pharIncomplete + pharValid +) + +func nativePHAR(data []byte) pharStatus { + stubEnd := bytes.Index(data, []byte(pharHaltCompiler)) + if stubEnd < 0 { + return pharNotFound + } + stubEnd += len(pharHaltCompiler) + + manifestOffset, status := pharManifestOffset(data, stubEnd) + if status != pharValid { + return status + } + if len(data)-manifestOffset < pharManifestLengthSize { + return pharIncomplete + } + + manifestLength := binary.LittleEndian.Uint32(data[manifestOffset:]) + if manifestLength < pharManifestFixedLen || manifestLength > pharManifestMaxLen { + return pharNotFound + } + + manifestStart := manifestOffset + pharManifestLengthSize + manifestEnd64 := uint64(manifestStart) + uint64(manifestLength) + if manifestEnd64 > uint64(len(data)) { + return pharIncomplete + } + manifestEnd := int(manifestEnd64) + manifest := data[manifestStart:manifestEnd] + + entryCount := binary.LittleEndian.Uint32(manifest) + if entryCount == 0 { + return pharNotFound + } + apiVersion := binary.BigEndian.Uint16(manifest[4:6]) + if apiVersion&pharAPIVersionMask < pharMinimumAPIVersion { + return pharNotFound + } + + offset := 10 // entry count, API version, and global flags + aliasLength, ok := pharUint32(manifest, &offset) + if !ok || !pharSkip(manifest, &offset, aliasLength) { + return pharNotFound + } + metadataLength, ok := pharUint32(manifest, &offset) + if !ok || !pharSkip(manifest, &offset, metadataLength) { + return pharNotFound + } + if uint64(entryCount)*pharEntryMinLen > uint64(len(manifest)-offset) { + return pharNotFound + } + + var payloadLength uint64 + for range entryCount { + filenameLength, ok := pharUint32(manifest, &offset) + if !ok || filenameLength == 0 || !pharSkip(manifest, &offset, filenameLength) { + return pharNotFound + } + if len(manifest)-offset < pharEntryFixedLen-pharManifestLengthSize { + return pharNotFound + } + + compressedSize := binary.LittleEndian.Uint32(manifest[offset+8:]) + metadataLength := binary.LittleEndian.Uint32(manifest[offset+20:]) + offset += pharEntryFixedLen - pharManifestLengthSize + if !pharSkip(manifest, &offset, metadataLength) { + return pharNotFound + } + payloadLength += uint64(compressedSize) + } + + if uint64(manifestEnd)+payloadLength > uint64(len(data)) { + return pharIncomplete + } + return pharValid +} + +func pharManifestOffset(data []byte, offset int) (int, pharStatus) { + if offset >= len(data) { + return 0, pharIncomplete + } + if data[offset] != ' ' && data[offset] != '\n' { + return offset, pharValid + } + if len(data)-offset < pharClosingTagSize { + return 0, pharIncomplete + } + if data[offset+1] != '?' || data[offset+2] != '>' { + return offset, pharValid + } + + offset += pharClosingTagSize + if offset >= len(data) { + return 0, pharIncomplete + } + switch data[offset] { + case '\n': + offset++ + case '\r': + if offset+1 >= len(data) { + return 0, pharIncomplete + } + if data[offset+1] != '\n' { + return 0, pharNotFound + } + offset += 2 + } + return offset, pharValid +} + +func pharUint32(data []byte, offset *int) (uint32, bool) { + if len(data)-*offset < pharManifestLengthSize { + return 0, false + } + value := binary.LittleEndian.Uint32(data[*offset:]) + *offset += 4 + return value, true +} + +func pharSkip(data []byte, offset *int, length uint32) bool { + end := uint64(*offset) + uint64(length) + if end > uint64(len(data)) { + return false + } + *offset = int(end) + return true +} diff --git a/phar_test.go b/phar_test.go new file mode 100644 index 0000000..262a61c --- /dev/null +++ b/phar_test.go @@ -0,0 +1,282 @@ +package magic + +import ( + "bytes" + "encoding/binary" + "hash/crc32" + "strings" + "testing" +) + +const ( + pharTestStub = "\n", + entries: []pharTestEntry{ + {name: "bin/tool", content: []byte("tool")}, + }, + }, + { + name: "closing tag and CRLF", + stub: pharTestStub, + stubEnd: "\n?>\r\n", + entries: []pharTestEntry{ + {name: "bin/tool", content: []byte("tool")}, + }, + }, + { + name: "long default-sized stub and opaque metadata", + stub: "\n", + globalMetadata: []byte("a:1:{s:3:\"key\";s:5:\"value\";}"), + entries: []pharTestEntry{ + { + name: "src/main.php", + content: []byte("\n", + nil, + pharTestEntry{name: pharTestFilename, content: []byte("payload")}, + ) + stubEnd := bytes.Index(valid, []byte(pharHaltCompiler)) + len(pharHaltCompiler) + manifestOffset := stubEnd + len(" ?>\n") + manifestLength := int(binary.LittleEndian.Uint32(valid[manifestOffset:])) + manifestEnd := manifestOffset + pharManifestLengthSize + manifestLength + + lengths := []int{ + stubEnd, + stubEnd + 2, + manifestOffset + 3, + manifestOffset + pharManifestLengthSize + 10, + manifestEnd, + len(valid) - 1, + } + for _, length := range lengths { + got := DetectPrefix(valid[:length]) + if got.Reason != ReasonNeedMore { + t.Fatalf("DetectPrefix(%d bytes) = %#v, want %q", length, got, ReasonNeedMore) + } + } + + assertResult(t, DetectPrefix(valid), Result{ + Kind: KindBinary, + MIME: mimePHAR, + Format: FormatPHAR, + }) + + invalid := bytes.Clone(valid) + binary.LittleEndian.PutUint32(invalid[manifestOffset+pharManifestLengthSize:], 0) + if got := DetectPrefix(invalid); got.Reason == ReasonNeedMore { + t.Fatalf("invalid complete manifest = %#v, want final non-PHAR result", got) + } +} + +func makeNativePHAR( + stub string, + stubEnd string, + globalMetadata []byte, + entries ...pharTestEntry, +) []byte { + manifest := make([]byte, 0, pharManifestFixedLen+len(entries)*pharEntryMinLen) + manifest = appendPHARUint32(manifest, uint32(len(entries))) + manifest = append(manifest, 0x11, 0x10) // API version 1.1.1 + manifest = appendPHARUint32(manifest, 0) + manifest = appendPHARUint32(manifest, 0) + manifest = appendPHARUint32(manifest, uint32(len(globalMetadata))) + manifest = append(manifest, globalMetadata...) + + var payload []byte + for _, entry := range entries { + manifest = appendPHARUint32(manifest, uint32(len(entry.name))) + manifest = append(manifest, entry.name...) + manifest = appendPHARUint32(manifest, uint32(len(entry.content))) + manifest = appendPHARUint32(manifest, 0) + manifest = appendPHARUint32(manifest, uint32(len(entry.content))) + manifest = appendPHARUint32(manifest, crc32.ChecksumIEEE(entry.content)) + manifest = appendPHARUint32(manifest, 0o666) + manifest = appendPHARUint32(manifest, uint32(len(entry.metadata))) + manifest = append(manifest, entry.metadata...) + payload = append(payload, entry.content...) + } + + data := make([]byte, 0, len(stub)+len(pharHaltCompiler)+len(stubEnd)+4+len(manifest)+len(payload)) + data = append(data, stub...) + data = append(data, pharHaltCompiler...) + data = append(data, stubEnd...) + data = appendPHARUint32(data, uint32(len(manifest))) + data = append(data, manifest...) + return append(data, payload...) +} + +func appendPHARUint32(data []byte, value uint32) []byte { + return binary.LittleEndian.AppendUint32(data, value) +} diff --git a/signatures.go b/signatures.go index dc4cfee..63b0290 100644 --- a/signatures.go +++ b/signatures.go @@ -10,8 +10,8 @@ package magic import "encoding/binary" const ( - // sniffLength is the furthest byte inspected by any signature. A binary - // rule that reads beyond it must also update prefixResultCanChange. + // sniffLength is the furthest byte inspected by a fixed-offset signature. + // Native PHAR detection separately locates and validates its manifest. sniffLength = 512 tarMagicOffset = 257 tarMagicEnd = 263 @@ -55,50 +55,62 @@ var htmlSignatures = [...]string{ } func binaryFormat(data []byte) (format, mime string) { + format, mime, _ = binaryFormatState(data) + return format, mime +} + +func binaryFormatState(data []byte) (format, mime string, needMore bool) { switch { case hasPrefix(data, "PK\x03\x04"), hasPrefix(data, "PK\x05\x06"), hasPrefix(data, "PK\x07\x08"): - return FormatZIP, mimeZIP + return FormatZIP, mimeZIP, false case hasPrefix(data, "\x1f\x8b\x08"): - return FormatGZIP, mimeGZIP + return FormatGZIP, mimeGZIP, false case len(data) >= 4 && hasPrefix(data, "BZh") && data[3] >= '1' && data[3] <= '9': - return FormatBZIP2, mimeBZIP2 + return FormatBZIP2, mimeBZIP2, false case hasPrefix(data, "\xfd7zXZ\x00"): - return FormatXZ, mimeXZ + return FormatXZ, mimeXZ, false case hasPrefix(data, "%PDF-"): - return FormatPDF, mimePDF + return FormatPDF, mimePDF, false case hasPrefix(data, "\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"): - return FormatCFBF, mimeCFBF + return FormatCFBF, mimeCFBF, false case hasPrefix(data, "\x89PNG\r\n\x1a\n"): - return FormatPNG, mimePNG + return FormatPNG, mimePNG, false case hasPrefix(data, "\xff\xd8\xff"): - return FormatJPEG, mimeJPEG + return FormatJPEG, mimeJPEG, false case hasPrefix(data, "GIF87a"), hasPrefix(data, "GIF89a"): - return FormatGIF, mimeGIF + return FormatGIF, mimeGIF, false case hasPrefix(data, "\x28\xb5\x2f\xfd"): - return FormatZstd, mimeZstd + return FormatZstd, mimeZstd, false case hasPrefix(data, "\x7fELF"): - return FormatELF, mimeELF + return FormatELF, mimeELF, false case hasPrefix(data, "\xcf\xfa\xed\xfe"), hasPrefix(data, "\xce\xfa\xed\xfe"), hasPrefix(data, "\xfe\xed\xfa\xcf"), hasPrefix(data, "\xfe\xed\xfa\xce"): - return FormatMachO, mimeMachO + return FormatMachO, mimeMachO, false case machOFatHeader(data): - return FormatMachO, mimeMachO + return FormatMachO, mimeMachO, false case hasPrefix(data, "\x00asm"): - return FormatWASM, mimeWASM + return FormatWASM, mimeWASM, false case hasPrefix(data, "!\n"): - return FormatAR, mimeAR + return FormatAR, mimeAR, false case peHeader(data): - return FormatPE, mimePE + return FormatPE, mimePE, false case validTARHeader(data): - return FormatTAR, mimeTAR + return FormatTAR, mimeTAR, false default: - return "", "" + switch nativePHAR(data) { + case pharValid: + return FormatPHAR, mimePHAR, false + case pharIncomplete: + return "", "", true + default: + return "", "", false + } } }