test(golang): make the UPX fixture exercise loader padding (#5354)

UPX pads to a 4 byte boundary before it writes the loader stub. The
image-small-upx build happened to need no padding, so the cataloger
tests over it passed with or without the fix in #5347.

- build the fixture with -X main.Version=1.0.11, which needs 2 bytes of
  padding, so the existing cataloger tests fail without the fix
- add TestImageSmallUPXNeedsLoaderPadding, which fails if a change to the
  fixture's inputs means it no longer needs padding
- run the crafted loader test on both sides of the boundary, so a skip
  that always rounds up is caught too

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
This commit is contained in:
Will Murphy
2026-09-29 19:00:42 +00:00
committed by GitHub
parent c0072873c9
commit c60aabad37
4 changed files with 92 additions and 31 deletions
+13 -12
View File
@@ -59,9 +59,10 @@ func Test_PackageCataloger_Binary(t *testing.T) {
{ {
name: "upx compressed binary", name: "upx compressed binary",
fixture: "image-small-upx", fixture: "image-small-upx",
// v1.0.11, not v1.0.0: see the fixture's Dockerfile
wantErr: require.NoError, wantErr: require.NoError,
expectedPkgs: []string{ expectedPkgs: []string{
"anchore.io/not/real @ v1.0.0 (/run-me)", "anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/andybalholm/brotli @ v1.1.1 (/run-me)", "github.com/andybalholm/brotli @ v1.1.1 (/run-me)",
"github.com/dsnet/compress @ v0.0.2-0.20210315054119-f66993602bf5 (/run-me)", "github.com/dsnet/compress @ v0.0.2-0.20210315054119-f66993602bf5 (/run-me)",
"github.com/golang/snappy @ v0.0.4 (/run-me)", "github.com/golang/snappy @ v0.0.4 (/run-me)",
@@ -75,17 +76,17 @@ func Test_PackageCataloger_Binary(t *testing.T) {
"github.com/anchore/archiver/v3 @ v3.5.3-0.20241210171143-5b1d8d1c7c51 (/run-me)", "github.com/anchore/archiver/v3 @ v3.5.3-0.20241210171143-5b1d8d1c7c51 (/run-me)",
}, },
expectedRels: []string{ expectedRels: []string{
"github.com/andybalholm/brotli @ v1.1.1 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/andybalholm/brotli @ v1.1.1 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/dsnet/compress @ v0.0.2-0.20210315054119-f66993602bf5 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/dsnet/compress @ v0.0.2-0.20210315054119-f66993602bf5 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/golang/snappy @ v0.0.4 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/golang/snappy @ v0.0.4 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/klauspost/compress @ v1.17.11 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/klauspost/compress @ v1.17.11 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/klauspost/pgzip @ v1.2.6 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/klauspost/pgzip @ v1.2.6 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/anchore/archiver/v3 @ v3.5.3-0.20241210171143-5b1d8d1c7c51 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/anchore/archiver/v3 @ v3.5.3-0.20241210171143-5b1d8d1c7c51 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/nwaples/rardecode @ v1.1.3 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/nwaples/rardecode @ v1.1.3 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/pierrec/lz4/v4 @ v4.1.21 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/pierrec/lz4/v4 @ v4.1.21 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/ulikunitz/xz @ v0.5.12 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/ulikunitz/xz @ v0.5.12 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"github.com/xi2/xz @ v0.0.0-20171230120015-48954b6210f8 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "github.com/xi2/xz @ v0.0.0-20171230120015-48954b6210f8 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
"stdlib @ go1.23.2 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.0 (/run-me)", "stdlib @ go1.23.2 (/run-me) [dependency-of] anchore.io/not/real @ v1.0.11 (/run-me)",
}, },
}, },
{ {
@@ -9,7 +9,9 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY main.go main.go COPY main.go main.go
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-X main.Version=1.0.0" -o run-me . # 1.0.11 is chosen so UPX has to pad before its loader stub (1.0.0 needed none, which hid a bug).
# TestImageSmallUPXNeedsLoaderPadding checks the padding is still there.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-X main.Version=1.0.11" -o run-me .
RUN upx --best --lzma --exact run-me RUN upx --best --lzma --exact run-me
FROM scratch FROM scratch
+31 -7
View File
@@ -718,20 +718,42 @@ func TestDecompressUPX_TailExtentsArePlacedPastTheLoader(t *testing.T) {
// front of it. Every other crafted fixture here has l_lsize == 0, so without this skipLoader was only // front of it. Every other crafted fixture here has l_lsize == 0, so without this skipLoader was only
// reachable through the Docker-backed fixture. // reachable through the Docker-backed fixture.
// //
// UPX pads to a 4 byte boundary before it writes the stub and l_lsize counts from there, so the head // UPX pads to a 4 byte boundary before it writes the stub and l_lsize counts from there, so each case
// extent here deliberately ends off that boundary: its end plus l_lsize lands inside the stub. // ends the head extent the given number of bytes short of a boundary. The head is a stored block so its
// length alone decides where it ends; a compressed one ends wherever the encoder happens to stop.
// //
// note: block 1 here is not an ELF, so there are no PT_LOAD offsets and the tail lands sequentially. // note: block 1 here is not an ELF, so there are no PT_LOAD offsets and the tail lands sequentially.
// The hole-filling path this feeds in production is covered by TestFirstHole and by the sparse // The hole-filling path this feeds in production is covered by TestFirstHole and by the sparse
// fixtures in upx_bounds_regression_test.go. // fixtures in upx_bounds_regression_test.go.
const loaderSize = 64 const loaderSize = 64
head := bytes.Repeat([]byte("H"), 128)
tail := bytes.Repeat([]byte("T"), 32) tail := bytes.Repeat([]byte("T"), 32)
tests := []struct {
name string
padding int
}{
{name: "no padding", padding: 0},
{name: "1 byte of padding", padding: 1},
{name: "2 bytes of padding", padding: 2},
{name: "3 bytes of padding", padding: 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := buildUPXHeaderWithLoader(4096, 4096, loaderSize) data := buildUPXHeaderWithLoader(4096, 4096, loaderSize)
data = append(data, blockFor(t, head)...)
require.NotZero(t, len(data)%4, "the head extent has to end off a 4 byte boundary for the padding to exist") // trim a 128 byte head so that it, plus its b_info, ends tt.padding bytes short of a boundary
data = append(data, make([]byte, 4-len(data)%4)...) // the padding UPX writes in front of the stub headLen := 128 - (len(data)+12+128+tt.padding)%4
head := bytes.Repeat([]byte("H"), headLen)
stored := make([]byte, 12)
binary.LittleEndian.PutUint32(stored[0:4], uint32(headLen)) // sz_unc
binary.LittleEndian.PutUint32(stored[4:8], uint32(headLen)) // sz_cpr, equal for a stored block
stored[8] = upxMethodStored
data = append(data, stored...)
data = append(data, head...)
require.Equal(t, tt.padding, (4-len(data)%4)%4, "the head should end tt.padding bytes short of a boundary")
data = append(data, make([]byte, tt.padding)...) // the padding UPX writes in front of the stub
data = append(data, make([]byte, 12)...) // end marker: closes the first run of extents data = append(data, make([]byte, 12)...) // end marker: closes the first run of extents
data = append(data, make([]byte, loaderSize-12)...) // the loader stub the chain skips over data = append(data, make([]byte, loaderSize-12)...) // the loader stub the chain skips over
data = append(data, blockFor(t, tail)...) data = append(data, blockFor(t, tail)...)
@@ -739,12 +761,14 @@ func TestDecompressUPX_TailExtentsArePlacedPastTheLoader(t *testing.T) {
data = padTo(data, 1024) data = padTo(data, 1024)
out, err := unpack(t, data) out, err := unpack(t, data)
// 160 bytes of a declared 4096, short by construction like every other crafted fixture here // a few hundred bytes of a declared 4096, short by construction like every other crafted fixture here
require.ErrorIs(t, err, errUPXPartial) require.ErrorIs(t, err, errUPXPartial)
got := readAll(t, out) got := readAll(t, out)
assert.Equal(t, head, got[:len(head)], "the head extent stays where it was placed") assert.Equal(t, head, got[:len(head)], "the head extent stays where it was placed")
assert.Contains(t, string(got), string(tail), "the extent behind the loader must be placed too") assert.Contains(t, string(got), string(tail), "the extent behind the loader must be placed too")
})
}
} }
func TestDecompressUPX_UnsupportedMethodIsNotReportable(t *testing.T) { func TestDecompressUPX_UnsupportedMethodIsNotReportable(t *testing.T) {
+34
View File
@@ -2,10 +2,14 @@ package golang
import ( import (
"bytes" "bytes"
"io"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/imagetest"
) )
// every case here is one the magic scan must reject, asserted on parseUPXInfo's errNotUPX result. // every case here is one the magic scan must reject, asserted on parseUPXInfo's errNotUPX result.
@@ -104,3 +108,33 @@ func TestParseUPXInfo_ValidHeader(t *testing.T) {
assert.Equal(t, uint8(22), info.format) assert.Equal(t, uint8(22), info.format)
assert.Equal(t, uint32(0x100000), info.originalSize) assert.Equal(t, uint32(0x100000), info.originalSize)
} }
// TestImageSmallUPXNeedsLoaderPadding checks the fixture rather than the code. UPX pads to a 4 byte
// boundary before its loader stub, and the cataloger tests over image-small-upx only catch an unpacker that
// ignores that padding if there is some. A build that needs none passes either way, which is how the bug
// went unnoticed. The case without padding is covered by TestDecompressUPX_TailExtentsArePlacedPastTheLoader.
func TestImageSmallUPXNeedsLoaderPadding(t *testing.T) {
img := imagetest.GetFixtureImage(t, "docker-archive", "image-small-upx")
rc, err := img.OpenPathFromSquash(stereoscopeFile.Path("/run-me"))
require.NoError(t, err)
data, err := io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
r := bytes.NewReader(data)
info, err := parseUPXInfo(r, int64(len(data)))
require.NoError(t, err)
require.NotZero(t, info.loaderSize, "real UPX output carries a loader stub")
// walk the first run of extents to the end marker in front of the stub
extentsEnd := info.firstBlockOff
for blockNum := 0; ; blockNum++ {
block, _, err := readChainBlock(r, extentsEnd, blockNum, info.inputLen)
require.NoError(t, err)
if block == nil {
break
}
extentsEnd = block.dataOffset + int64(block.compressedSize)
}
require.NotZero(t, extentsEnd%4, "the fixture no longer needs padding: change the version string in its Dockerfile")
}