Skip to content

Commit cbdd549

Browse files
authored
Detect native PHAR archives (#7)
1 parent a804821 commit cbdd549

7 files changed

Lines changed: 481 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ the exported `Format*` constants rather than string literals.
5454

5555
The format registry contains:
5656

57-
- ZIP, TAR, ar, gzip, bzip2, xz, zstd, PDF, CFBF, PNG, JPEG, and GIF
57+
- ZIP, TAR, native PHAR, ar, gzip, bzip2, xz, zstd, PDF, CFBF, PNG, JPEG, and GIF
5858
- ELF, Mach-O (thin and universal), PE/COFF, and WebAssembly
5959
- plain text, HTML, XML, and SVG
6060

@@ -89,9 +89,10 @@ Run the package benchmarks on the target machine:
8989
go test -run '^$' -bench . -benchmem
9090
```
9191

92-
The implementation scans at most 512 bytes for registered signatures. Text
93-
validation is linear in the supplied byte count and uses fixed auxiliary
94-
memory.
92+
Fixed signatures inspect at most 512 bytes, while native PHAR detection
93+
searches for the end of the PHP stub and validates the manifest and stored
94+
payload bounds. Text validation is linear in the supplied byte count and uses
95+
fixed auxiliary memory.
9596

9697
## Provenance
9798

‎fuzz_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func FuzzDetect(f *testing.F) {
3131
f.Add(seed)
3232
}
3333
f.Add(makeTAR(f))
34+
f.Add(makeNativePHAR(pharTestStub, "", nil, pharTestEntry{name: "file", content: []byte("data")}))
3435

3536
f.Fuzz(func(t *testing.T, data []byte) {
3637
first := Detect(data)

‎magic.go‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const (
4848
FormatSVG = "svg"
4949
FormatZIP = "zip"
5050
FormatTAR = "tar"
51+
FormatPHAR = "phar"
5152
FormatGZIP = "gzip"
5253
FormatBZIP2 = "bzip2"
5354
FormatXZ = "xz"
@@ -71,6 +72,7 @@ const (
7172
mimeSVG = "image/svg+xml"
7273
mimeZIP = "application/zip"
7374
mimeTAR = "application/x-tar"
75+
mimePHAR = "application/x-phar"
7476
mimeGZIP = "application/gzip"
7577
mimeBZIP2 = "application/x-bzip2"
7678
mimeXZ = "application/x-xz"
@@ -108,15 +110,16 @@ func DetectPrefix(prefix []byte) Result {
108110
}
109111

110112
func detect(data []byte, prefix bool) Result {
111-
if format, mime := binaryFormat(data); format != "" {
113+
format, mime, binaryNeedsMore := binaryFormatState(data)
114+
if format != "" {
112115
return Result{
113116
Kind: KindBinary,
114117
MIME: mime,
115118
Format: format,
116119
}
117120
}
118121

119-
format, mime := textFormat(data)
122+
format, mime = textFormat(data)
120123
result := classifyText(data)
121124
if format != "" {
122125
result.Format = format
@@ -126,7 +129,7 @@ func detect(data []byte, prefix bool) Result {
126129
result.MIME = mimeText
127130
}
128131

129-
if prefix && prefixResultCanChange(result, len(data)) {
132+
if prefix && (binaryNeedsMore || prefixResultCanChange(result, len(data))) {
130133
result.Reason = ReasonNeedMore
131134
}
132135

@@ -135,7 +138,8 @@ func detect(data []byte, prefix bool) Result {
135138

136139
func prefixResultCanChange(result Result, inputLength int) bool {
137140
if result.Kind == KindBinary {
138-
// sniffLength is also the furthest offset read by a binary signature.
141+
// Fixed-offset binary signatures are final once the sniff window is
142+
// present. Incomplete PHAR validation is handled before this function.
139143
return inputLength < sniffLength
140144
}
141145
return true

‎magic_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func TestDetectAllocations(t *testing.T) {
135135
[]byte("package magic\n"),
136136
[]byte("\xff\xfeh\x00i\x00"),
137137
[]byte("\x89PNG\r\n\x1a\n"),
138+
makeNativePHAR(pharTestStub, "", nil, pharTestEntry{name: "file", content: []byte("data")}),
138139
}
139140
for _, input := range inputs {
140141
if allocations := testing.AllocsPerRun(1000, func() {

‎phar.go‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package magic
2+
3+
import (
4+
"bytes"
5+
"encoding/binary"
6+
)
7+
8+
const (
9+
pharHaltCompiler = "__HALT_COMPILER();"
10+
pharManifestFixedLen = 18
11+
pharEntryFixedLen = 28
12+
pharEntryMinLen = pharEntryFixedLen + 1
13+
pharManifestMaxLen = 100 << 20
14+
pharAPIVersionMask = 0xfff0
15+
pharMinimumAPIVersion = 0x1000
16+
pharManifestLengthSize = 4
17+
pharClosingTagSize = 3
18+
)
19+
20+
type pharStatus uint8
21+
22+
const (
23+
pharNotFound pharStatus = iota
24+
pharIncomplete
25+
pharValid
26+
)
27+
28+
func nativePHAR(data []byte) pharStatus {
29+
stubEnd := bytes.Index(data, []byte(pharHaltCompiler))
30+
if stubEnd < 0 {
31+
return pharNotFound
32+
}
33+
stubEnd += len(pharHaltCompiler)
34+
35+
manifestOffset, status := pharManifestOffset(data, stubEnd)
36+
if status != pharValid {
37+
return status
38+
}
39+
if len(data)-manifestOffset < pharManifestLengthSize {
40+
return pharIncomplete
41+
}
42+
43+
manifestLength := binary.LittleEndian.Uint32(data[manifestOffset:])
44+
if manifestLength < pharManifestFixedLen || manifestLength > pharManifestMaxLen {
45+
return pharNotFound
46+
}
47+
48+
manifestStart := manifestOffset + pharManifestLengthSize
49+
manifestEnd64 := uint64(manifestStart) + uint64(manifestLength)
50+
if manifestEnd64 > uint64(len(data)) {
51+
return pharIncomplete
52+
}
53+
manifestEnd := int(manifestEnd64)
54+
manifest := data[manifestStart:manifestEnd]
55+
56+
entryCount := binary.LittleEndian.Uint32(manifest)
57+
if entryCount == 0 {
58+
return pharNotFound
59+
}
60+
apiVersion := binary.BigEndian.Uint16(manifest[4:6])
61+
if apiVersion&pharAPIVersionMask < pharMinimumAPIVersion {
62+
return pharNotFound
63+
}
64+
65+
offset := 10 // entry count, API version, and global flags
66+
aliasLength, ok := pharUint32(manifest, &offset)
67+
if !ok || !pharSkip(manifest, &offset, aliasLength) {
68+
return pharNotFound
69+
}
70+
metadataLength, ok := pharUint32(manifest, &offset)
71+
if !ok || !pharSkip(manifest, &offset, metadataLength) {
72+
return pharNotFound
73+
}
74+
if uint64(entryCount)*pharEntryMinLen > uint64(len(manifest)-offset) {
75+
return pharNotFound
76+
}
77+
78+
var payloadLength uint64
79+
for range entryCount {
80+
filenameLength, ok := pharUint32(manifest, &offset)
81+
if !ok || filenameLength == 0 || !pharSkip(manifest, &offset, filenameLength) {
82+
return pharNotFound
83+
}
84+
if len(manifest)-offset < pharEntryFixedLen-pharManifestLengthSize {
85+
return pharNotFound
86+
}
87+
88+
compressedSize := binary.LittleEndian.Uint32(manifest[offset+8:])
89+
metadataLength := binary.LittleEndian.Uint32(manifest[offset+20:])
90+
offset += pharEntryFixedLen - pharManifestLengthSize
91+
if !pharSkip(manifest, &offset, metadataLength) {
92+
return pharNotFound
93+
}
94+
payloadLength += uint64(compressedSize)
95+
}
96+
97+
if uint64(manifestEnd)+payloadLength > uint64(len(data)) {
98+
return pharIncomplete
99+
}
100+
return pharValid
101+
}
102+
103+
func pharManifestOffset(data []byte, offset int) (int, pharStatus) {
104+
if offset >= len(data) {
105+
return 0, pharIncomplete
106+
}
107+
if data[offset] != ' ' && data[offset] != '\n' {
108+
return offset, pharValid
109+
}
110+
if len(data)-offset < pharClosingTagSize {
111+
return 0, pharIncomplete
112+
}
113+
if data[offset+1] != '?' || data[offset+2] != '>' {
114+
return offset, pharValid
115+
}
116+
117+
offset += pharClosingTagSize
118+
if offset >= len(data) {
119+
return 0, pharIncomplete
120+
}
121+
switch data[offset] {
122+
case '\n':
123+
offset++
124+
case '\r':
125+
if offset+1 >= len(data) {
126+
return 0, pharIncomplete
127+
}
128+
if data[offset+1] != '\n' {
129+
return 0, pharNotFound
130+
}
131+
offset += 2
132+
}
133+
return offset, pharValid
134+
}
135+
136+
func pharUint32(data []byte, offset *int) (uint32, bool) {
137+
if len(data)-*offset < pharManifestLengthSize {
138+
return 0, false
139+
}
140+
value := binary.LittleEndian.Uint32(data[*offset:])
141+
*offset += 4
142+
return value, true
143+
}
144+
145+
func pharSkip(data []byte, offset *int, length uint32) bool {
146+
end := uint64(*offset) + uint64(length)
147+
if end > uint64(len(data)) {
148+
return false
149+
}
150+
*offset = int(end)
151+
return true
152+
}

0 commit comments

Comments
 (0)