Skip to content

Commit 26b6027

Browse files
cwayne18Copilot
andcommitted
pkgdb: recover from go-rpmdb panics on malformed rpm headers
Scanning an image whose rpm database carries a malformed header crashed the whole run instead of failing the scan. go-rpmdb v0.1.1 imports a header region as peList[1:ril] (entry.go:170) without checking ril >= 1, so a header whose region trailer yields a zero index-length panics with "slice bounds out of range [1:0]". rancher/rancher:v2.15.0 is one such image; the panic propagated from ListPackages up through ospkg and killed the process. The parse path has been unguarded since the rpm backend first shipped, and v0.1.1 is the latest go-rpmdb release, so there is no upstream fix to pull in. Wrap ListPackages and InstalledFileNames in a recover that turns a parser panic into an error. A database vexscan cannot parse is already treated as a scan failure by pkgdb.Read, so this keeps that contract rather than crashing. The regression test builds a real SQLite rpm database holding the smallest header that reaches the panicking slice and asserts Read returns an error. Without the recover the same test reproduces the original crash. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 2a8623e commit 26b6027

2 files changed

Lines changed: 117 additions & 2 deletions

File tree

internal/pkgdb/rpm.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func (r *RPM) Read(fsys target.RootFS) ([]Package, error) {
6363
}
6464
defer handle.Close()
6565

66-
infos, err := handle.ListPackages()
66+
infos, err := listPackages(handle)
6767
if err != nil {
6868
return nil, fmt.Errorf("%s: %w", db, err)
6969
}
@@ -88,7 +88,7 @@ func (r *RPM) Read(fsys target.RootFS) ([]Package, error) {
8888
// A package with no file list is normal (metapackages own nothing),
8989
// but a header that fails to decode is not, and silently dropping its
9090
// files would make the package look like docs-only.
91-
files, err := info.InstalledFileNames()
91+
files, err := installedFileNames(info)
9292
if err != nil {
9393
return nil, fmt.Errorf("%s: file list for %s: %w", db, info.Name, err)
9494
}
@@ -105,3 +105,31 @@ func (r *RPM) Read(fsys target.RootFS) ([]Package, error) {
105105
// rpmEVR, sourceRPMName and normalizePaths live in rpmfile.go, which carries no
106106
// build tag: the file reader needs them too, and it has to keep working in the
107107
// norpm build that omits everything above.
108+
109+
// listPackages wraps go-rpmdb's ListPackages so a parser panic becomes an
110+
// error. go-rpmdb v0.1.1 slices a header region as peList[1:ril] at
111+
// entry.go:170 without checking ril >= 1, so a header whose region index length
112+
// is zero -- as occurs in some rpm databases in the wild -- panics with "slice
113+
// bounds out of range [1:0]" rather than returning an error. A database we
114+
// cannot parse is a scan failure, not a crash of the whole run.
115+
func listPackages(handle *rpmdb.RpmDB) (infos []*rpmdb.PackageInfo, err error) {
116+
defer func() {
117+
if r := recover(); r != nil {
118+
infos = nil
119+
err = fmt.Errorf("go-rpmdb panicked parsing the database: %v", r)
120+
}
121+
}()
122+
return handle.ListPackages()
123+
}
124+
125+
// installedFileNames wraps InstalledFileNames for the same reason: it decodes
126+
// the package's header on demand and can panic on the same malformed input.
127+
func installedFileNames(info *rpmdb.PackageInfo) (names []string, err error) {
128+
defer func() {
129+
if r := recover(); r != nil {
130+
names = nil
131+
err = fmt.Errorf("go-rpmdb panicked reading the file list: %v", r)
132+
}
133+
}()
134+
return info.InstalledFileNames()
135+
}

internal/pkgdb/rpm_panic_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//go:build !norpm
2+
3+
package pkgdb
4+
5+
import (
6+
"database/sql"
7+
"encoding/binary"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
13+
"github.com/cwayne18/vexscan/internal/target"
14+
)
15+
16+
// panicHeader is an rpm header blob that drives go-rpmdb v0.1.1 into a panic
17+
// rather than an error. hdrblobImport slices the entry list as peList[1:ril]
18+
// (entry.go:170) without checking ril >= 1, so a header whose region trailer
19+
// yields a zero region index-length crashes with "slice bounds out of range
20+
// [1:0]". Databases in the wild carry such headers -- rancher/rancher:v2.15.0
21+
// is one -- and a database we cannot parse must be a scan error, not a crash of
22+
// the whole run.
23+
//
24+
// The bytes are the smallest header that reaches that line: one index entry
25+
// (il=1) that is a HEADERIMMUTABLE region tag with a non-zero offset, and an
26+
// all-zero region trailer so the computed ril is 0. Every field go-rpmdb
27+
// verifies before the slice -- the region tag type and count, the offset range,
28+
// the trailer bounds -- is satisfied; only the final slice is unreachable in a
29+
// well-formed header.
30+
func panicHeader() []byte {
31+
be := func(v int32) []byte {
32+
b := make([]byte, 4)
33+
binary.BigEndian.PutUint32(b, uint32(v))
34+
return b
35+
}
36+
var d []byte
37+
d = append(d, be(1)...) // il: one index entry
38+
d = append(d, be(32)...) // dl: data length
39+
// peList[0]: region tag RPMTAG_HEADERIMMUTABLE(63), RPM_BIN_TYPE(7),
40+
// offset 16 (non-zero, so ril is not reset to il), count 16.
41+
d = append(d, be(63)...)
42+
d = append(d, be(7)...)
43+
d = append(d, be(16)...)
44+
d = append(d, be(16)...)
45+
d = append(d, make([]byte, 16)...) // region data segment
46+
d = append(d, make([]byte, 16)...) // region trailer, all zero -> ril == 0
47+
return d
48+
}
49+
50+
// writeSQLiteRPMDB builds a real SQLite rpm database at db within a throwaway
51+
// rootfs, holding blob as its single Packages row. It uses the modernc sqlite
52+
// driver that rpm.go blank-imports, so no new dependency is pulled in.
53+
func writeSQLiteRPMDB(t *testing.T, db string, blob []byte) target.RootFS {
54+
t.Helper()
55+
root := t.TempDir()
56+
path := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(db, "/")))
57+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
58+
t.Fatal(err)
59+
}
60+
conn, err := sql.Open("sqlite", path)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
defer conn.Close()
65+
if _, err := conn.Exec(`CREATE TABLE Packages (hnum INTEGER PRIMARY KEY, blob BLOB)`); err != nil {
66+
t.Fatal(err)
67+
}
68+
if _, err := conn.Exec(`INSERT INTO Packages (blob) VALUES (?)`, blob); err != nil {
69+
t.Fatal(err)
70+
}
71+
return target.NewDirFS(root)
72+
}
73+
74+
// TestRPMSurvivesAPanickingHeader is the regression guard: a header that makes
75+
// go-rpmdb panic must surface as an error from Read, so one malformed database
76+
// cannot take down the whole scan. Before the recover in listPackages this
77+
// call crashed the test binary outright.
78+
func TestRPMSurvivesAPanickingHeader(t *testing.T) {
79+
fsys := writeSQLiteRPMDB(t, "/var/lib/rpm/rpmdb.sqlite", panicHeader())
80+
pkgs, err := (&RPM{}).Read(fsys)
81+
if err == nil {
82+
t.Fatalf("a panicking rpm header parsed as an inventory: %+v", pkgs)
83+
}
84+
if !strings.Contains(err.Error(), "panicked") {
85+
t.Errorf("error does not name the recovered panic: %v", err)
86+
}
87+
}

0 commit comments

Comments
 (0)