| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "encoding/binary" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // buildStampedProgram builds, for goarch, a real program in a module named |
| 13 | // github.com/carlosframework/platform whose internal/version package holds a |
| 14 | // Build string variable, the shape carlos and cmd/console have, with |
| 15 | // ldflags as given. |
| 16 | func buildStampedProgram(t *testing.T, goarch, ldflags string) []byte { |
| 17 | t.Helper() |
| 18 | dir := t.TempDir() |
| 19 | for name, body := range map[string]string{ |
| 20 | "go.mod": "module github.com/carlosframework/platform\n\ngo 1.21\n", |
| 21 | "internal/version/version.go": "package version\n\nvar Build = \"dev\"\n", |
| 22 | "main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/carlosframework/platform/internal/version\"\n)\n\nfunc main() { fmt.Println(version.Build) }\n", |
| 23 | } { |
| 24 | if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | } |
| 31 | out := filepath.Join(dir, "bin") |
| 32 | args := []string{"build", "-o", out} |
| 33 | if ldflags != "" { |
| 34 | args = append(args, "-ldflags", ldflags) |
| 35 | } |
| 36 | cmd := exec.Command("go", append(args, ".")...) |
| 37 | cmd.Dir = dir |
| 38 | cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+goarch, "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GOWORK=off", "GOPROXY=off") |
| 39 | if output, err := cmd.CombinedOutput(); err != nil { |
| 40 | t.Fatal(string(output), err) |
| 41 | } |
| 42 | b, err := os.ReadFile(out) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return b |
| 47 | } |
| 48 | |
| 49 | // ArtifactBuild reads the -X version.Build flag a real linux-arm64 build |
| 50 | // records, for a program that has the version.Build variable it stamps. It |
| 51 | // reads the recorded flag; it does not establish the linked value |
| 52 | // (controller ruling F2: owed before production cohorts, trusted build |
| 53 | // attestation tied to the artefact digest). |
| 54 | func TestArtifactBuildReadsTheRecordedStampFlag(t *testing.T) { |
| 55 | if testing.Short() { |
| 56 | t.Skip("builds Go binaries") |
| 57 | } |
| 58 | for ldflags, want := range map[string]string{ |
| 59 | "-s -w -X " + BuildStampFlag + "=v0.28.2": "v0.28.2", |
| 60 | "-s -w -X " + BuildStampFlag + "=26065378": "26065378", |
| 61 | // Unstripped: Go compresses its DWARF sections (SHF_COMPRESSED), which |
| 62 | // the bounded pre-check refuses. Release and console builds use -s -w. |
| 63 | "-X " + BuildStampFlag + "=v0.28.2": "", |
| 64 | "": "", |
| 65 | "-s -w": "", |
| 66 | "-s -w -X " + BuildStampFlag + "=v1 -X " + BuildStampFlag + "=v2": "", |
| 67 | "-s -w -X=" + BuildStampFlag + "=v0.28.2": "", |
| 68 | "-s -w -X github.com/carlosframework/platform/internal/version.BuildX=v0.28.2": "", |
| 69 | "-X github.com/carlosframework/platform/internal/other.Build=v0.28.2 -s -w": "", |
| 70 | } { |
| 71 | if got := ArtifactBuild(buildStampedProgram(t, "arm64", ldflags)); got != want { |
| 72 | t.Errorf("ldflags %q: stamp %q, want %q", ldflags, got, want) |
| 73 | } |
| 74 | } |
| 75 | // linux-amd64 is outside the bounded pre-check, so it has no stamp. |
| 76 | if got := ArtifactBuild(buildStampedProgram(t, "amd64", "-s -w -X "+BuildStampFlag+"=v0.28.2")); got != "" { |
| 77 | t.Fatal("stamp read from a non-arm64 binary", got) |
| 78 | } |
| 79 | if ArtifactBuild([]byte("not a binary")) != "" { |
| 80 | t.Fatal("stamp from non-binary") |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // elfMutate returns a copy of a real linux-arm64 build with change applied |
| 85 | // to it, given its section and program header offsets. |
| 86 | func elfMutate(b []byte, change func(b []byte, shoff, phoff uint64)) []byte { |
| 87 | c := append([]byte(nil), b...) |
| 88 | change(c, binary.LittleEndian.Uint64(c[40:]), binary.LittleEndian.Uint64(c[32:])) |
| 89 | return c |
| 90 | } |
| 91 | |
| 92 | // elfShstrtabCompressed sets SHF_COMPRESSED on the section-name table of a |
| 93 | // real linux-arm64 build: the shape whose decompression Go's ELF reader |
| 94 | // performs during parsing, unbounded by the file size. |
| 95 | func elfShstrtabCompressed(b []byte) []byte { |
| 96 | return elfMutate(b, func(c []byte, shoff, _ uint64) { |
| 97 | idx := uint64(binary.LittleEndian.Uint16(c[62:])) |
| 98 | h := c[shoff+idx*64:] |
| 99 | binary.LittleEndian.PutUint64(h[8:], binary.LittleEndian.Uint64(h[8:])|0x800) |
| 100 | }) |
| 101 | } |
| 102 | |
| 103 | // elfOversizedShnum claims 65000 section headers in a real build. |
| 104 | func elfOversizedShnum(b []byte) []byte { |
| 105 | return elfMutate(b, func(c []byte, _, _ uint64) { binary.LittleEndian.PutUint16(c[60:], 65000) }) |
| 106 | } |
| 107 | |
| 108 | // elfRepeatedName appends a name table holding one name of size bytes |
| 109 | // to a real build, points its section-name table at it and every section's |
| 110 | // name at that one name: the shape debug/elf would copy once per section |
| 111 | // (Astra's re-review of F1). |
| 112 | func elfRepeatedName(b []byte, size int) []byte { |
| 113 | c := append(append([]byte(nil), b...), make([]byte, size+1)...) |
| 114 | for i := len(b); i < len(b)+size; i++ { |
| 115 | c[i] = 'a' |
| 116 | } |
| 117 | le := binary.LittleEndian |
| 118 | shoff, shnum, idx := le.Uint64(c[40:]), uint64(le.Uint16(c[60:])), uint64(le.Uint16(c[62:])) |
| 119 | h := c[shoff+idx*64:] |
| 120 | le.PutUint64(h[24:], uint64(len(b))) |
| 121 | le.PutUint64(h[32:], uint64(size+1)) |
| 122 | for i := uint64(0); i < shnum; i++ { |
| 123 | le.PutUint32(c[shoff+i*64:], 0) |
| 124 | } |
| 125 | return c |
| 126 | } |
| 127 | |
| 128 | func TestCheckELFBoundsRefusesUnboundedMetadata(t *testing.T) { |
| 129 | if testing.Short() { |
| 130 | t.Skip("builds Go binaries") |
| 131 | } |
| 132 | real := buildStampedProgram(t, "arm64", "-s -w -X "+BuildStampFlag+"=v0.28.2") |
| 133 | if err := CheckELFBounds(real); err != nil { |
| 134 | t.Fatal("a real stripped linux-arm64 build refused", err) |
| 135 | } |
| 136 | // A 256-byte name, repeated, is inside the caps: 256 x 256 bytes at most. |
| 137 | if err := CheckELFBounds(elfRepeatedName(real, 256)); err != nil { |
| 138 | t.Fatal("a name at the cap refused", err) |
| 139 | } |
| 140 | le := binary.LittleEndian |
| 141 | for name, crafted := range map[string][]byte{ |
| 142 | "compressed section-name table": elfShstrtabCompressed(real), |
| 143 | "oversized shnum": elfOversizedShnum(real), |
| 144 | "compressed data section": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| 145 | h := c[shoff+3*64:] |
| 146 | le.PutUint64(h[8:], le.Uint64(h[8:])|0x800) |
| 147 | }), |
| 148 | "extended numbering": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[60:], 0) }), |
| 149 | "shstrndx extended": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[62:], 0xffff) }), |
| 150 | "shstrndx past table": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[62:], le.Uint16(c[60:])) }), |
| 151 | "oversized phnum": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[56:], 65000) }), |
| 152 | "section table outside": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[40:], uint64(len(c))) }), |
| 153 | "section table wraps": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[40:], ^uint64(0)-10) }), |
| 154 | "tables overlap": elfMutate(real, func(c []byte, _, phoff uint64) { le.PutUint64(c[40:], phoff) }), |
| 155 | "table over the header": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[32:], 0) }), |
| 156 | "wrong header entry size": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[58:], 40) }), |
| 157 | "section past the end": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| 158 | h := c[shoff+1*64:] |
| 159 | le.PutUint32(h[4:], 1) |
| 160 | le.PutUint64(h[32:], uint64(len(c))+1) |
| 161 | }), |
| 162 | "segment past the end": elfMutate(real, func(c []byte, _, phoff uint64) { le.PutUint64(c[phoff+32:], uint64(len(c))+1) }), |
| 163 | "overlapping sections": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| 164 | a, b := c[shoff+1*64:], c[shoff+2*64:] |
| 165 | le.PutUint32(a[4:], 1) |
| 166 | le.PutUint32(b[4:], 1) |
| 167 | le.PutUint64(b[24:], le.Uint64(a[24:])) |
| 168 | if le.Uint64(a[32:]) == 0 || le.Uint64(b[32:]) == 0 { |
| 169 | le.PutUint64(a[32:], 16) |
| 170 | le.PutUint64(b[32:], 16) |
| 171 | } |
| 172 | }), |
| 173 | "repeated 32 MiB name": elfRepeatedName(real, 32<<20), |
| 174 | "name table over 64 KiB": elfRepeatedName(real, 64<<10), |
| 175 | "repeated 257-byte name": elfRepeatedName(real, 257), |
| 176 | "name table not a strtab": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| 177 | le.PutUint32(c[shoff+uint64(le.Uint16(c[62:]))*64+4:], 1) |
| 178 | }), |
| 179 | "empty name table": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| 180 | le.PutUint64(c[shoff+uint64(le.Uint16(c[62:]))*64+32:], 0) |
| 181 | }), |
| 182 | "name offset outside the table": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| 183 | size := le.Uint64(c[shoff+uint64(le.Uint16(c[62:]))*64+32:]) |
| 184 | le.PutUint32(c[shoff+64:], uint32(size)) |
| 185 | }), |
| 186 | "x86-64": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[18:], 62) }), |
| 187 | "32-bit class": elfMutate(real, func(c []byte, _, _ uint64) { c[4] = 1 }), |
| 188 | "big endian": elfMutate(real, func(c []byte, _, _ uint64) { c[5] = 2 }), |
| 189 | "not ELF": append([]byte("MZ"), real[2:]...), |
| 190 | "truncated": real[:63], |
| 191 | "empty": nil, |
| 192 | } { |
| 193 | if err := CheckELFBounds(crafted); !errors.Is(err, ErrELFBounds) { |
| 194 | t.Errorf("%s: %v", name, err) |
| 195 | } |
| 196 | if ArtifactBuild(crafted) != "" { |
| 197 | t.Errorf("%s: stamp read", name) |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | |