diff --git a/.gitattributes b/.gitattributes index b5d6c834584..e41cdb2e17e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,8 @@ Dockerfile* text eol=lf server/security/ubuntu-backports/bundle/*.json text eol=lf server/security/ubuntu-backports/bundle/installer/*.go text eol=lf server/security/ubuntu-backports/bundle/installer/go.mod text eol=lf +server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile text eol=lf +server/security/ubuntu-backports/bundle/installer/testdata/*.c text eol=lf scripts/** text eol=lf server/bin/** text eol=lf server/service/** text eol=lf diff --git a/server/security/ubuntu-backports/bundle/installer/README.md b/server/security/ubuntu-backports/bundle/installer/README.md index d60344b071c..8c8706537b3 100644 --- a/server/security/ubuntu-backports/bundle/installer/README.md +++ b/server/security/ubuntu-backports/bundle/installer/README.md @@ -18,8 +18,14 @@ libc-gconv-modules-extra; rust-coreutils; jq and libjq1; p11-kit, p11-kit-modules and libp11-kit0; and libxrender1. Archives, complete non-directory payload receipts, source provenance and qualification are SHA256-bound. The installer independently reads each selected archive's control and tar stream, -checks every file/link and ELF identity, and preserves existing SONAME, NEEDED -and exported symbols before installation. The publisher qualifies native owning +checks every file/link and ELF identity, and preserves existing SONAME, library +dependencies and exported symbols before installation. The sole additive +dependency accepted is the amd64 system loader for a verified +`__tls_get_addr@GLIBC_2.3` import: every original dependency must remain, no other +loader import may be added, and the installed loader must provide that exact +symbol version. Unknown dependencies or missing provider symbols fail before +installation. This platform TLS rule does not special-case a product or package. +The publisher qualifies native owning test evidence; consumer installation and product acceptance remain separate. Only packages already installed in the consumer are selected. The package set @@ -33,11 +39,12 @@ host configuration are compared before/after. Only the generated ld.so.cache is excluded from the host-configuration snapshot. Before installation, dpkg checksum differences or missing runtime files fail. -The sole permitted pre-existing difference is a missing selected-package -documentation file proven excluded by the consumer's current dpkg path rules. -Every such path is enumerated in the JSON receipt. Exact one-shot -`--path-include=/full/path` options restore selected documentation without -editing dpkg configuration. Other excluded runtime payload paths fail. After +The sole permitted pre-existing difference is missing selected-package +documentation or a non-executable `usr/share/locale//LC_MESSAGES/*.mo` +message catalog proven excluded by the consumer's current dpkg path rules. +Every such path is enumerated separately in the JSON receipt. Exact one-shot +`--path-include=/full/path` options restore these SHA-bound package data files +without editing dpkg configuration. Other excluded runtime payload paths fail. After installation, every payload file's SHA256/type/mode/uid/gid or literal symlink target, every ELF receipt, package metadata, dpkg audit and full `dpkg --verify` must match. No post-install difference is ignored. @@ -54,6 +61,10 @@ packages and no Rust; the uutils fixture has all twelve. Both consume the same read-only qualified bundle bytes, restore deliberately path-excluded docs, perform actual install/verify, preserve package set and provider, and exercise the installed glibc loader/cache, jq, id, p11-kit, PCRE grep and NSS getent. +A separate test-only C probe uses the official jq release headers and +`dlopen(..., RTLD_NOW)` without linking libjq or libm into the executable. Four +threads each execute 128 runtime sorts using independent jq states, starting at +a barrier. The compiler, headers and probe remain outside product images. `result.json` and the console/install/verify receipts are exported. These are isolated consumer fixtures. GNU baseline construction follows Ubuntu's documented provider transition, including its required `--allow-remove-essential` option; diff --git a/server/security/ubuntu-backports/bundle/installer/abi.go b/server/security/ubuntu-backports/bundle/installer/abi.go new file mode 100644 index 00000000000..1c49f367132 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/abi.go @@ -0,0 +1,234 @@ +package main + +import ( + "archive/tar" + "bytes" + "debug/elf" + "fmt" + "io" + "reflect" + "sort" +) + +const amd64Loader = "ld-linux-x86-64.so.2" +const amd64LoaderPath = "usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2" + +type abiSymbol struct { + Name, Version, Library string + Binding elf.SymBind + Type elf.SymType + Visibility elf.SymVis + Hidden bool +} +type abiELF struct { + Metadata ELF + Imports, Exports []abiSymbol +} + +// No public payload fields change: return bytes only after the complete tar, +// file/link metadata and ELF receipts have matched the SHA-bound payload. +func verifiedArchiveELFs(content []byte, payload Payload) (map[string][]byte, error) { + if err := validateArchive(content, payload); err != nil { + return nil, err + } + byDigest := map[string][]byte{} + reader := tar.NewReader(bytes.NewReader(content)) + for { + h, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeRegA { + continue + } + b, err := io.ReadAll(io.LimitReader(reader, (256<<20)+1)) + if err != nil { + return nil, err + } + if len(b) > 256<<20 { + return nil, fmt.Errorf("oversize verified archive file") + } + if bytes.HasPrefix(b, []byte("\x7fELF")) { + byDigest[digest(b)] = b + } + } + result := map[string][]byte{} + for _, e := range payload.ELF { + b, exists := byDigest[e.SHA256] + if !exists { + return nil, fmt.Errorf("verified ELF bytes are absent: %s", e.Path) + } + result[e.Path] = b // A tar hardlink uses the same already-verified bytes. + } + return result, nil +} + +func inspectABI(name string, content []byte) (abiELF, error) { + metadata, err := inspectELF(name, content) + if err != nil { + return abiELF{}, err + } + f, err := elf.NewFile(bytes.NewReader(content)) + if err != nil { + return abiELF{}, err + } + defer f.Close() + symbols, err := f.DynamicSymbols() + if err != nil && err != elf.ErrNoSymbols { + return abiELF{}, err + } + result := abiELF{Metadata: metadata, Imports: []abiSymbol{}, Exports: []abiSymbol{}} + for _, s := range symbols { + if s.Name == "" || elf.ST_BIND(s.Info) == elf.STB_LOCAL { + continue + } + item := abiSymbol{s.Name, s.Version, s.Library, elf.ST_BIND(s.Info), elf.ST_TYPE(s.Info), elf.ST_VISIBILITY(s.Other), s.VersionIndex.IsHidden()} + if s.Section == elf.SHN_UNDEF { + result.Imports = append(result.Imports, item) + } else { + result.Exports = append(result.Exports, item) + } + } + return result, nil +} + +func difference(a, b []string) []string { + known := map[string]bool{} + for _, value := range b { + known[value] = true + } + out := []string{} + for _, value := range a { + if !known[value] { + out = append(out, value) + } + } + sort.Strings(out) + return out +} +func hasTLSExport(provider abiELF) bool { + if provider.Metadata.ELFClass != 64 || provider.Metadata.Machine != 62 || provider.Metadata.SONAME == nil || *provider.Metadata.SONAME != amd64Loader { + return false + } + for _, symbol := range provider.Exports { + if symbol.Name == "__tls_get_addr" && symbol.Version == "GLIBC_2.3" && symbol.Binding == elf.STB_GLOBAL && symbol.Type == elf.STT_FUNC && symbol.Visibility == elf.STV_DEFAULT && !symbol.Hidden { + return true + } + } + return false +} + +func sonameValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +// The only permitted NEEDED addition is the supported amd64 loader's exact TLS +// resolver. This is an ELF/platform rule and has no application/package names. +func compatibleLibrary(old, new abiELF, providers func() (abiELF, abiELF, error)) error { + name := new.Metadata.Path + if !reflect.DeepEqual(old.Metadata.SONAME, new.Metadata.SONAME) { + return fmt.Errorf("library SONAME changed: %s old=%q new=%q", name, sonameValue(old.Metadata.SONAME), sonameValue(new.Metadata.SONAME)) + } + type exportKey struct { + Name, Version string + Hidden bool + } + available := map[exportKey]bool{} + for _, s := range new.Exports { + available[exportKey{s.Name, s.Version, s.Hidden}] = true + } + for _, s := range old.Exports { + if !available[exportKey{s.Name, s.Version, s.Hidden}] { + return fmt.Errorf("library dropped exported symbol %s@%s: %s", s.Name, s.Version, name) + } + } + removed := difference(old.Metadata.Needed, new.Metadata.Needed) + added := difference(new.Metadata.Needed, old.Metadata.Needed) + if len(removed) > 0 { + return fmt.Errorf("library NEEDED removed: %s old=%v new=%v removed=%v", name, old.Metadata.Needed, new.Metadata.Needed, removed) + } + if len(added) == 0 { + return nil + } + if old.Metadata.ELFClass != 64 || old.Metadata.Machine != 62 || new.Metadata.ELFClass != 64 || new.Metadata.Machine != 62 || len(added) != 1 || added[0] != amd64Loader { + return fmt.Errorf("library NEEDED addition is unsupported: %s old=%v new=%v added=%v", name, old.Metadata.Needed, new.Metadata.Needed, added) + } + original := map[abiSymbol]bool{} + for _, s := range old.Imports { + original[s] = true + } + delta := []abiSymbol{} + for _, s := range new.Imports { + if !original[s] { + delta = append(delta, s) + } + } + expected := abiSymbol{Name: "__tls_get_addr", Version: "GLIBC_2.3", Library: amd64Loader, Binding: elf.STB_GLOBAL, Type: elf.STT_FUNC, Visibility: elf.STV_DEFAULT} + if len(delta) != 1 || delta[0] != expected { + return fmt.Errorf("library TLS import delta is unsupported: %s newImports=%v", name, delta) + } + if providers == nil { + return fmt.Errorf("qualified and installed amd64 loader evidence is missing: %s", name) + } + installed, qualified, err := providers() + if err != nil { + return err + } + if !hasTLSExport(installed) || !hasTLSExport(qualified) { + return fmt.Errorf("installed/qualified loader lacks public __tls_get_addr@GLIBC_2.3: %s", name) + } + return nil +} + +func checkABI(root string, p Payload, qualified map[string][]byte) error { + providers := func() (abiELF, abiELF, error) { + b, exists := qualified[amd64LoaderPath] + if !exists { + return abiELF{}, abiELF{}, fmt.Errorf("qualified amd64 loader bytes are absent") + } + next, err := inspectABI(amd64LoaderPath, b) + if err != nil { + return abiELF{}, abiELF{}, err + } + _, b, err = installedFile(root, File{Path: amd64LoaderPath}) + if err != nil { + return abiELF{}, abiELF{}, err + } + current, err := inspectABI(amd64LoaderPath, b) + return current, next, err + } + for _, candidate := range p.ELF { + if candidate.SONAME == nil { + continue + } + b, exists := qualified[candidate.Path] + if !exists || digest(b) != candidate.SHA256 { + return fmt.Errorf("qualified ABI ELF bytes mismatch: %s", candidate.Path) + } + next, err := inspectABI(candidate.Path, b) + if err != nil { + return err + } + if !reflect.DeepEqual(next.Metadata, candidate) { + return fmt.Errorf("qualified ABI metadata mismatch: %s", candidate.Path) + } + _, b, err = installedFile(root, File{Path: candidate.Path}) + if err != nil { + return err + } + current, err := inspectABI(candidate.Path, b) + if err != nil { + return err + } + if err = compatibleLibrary(current, next, providers); err != nil { + return err + } + } + return nil +} diff --git a/server/security/ubuntu-backports/bundle/installer/abi_test.go b/server/security/ubuntu-backports/bundle/installer/abi_test.go new file mode 100644 index 00000000000..6bb11eeaa92 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/abi_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "archive/tar" + "bytes" + "debug/elf" + "os" + "strings" + "testing" +) + +func tlsABIControl() (abiELF, abiELF, abiELF, abiELF) { + soname := "libcomponent.so.1" + base := abiELF{Metadata: ELF{Path: "usr/lib/component.so.1", ELFClass: 64, Machine: 62, SONAME: &soname, Needed: []string{"libc.so.6", "libm.so.6"}}, + Imports: []abiSymbol{{Name: "malloc", Version: "GLIBC_2.2.5", Library: "libc.so.6", Binding: elf.STB_GLOBAL, Type: elf.STT_FUNC}}, + Exports: []abiSymbol{{Name: "component_api", Version: "COMPONENT_1", Binding: elf.STB_GLOBAL, Type: elf.STT_FUNC}}} + next := base + next.Metadata.Needed = []string{"libc.so.6", "libm.so.6", amd64Loader} + next.Imports = append(append([]abiSymbol{}, base.Imports...), abiSymbol{Name: "__tls_get_addr", Version: "GLIBC_2.3", Library: amd64Loader, Binding: elf.STB_GLOBAL, Type: elf.STT_FUNC}) + loaderName := amd64Loader + provider := abiELF{Metadata: ELF{Path: amd64LoaderPath, ELFClass: 64, Machine: 62, SONAME: &loaderName}, Exports: []abiSymbol{{Name: "__tls_get_addr", Version: "GLIBC_2.3", Binding: elf.STB_GLOBAL, Type: elf.STT_FUNC}}} + return base, next, provider, provider +} +func TestExactPlatformTLSAddition(t *testing.T) { + old, next, installed, qualified := tlsABIControl() + calls := 0 + must(t, compatibleLibrary(old, next, func() (abiELF, abiELF, error) { calls++; return installed, qualified, nil })) + if calls != 1 { + t.Fatal("loader evidence was not checked") + } + must(t, compatibleLibrary(old, old, nil)) +} +func TestPlatformTLSRejectsOtherABIDrift(t *testing.T) { + for _, kind := range []string{"unknown-needed", "drop-needed", "no-tls", "wrong-tls-version", "wrong-tls-library", "weak-tls", "extra-weak-loader-import", "missing-providers", "wrong-installed-version", "wrong-qualified-version", "hidden-provider", "wrong-loader-soname", "changed-soname", "drop-export", "changed-export-version", "hidden-export-version", "wrong-machine"} { + t.Run(kind, func(t *testing.T) { + old, next, current, qualified := tlsABIControl() + next.Exports = append([]abiSymbol{}, old.Exports...) + current.Exports = append([]abiSymbol{}, current.Exports...) + qualified.Exports = append([]abiSymbol{}, qualified.Exports...) + provider := func() (abiELF, abiELF, error) { return current, qualified, nil } + switch kind { + case "unknown-needed": + next.Metadata.Needed = append(next.Metadata.Needed, "libunknown.so.9") + case "drop-needed": + next.Metadata.Needed = []string{"libc.so.6", amd64Loader} + case "no-tls": + next.Imports = next.Imports[:1] + case "wrong-tls-version": + next.Imports[1].Version = "GLIBC_2.4" + case "wrong-tls-library": + next.Imports[1].Library = "libc.so.6" + case "weak-tls": + next.Imports[1].Binding = elf.STB_WEAK + case "extra-weak-loader-import": + next.Imports = append(next.Imports, abiSymbol{Name: "other_loader_function", Version: "GLIBC_2.3", Library: amd64Loader, Binding: elf.STB_WEAK, Type: elf.STT_FUNC}) + case "missing-providers": + provider = nil + case "wrong-installed-version": + current.Exports[0].Version = "GLIBC_2.4" + case "wrong-qualified-version": + qualified.Exports[0].Version = "GLIBC_2.4" + case "hidden-provider": + qualified.Exports[0].Visibility = elf.STV_HIDDEN + case "wrong-loader-soname": + name := "ld-other.so.1" + qualified.Metadata.SONAME = &name + case "changed-soname": + name := "libcomponent.so.2" + next.Metadata.SONAME = &name + case "drop-export": + next.Exports = nil + case "changed-export-version": + next.Exports[0].Version = "COMPONENT_2" + case "hidden-export-version": + next.Exports[0].Hidden = true + case "wrong-machine": + next.Metadata.Machine = 183 + } + if err := compatibleLibrary(old, next, provider); err == nil { + t.Fatalf("accepted %s", kind) + } + }) + } +} +func TestInspectABIUsesRealVersionedLoaderExports(t *testing.T) { + b, err := os.ReadFile("/" + amd64LoaderPath) + must(t, err) + provider, err := inspectABI(amd64LoaderPath, b) + must(t, err) + if !hasTLSExport(provider) { + t.Fatal("actual amd64 loader versioned export was not parsed") + } + for _, s := range provider.Exports { + if s.Name == "__tls_get_addr" && s.Version == "GLIBC_2.3" { + return + } + } + t.Fatal("missing versioned TLS definition") +} +func TestVerifiedArchiveELFBytesAndHardlinks(t *testing.T) { + b, err := os.ReadFile("/" + amd64LoaderPath) + must(t, err) + var archive bytes.Buffer + tw := tar.NewWriter(&archive) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/loader", Typeflag: tar.TypeReg, Size: int64(len(b)), Mode: 0755})) + _, err = tw.Write(b) + must(t, err) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/alias", Typeflag: tar.TypeLink, Linkname: "./usr/lib/loader", Mode: 0755})) + must(t, tw.Close()) + files, elfs, err := inspectTar(archive.Bytes()) + must(t, err) + payload := Payload{Files: files, ELF: elfs} + images, err := verifiedArchiveELFs(archive.Bytes(), payload) + must(t, err) + if len(images) != 2 || !bytes.Equal(images["usr/lib/alias"], b) || !bytes.Equal(images["usr/lib/loader"], b) { + t.Fatal("verified ELF/hardlink data was not carried through") + } + payload.ELF[0].SHA256 = digest([]byte("unqualified")) + if images, err := verifiedArchiveELFs(archive.Bytes(), payload); err == nil || images != nil { + t.Fatal("unqualified bytes were returned") + } +} +func TestCheckABIRequiresVerifiedBytes(t *testing.T) { + b, err := os.ReadFile("/" + amd64LoaderPath) + must(t, err) + e, err := inspectELF(amd64LoaderPath, b) + must(t, err) + if err := checkABI("/", Payload{ELF: []ELF{e}}, map[string][]byte{}); err == nil || !strings.Contains(err.Error(), "qualified ABI ELF bytes mismatch") { + t.Fatalf("missing byte evidence accepted: %v", err) + } + must(t, checkABI("/", Payload{ELF: []ELF{e}}, map[string][]byte{amd64LoaderPath: b})) +} diff --git a/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile b/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile index 5275adb14f5..3e46135b585 100644 --- a/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile +++ b/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile @@ -1,5 +1,19 @@ # syntax=docker/dockerfile:1.7 # Disposable prepublication consumer acceptance; never used as a product image. +FROM golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5 AS embedding_probe +# The public API headers come from the same immutable official release as the +# qualified package. The probe dynamically loads libjq in the Ubuntu consumer; +# neither libjq nor libm is linked into the executable at build time. +ADD --checksum=sha256:71b8d6e8f5fe81f6c6d0d110e3892251f6ce76ed095abd315e26e6e1193af3af https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-1.8.2.tar.gz /tmp/jq.tar.gz +COPY testdata/consumer-libjq-probe.c /src/consumer-libjq-probe.c +RUN set -eux; \ + mkdir /tmp/headers; \ + tar --extract --gzip --file /tmp/jq.tar.gz --directory /tmp/headers --strip-components=2 jq-1.8.2/src/jq.h jq-1.8.2/src/jv.h; \ + cc -std=gnu11 -Wall -Wextra -Werror -O2 -pthread -isystem /tmp/headers \ + /src/consumer-libjq-probe.c -ldl -o /consumer-libjq-probe; \ + readelf --dynamic /consumer-libjq-probe >/tmp/probe-dynamic.txt; \ + if grep -E 'NEEDED.*\[(libjq|libm)\.' /tmp/probe-dynamic.txt; then exit 1; fi + FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b AS consumer ARG PROVIDER=gnu ENV DEBIAN_FRONTEND=noninteractive @@ -57,6 +71,7 @@ apt-get install --no-install-recommends --reinstall -y "$@" rm -rf /var/lib/apt/lists/* /tmp/ca-bootstrap /tmp/ca-certificates.deb SH COPY consumer-smoke.sh /consumer-smoke.sh +COPY --from=embedding_probe /consumer-libjq-probe /consumer-libjq-probe RUN --mount=from=qualified,source=/out,target=/opt/pasturestack/ubuntu-security-backports,ro \ /bin/bash /consumer-smoke.sh "$PROVIDER" diff --git a/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh b/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh index a960fbcb431..8d927d17ccf 100644 --- a/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh +++ b/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh @@ -55,6 +55,8 @@ sha256sum /etc/ld.so.cache >"$evidence/ld-so-cache-before.sha256" "$data/install-existing-backports" --verify-installed >"$evidence/verify.json" cat "$evidence/install.json" "$evidence/verify.json" jq -e --argjson expected "$expected" '.status == "installed-and-verified" and .providerPreserved and .configurationPreserved and (.packages | length) == $expected and (.beforeMissingDocumentation | length) > 0 and (.exactPathIncludes | length) > 0' "$evidence/install.json" >/dev/null +jq -e '.beforeMissingTranslations | type == "array"' "$evidence/install.json" >/dev/null +jq -e '.exactPathIncludes | any(startswith("/usr/share/locale/") and endswith(".mo"))' "$evidence/install.json" >/dev/null jq -e '.status == "verified"' "$evidence/verify.json" >/dev/null cmp <(jq -c '.packages|sort' "$evidence/install.json") <(jq -c '.packages|sort' "$evidence/verify.json") if [[ "$provider" == gnu ]]; then @@ -69,7 +71,7 @@ provider_state >"$evidence/provider-after.tsv" cmp "$evidence/packages-before.tsv" "$evidence/packages-after.tsv" cmp "$evidence/provider-before.tsv" "$evidence/provider-after.tsv" while IFS= read -r path; do - case "$path" in /usr/share/doc/*|/usr/share/man/*|/usr/share/info/*) ;; *) exit 1 ;; esac + case "$path" in /usr/share/doc/*|/usr/share/man/*|/usr/share/info/*|/usr/share/locale/*/LC_MESSAGES/*.mo) ;; *) exit 1 ;; esac test -e "$path" || test -L "$path" done < <(jq -r '.exactPathIncludes[]' "$evidence/install.json") test -z "$(dpkg --audit)" @@ -87,6 +89,7 @@ getconf GNU_LIBC_VERSION | tee "$evidence/glibc-version.txt" | grep -Fx 'glibc 2 ldd /usr/lib/x86_64-linux-gnu/libXrender.so.1 >"$evidence/libxrender-loader.txt" if grep -F 'not found' "$evidence/libxrender-loader.txt"; then exit 1; fi printf '%s\n' '{"numbers":[1,2,3]}' | jq -e '.numbers | add == 6' +timeout 30 /consumer-libjq-probe | tee "$evidence/libjq-embedding.txt" test "$(id -u)" = 0 test "$(id -g)" = 0 id -G >"$evidence/id-groups.txt" @@ -96,5 +99,5 @@ getent passwd root | tee "$evidence/getent-root.txt" | grep -E '^root:[^:]*:0:0: test "$input_manifest_sha" = "$(sha256sum "$data/manifest.json" | awk '{print $1}')" jq -n --arg provider "$provider" --arg manifest "$input_manifest_sha" \ --slurpfile receipt "$evidence/install.json" \ - '{schemaVersion:1,provider:$provider,status:"PASS",inputManifestSha256:$manifest,selectedPackages:$receipt[0].packages,checks:["batch-install","verify-installed","package-set","provider","docs-restored","glibc-loader","jq","id","p11-kit","grep","getent"]}' >"$evidence/result.json" + '{schemaVersion:1,provider:$provider,status:"PASS",inputManifestSha256:$manifest,selectedPackages:$receipt[0].packages,checks:["batch-install","verify-installed","package-set","provider","docs-restored","glibc-loader","jq","libjq-RTLD_NOW-TLS-threads","id","p11-kit","grep","getent"]}' >"$evidence/result.json" cat "$evidence/result.json" diff --git a/server/security/ubuntu-backports/bundle/installer/installer.go b/server/security/ubuntu-backports/bundle/installer/installer.go index 9ac34f8d466..5c48e9bef00 100644 --- a/server/security/ubuntu-backports/bundle/installer/installer.go +++ b/server/security/ubuntu-backports/bundle/installer/installer.go @@ -24,6 +24,7 @@ type Receipt struct { Status string `json:"status"` Packages []string `json:"packages"` BeforeMissingDocumentation []string `json:"beforeMissingDocumentation"` + BeforeMissingTranslations []string `json:"beforeMissingTranslations"` ExactPathIncludes []string `json:"exactPathIncludes"` ProviderPreserved bool `json:"providerPreserved,omitempty"` ConfigurationPreserved bool `json:"configurationPreserved,omitempty"` @@ -107,7 +108,7 @@ func parseControl(b []byte) (map[string]string, error) { } return result, nil } -func (i Installer) archive(p Package, payload Payload) error { +func (i Installer) archive(p Package, payload Payload, qualified map[string][]byte) error { archive := filepath.Join(i.bundle, filepath.FromSlash(p.Archive.Path)) b, err := i.run.Run("/usr/bin/dpkg-deb", "--field", archive) if err != nil { @@ -128,7 +129,17 @@ func (i Installer) archive(p Package, payload Payload) error { if err != nil { return err } - return validateArchive(b, payload) + images, err := verifiedArchiveELFs(b, payload) + if err != nil { + return err + } + for name, content := range images { + if previous, exists := qualified[name]; exists && digest(previous) != digest(content) { + return fmt.Errorf("conflicting qualified ELF path: %s", name) + } + qualified[name] = content + } + return nil } var exactDependency = regexp.MustCompile(`(?:^|[,|])\s*([a-z0-9][a-z0-9+.-]*)(?::[a-z0-9-]+)?\s*\(=\s*([^()]+)\)`) @@ -223,12 +234,32 @@ func documentation(name, pkg string) bool { return name == "usr/share/doc/"+pkg || strings.HasPrefix(name, "usr/share/doc/"+pkg+"/") || strings.HasPrefix(name, "usr/share/man/") || strings.HasPrefix(name, "usr/share/info/") } +var translationPath = regexp.MustCompile(`^usr/share/locale/[A-Za-z][A-Za-z0-9_.@-]*/LC_MESSAGES/[^/]+\.mo$`) + +// Minimal Ubuntu images exclude message catalogs as well as documentation. +// Restore only exact SHA-bound, non-executable catalog files from a selected +// package. Program/library exclusions remain errors; no dpkg rule is changed. +func restorablePackageData(file File, pkg string, elfs []ELF) bool { + if documentation(file.Path, pkg) { + return true + } + if file.Type != "file" || file.Mode&0111 != 0 || !translationPath.MatchString(file.Path) { + return false + } + for _, image := range elfs { + if image.Path == file.Path { + return false + } + } + return true +} + var missingLine = regexp.MustCompile(`^missing\s+(/\S+)$`) func beforeVerify(output []byte, p Package, payload Payload, rules []pathRule) ([]string, error) { allowed := map[string]bool{} for _, f := range payload.Files { - if documentation(f.Path, p.Name) && excluded(f.Path, rules) { + if restorablePackageData(f, p.Name, payload.ELF) && excluded(f.Path, rules) { allowed["/"+f.Path] = true } } @@ -360,7 +391,7 @@ func unchangedInventory(before, after map[string]Installed, selected []Package) return nil } func (i Installer) Execute(install bool) (Receipt, error) { - r := Receipt{SchemaVersion: 1, BundleVersion: "0.1.0", Packages: []string{}, BeforeMissingDocumentation: []string{}, ExactPathIncludes: []string{}} + r := Receipt{SchemaVersion: 1, BundleVersion: "0.1.0", Packages: []string{}, BeforeMissingDocumentation: []string{}, BeforeMissingTranslations: []string{}, ExactPathIncludes: []string{}} if err := i.platform(); err != nil { return r, err } @@ -376,6 +407,7 @@ func (i Installer) Execute(install bool) (Receipt, error) { return r, err } selected := []Package{} + qualified := map[string][]byte{} for _, p := range m.Packages { old, exists := installed[p.Name] if !exists { @@ -390,7 +422,7 @@ func (i Installer) Execute(install bool) (Receipt, error) { if _, err = i.run.Run("/usr/bin/dpkg", "--compare-versions", old.Version, "le", p.Version); err != nil { return r, fmt.Errorf("refuse downgrade: %s", p.Name) } - if err = i.archive(p, payloads[p.Name]); err != nil { + if err = i.archive(p, payloads[p.Name], qualified); err != nil { return r, err } selected = append(selected, p) @@ -429,20 +461,27 @@ func (i Installer) Execute(install bool) (Receipt, error) { if err != nil { return r, err } - r.BeforeMissingDocumentation = append(r.BeforeMissingDocumentation, missing...) + for _, name := range missing { + if documentation(strings.TrimPrefix(name, "/"), p.Name) { + r.BeforeMissingDocumentation = append(r.BeforeMissingDocumentation, name) + } else { + r.BeforeMissingTranslations = append(r.BeforeMissingTranslations, name) + } + } for _, f := range payloads[p.Name].Files { if excluded(f.Path, rules) { - if !documentation(f.Path, p.Name) { + if !restorablePackageData(f, p.Name, payloads[p.Name].ELF) { return r, fmt.Errorf("dpkg excludes a runtime payload path: %s", f.Path) } includes = append(includes, "/"+f.Path) } } - if err = checkABI(i.root, payloads[p.Name]); err != nil { + if err = checkABI(i.root, payloads[p.Name], qualified); err != nil { return r, err } } r.BeforeMissingDocumentation = unique(r.BeforeMissingDocumentation) + r.BeforeMissingTranslations = unique(r.BeforeMissingTranslations) r.ExactPathIncludes = unique(includes) before, err := i.snapshot(installed) if err != nil { diff --git a/server/security/ubuntu-backports/bundle/installer/installer_test.go b/server/security/ubuntu-backports/bundle/installer/installer_test.go index f40c6dd7b38..56ce3b1f5ad 100644 --- a/server/security/ubuntu-backports/bundle/installer/installer_test.go +++ b/server/security/ubuntu-backports/bundle/installer/installer_test.go @@ -411,10 +411,11 @@ func TestNativeELFABIRejectsDroppedSymbols(t *testing.T) { if e.SONAME == nil || len(e.DefinedSymbols) == 0 { t.Fatal("native amd64 libc fixture lacks SONAME/exports") } - p := Payload{ELF: []ELF{e}} - must(t, checkABI("/", p)) - p.ELF[0].DefinedSymbols = []string{} - if err := checkABI("/", p); err == nil || !strings.Contains(err.Error(), "dropped exported symbol") { + original, err := inspectABI(library, b) + must(t, err) + changed := original + changed.Exports = []abiSymbol{} + if err := compatibleLibrary(original, changed, nil); err == nil || !strings.Contains(err.Error(), "dropped exported symbol") { t.Fatalf("ABI loss was not rejected: %v", err) } } diff --git a/server/security/ubuntu-backports/bundle/installer/optional_data_test.go b/server/security/ubuntu-backports/bundle/installer/optional_data_test.go new file mode 100644 index 00000000000..b9e9e16bd1d --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/optional_data_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestExcludedTranslationIsRestoredOnlyWithPayloadAndRule(t *testing.T) { + const name = "usr/share/locale/ar/LC_MESSAGES/p11-kit.mo" + p := Package{Name: "p11-kit"} + payload := Payload{Files: []File{{Path: name, Type: "file", Mode: 0644}}} + rules := []pathRule{{pattern: "/usr/share/locale/*"}} + missing := []byte("missing /" + name + "\n") + got, err := beforeVerify(missing, p, payload, rules) + must(t, err) + if !reflect.DeepEqual(got, []string{"/" + name}) { + t.Fatal("excluded message catalog was not explicitly recorded") + } + if _, err := beforeVerify(missing, p, payload, nil); err == nil { + t.Fatal("missing translation without an exclusion was accepted") + } + if _, err := beforeVerify(missing, p, Payload{}, rules); err == nil { + t.Fatal("missing translation outside the selected payload was accepted") + } + if _, err := beforeVerify([]byte("??5?????? /"+name+"\n"), p, payload, rules); err == nil { + t.Fatal("modified translation content was accepted") + } + payload.ELF = []ELF{{Path: name}} + if _, err := beforeVerify(missing, p, payload, rules); err == nil { + t.Fatal("non-executable ELF disguised as a message catalog was accepted") + } +} + +func TestMessageCatalogRuleDoesNotPermitRuntimeExclusions(t *testing.T) { + for _, file := range []File{ + {Path: "usr/share/locale/ar/LC_MESSAGES/p11-kit.mo", Type: "file", Mode: 0755}, + {Path: "usr/share/locale/ar/LC_MESSAGES/p11-kit.mo", Type: "symlink", Mode: 0777}, + {Path: "usr/share/locale/ar/LC_MESSAGES/p11-kit.so", Type: "file", Mode: 0644}, + {Path: "usr/share/locale/ar/p11-kit.mo", Type: "file", Mode: 0644}, + {Path: "usr/share/locale/ar/LC_MESSAGES/nested/p11-kit.mo", Type: "file", Mode: 0644}, + {Path: "usr/lib/x86_64-linux-gnu/libp11-kit.so.0", Type: "file", Mode: 0644}, + {Path: "usr/bin/p11-kit", Type: "file", Mode: 0755}, + } { + if restorablePackageData(file, "p11-kit", nil) { + t.Errorf("runtime or noncatalog path was accepted: %#v", file) + } + } +} diff --git a/server/security/ubuntu-backports/bundle/installer/payload.go b/server/security/ubuntu-backports/bundle/installer/payload.go index dcb17b7d0d6..29e009dc24a 100644 --- a/server/security/ubuntu-backports/bundle/installer/payload.go +++ b/server/security/ubuntu-backports/bundle/installer/payload.go @@ -253,31 +253,3 @@ func verifyPayload(root string, p Payload) error { } return nil } -func checkABI(root string, p Payload) error { - for _, candidate := range p.ELF { - if candidate.SONAME == nil { - continue - } - _, b, err := installedFile(root, File{Path: candidate.Path}) - if err != nil { - return err - } - old, err := inspectELF(candidate.Path, b) - if err != nil { - return err - } - if !reflect.DeepEqual(old.SONAME, candidate.SONAME) || !reflect.DeepEqual(old.Needed, candidate.Needed) { - return fmt.Errorf("library SONAME/NEEDED changed: %s", candidate.Path) - } - available := map[string]bool{} - for _, symbol := range candidate.DefinedSymbols { - available[symbol] = true - } - for _, symbol := range old.DefinedSymbols { - if !available[symbol] { - return fmt.Errorf("library dropped exported symbol %s: %s", symbol, candidate.Path) - } - } - } - return nil -} diff --git a/server/security/ubuntu-backports/bundle/installer/testdata/consumer-libjq-probe.c b/server/security/ubuntu-backports/bundle/installer/testdata/consumer-libjq-probe.c new file mode 100644 index 00000000000..060a2ad22a4 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/testdata/consumer-libjq-probe.c @@ -0,0 +1,109 @@ +/* Test-only embedding control. No compiler or probe enters a product image. */ +#include +#include +#include +#include +#include "jq.h" + +struct jq_api { + jq_state *(*init)(void); + void (*teardown)(jq_state **); + int (*compile)(jq_state *, const char *); + void (*start)(jq_state *, jv, int); + jv (*next)(jq_state *); + jv (*parse)(const char *); + jv_kind (*kind)(jv); + jv (*copy_value)(jv); + int (*invalid_has_message)(jv); + void (*free_value)(jv); +}; + +enum { THREADS = 4, ITERATIONS = 128 }; +struct worker { + const struct jq_api *api; + pthread_barrier_t *barrier; + int failed; +}; + +static void *evaluate(void *argument) { + struct worker *worker = argument; + const struct jq_api *api = worker->api; + jq_state *state = api->init(); + if (!state || !api->compile(state, "sort_by(.k) | map(.k) == [1,2,3]")) { + worker->failed = 1; + } + /* All independent jq states reach the runtime sort together, without sleeps. */ + int barrier = pthread_barrier_wait(worker->barrier); + if (barrier != 0 && barrier != PTHREAD_BARRIER_SERIAL_THREAD) { + worker->failed = 1; + } + for (int iteration = 0; !worker->failed && iteration < ITERATIONS; ++iteration) { + jv input = api->parse("[{\"k\":3},{\"k\":1},{\"k\":2}]"); + api->start(state, input, 0); + jv result = api->next(state); + if (api->kind(result) != JV_KIND_TRUE) { + worker->failed = 1; + } + api->free_value(result); + jv end = api->next(state); + if (api->kind(end) != JV_KIND_INVALID || api->invalid_has_message(api->copy_value(end))) { + worker->failed = 1; + } + api->free_value(end); + } + if (state) api->teardown(&state); + return NULL; +} + +int main(void) { + /* Resolve every relocation before starting threads. Linking the executable + against libjq/libm would mask a missing shared-library dependency. */ + void *library = dlopen("libjq.so.1", RTLD_NOW | RTLD_LOCAL); + if (!library) { + fprintf(stderr, "libjq RTLD_NOW failed: %s\n", dlerror()); + return EXIT_FAILURE; + } + struct jq_api api; +#define RESOLVE(field, name) do { \ + dlerror(); \ + api.field = (__typeof__(api.field))dlsym(library, name); \ + const char *error = dlerror(); \ + if (error || !api.field) { \ + fprintf(stderr, "libjq symbol %s failed: %s\n", name, error ? error : "null"); \ + return EXIT_FAILURE; \ + } \ +} while (0) + RESOLVE(init, "jq_init"); + RESOLVE(teardown, "jq_teardown"); + RESOLVE(compile, "jq_compile"); + RESOLVE(start, "jq_start"); + RESOLVE(next, "jq_next"); + RESOLVE(parse, "jv_parse"); + RESOLVE(kind, "jv_get_kind"); + RESOLVE(copy_value, "jv_copy"); + RESOLVE(invalid_has_message, "jv_invalid_has_msg"); + RESOLVE(free_value, "jv_free"); +#undef RESOLVE + pthread_barrier_t barrier; + if (pthread_barrier_init(&barrier, NULL, THREADS) != 0) return EXIT_FAILURE; + pthread_t threads[THREADS]; + struct worker workers[THREADS]; + for (int index = 0; index < THREADS; ++index) { + workers[index] = (struct worker){ &api, &barrier, 0 }; + if (pthread_create(&threads[index], NULL, evaluate, &workers[index]) != 0) { + return EXIT_FAILURE; + } + } + int failed = 0; + for (int index = 0; index < THREADS; ++index) { + if (pthread_join(threads[index], NULL) != 0 || workers[index].failed) failed = 1; + } + if (pthread_barrier_destroy(&barrier) != 0 || dlclose(library) != 0) failed = 1; + if (failed) { + fputs("libjq embedding/TLS control failed\n", stderr); + return EXIT_FAILURE; + } + printf("PASS libjq RTLD_NOW, %d independent threads, %d runtime sorts per thread\n", + THREADS, ITERATIONS); + return EXIT_SUCCESS; +} diff --git a/server/security/ubuntu-backports/bundle/qualify-native-packages.py b/server/security/ubuntu-backports/bundle/qualify-native-packages.py index 48babfae9cf..47b83848485 100644 --- a/server/security/ubuntu-backports/bundle/qualify-native-packages.py +++ b/server/security/ubuntu-backports/bundle/qualify-native-packages.py @@ -752,7 +752,7 @@ def record_consumer_tests(): manifest_sha = file_sha(output / 'manifest.json') records = [] checks = ['batch-install', 'verify-installed', 'package-set', 'provider', 'docs-restored', - 'glibc-loader', 'jq', 'id', 'p11-kit', 'grep', 'getent'] + 'glibc-loader', 'jq', 'libjq-RTLD_NOW-TLS-threads', 'id', 'p11-kit', 'grep', 'getent'] for provider in ['gnu', 'uutils']: source = WORK / ('consumer-' + provider) result = json.loads((source / 'result.json').read_text()) diff --git a/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py b/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py index f09e552869b..efce04e9b1c 100644 --- a/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py +++ b/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py @@ -333,7 +333,8 @@ def consumer_fixture(self, work, wrong=None): if provider == 'uutils' or n != 'rust-coreutils') result = {'schemaVersion': 1, 'provider': provider, 'status': 'PASS', 'inputManifestSha256': digest, 'selectedPackages': selected, 'checks': ['batch-install', 'verify-installed', 'package-set', - 'provider', 'docs-restored', 'glibc-loader', 'jq', 'id', 'p11-kit', 'grep', 'getent']} + 'provider', 'docs-restored', 'glibc-loader', 'jq', 'libjq-RTLD_NOW-TLS-threads', + 'id', 'p11-kit', 'grep', 'getent']} if wrong and provider == 'gnu': wrong(result) source = work / ('consumer-' + provider); source.mkdir() Q.write_json(source / 'result.json', result)