fix(golang): account for UPX's loader padding when unpacking Go binaries (#5347)

UPX pads its output to a 4 byte boundary before it writes the loader
stub, and l_lsize counts from that boundary. The block chain resumed at
the end of the last PT_LOAD extent plus l_lsize, so whenever the
compressed extents ended off a 4 byte boundary it landed 1 to 3 bytes
early, read a misaligned b_info, and stopped. The padding between the
segments was never filled in, the reconstruction ended at that first
hole, and .go.buildinfo in the data segment went with it, so the binary
contributed no packages.

The loader skip now rounds up to 4 before adding l_lsize, which matches
the funpad4 in UPX's own unpacker. The crafted fixture for the tail
extents put its stub straight after the head extent, the layout the bug
assumed, so it now pads the way UPX does.

Signed-off-by: huuyafwww <huuya.yamauchi@3-shake.com>
This commit is contained in:
huuya
2026-09-29 14:25:04 -04:00
committed by GitHub
parent 236ab25393
commit c0072873c9
2 changed files with 8 additions and 2 deletions
+3 -2
View File
@@ -502,13 +502,14 @@ type loaderSkip struct {
}
// past reports the offset the chain resumes at, and false once the jump has been spent or there is no
// stub to jump over.
// stub to jump over. UPX pads to a 4 byte boundary before it writes the stub, and l_lsize counts from that
// boundary rather than from where the last extent ended.
func (l *loaderSkip) past(offset int64) (int64, bool) {
if l.done || l.size == 0 {
return offset, false
}
l.done = true
return offset + int64(l.size), true
return (offset+3)&^3 + int64(l.size), true
}
// decompressUPXBlocks walks the b_info chain, decoding each block into its place in the reconstructed
@@ -718,6 +718,9 @@ func TestDecompressUPX_TailExtentsArePlacedPastTheLoader(t *testing.T) {
// front of it. Every other crafted fixture here has l_lsize == 0, so without this skipLoader was only
// 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
// extent here deliberately ends off that boundary: its end plus l_lsize lands inside the stub.
//
// 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
// fixtures in upx_bounds_regression_test.go.
@@ -727,6 +730,8 @@ func TestDecompressUPX_TailExtentsArePlacedPastTheLoader(t *testing.T) {
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")
data = append(data, make([]byte, 4-len(data)%4)...) // 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, loaderSize-12)...) // the loader stub the chain skips over
data = append(data, blockFor(t, tail)...)