psbt: reject unsigned tx with trailing bytes#2541
Conversation
|
Friendly ping — this is ready for review whenever a maintainer has bandwidth. Small fix: reject unsigned PSBT transactions with trailing bytes. Thanks! |
|
Thanks for fixing this! I found more instances of the same issue in PSBT code: https://gist.github.com/starius/0b74bc1f0842947e577ed20642d65f90 Could you integrate them into your PR, please? Ideally if you follow this commit structure for each bugfix: (1) fix commit, (2) regression test commit. The later commit should compile if the former commit is reverted, and the test should fail with a symptomatic error. |
efbaf57 to
cb9ccaa
Compare
|
Thanks @starius — done. I integrated your full series and force-pushed the branch. The PR now covers all the instances from your gist, each as a
I kept the authorship on your commits since they're yours. Each test commit compiles if its preceding fix commit is reverted, and I verified that reverting each fix makes exactly its test fail with a symptomatic Let me know if you'd like any wording or structure adjusted. |
|
Replacing this PR with #2558 |
guggero
left a comment
There was a problem hiding this comment.
One suggestion for an optimization/generalization, otherwise looks good to me. Thanks a lot for the fixes!
| return nil, err | ||
| } | ||
|
|
||
| var trailing [1]byte |
There was a problem hiding this comment.
I think we can introduce a helper function in the first commit and use it everywhere:
// assertFullyConsumed returns ErrInvalidPsbtFormat if the given reader is _not_
// fully exhausted, meaning there are bytes left to consume.
func assertFullyConsumed(r io.Reader) error {
// Short-cut if it's already a bytes.Reader, which has a Len() function.
if lr, ok := r.(interface{Len() int}); ok {
if lr.Len() > 0 {
return ErrInvalidPsbtFormat
}
return nil
}
// We have a generic reader, let's try reading a single byte. If that
// succeeds, it means the reader wasn't fully exhausted/consumed.
var trailing [1]byte
_, err := io.ReadFull(r, trailing[:])
switch {
// Reading was a success, wich means the reader wasn't fully consumed.
case err == nil:
return ErrInvalidPsbtFormat
// We couldn't read, which is what we wanted to assert.
case errors.Is(err, io.EOF):
return nil
// Something unexpected happened.
default:
return err
}
}
|
Folded into #2558 |
Summary
While parsing the global unsigned transaction of a PSBT, btcd reads it from a reader over the value bytes but never verifies that the transaction consumed the entire value. A value whose length prefix (
<valuelen>) declares more bytes than the transaction actually occupies is silently accepted.This diverges from Bitcoin Core, which enforces that the deserialized data exactly matches the stated length in
UnserializeFromVector:This was originally surfaced by differential fuzzing between Bitcoin Core and btcd (#2424): btcd parsed a PSBT that Core rejected.
Fix
After decoding the unsigned transaction, assert that the value reader has no bytes left; otherwise return
ErrInvalidPsbtFormat. The ldflags-free path is unchanged — well-formed PSBTs are unaffected. This also avoids the wasted allocations and trie lookups a malicious peer could trigger with oversized values.Testing
Added
TestNewFromRawBytesTrailingTxBytes, which:NewFromRawBytesnow returnsErrInvalidPsbtFormat.gofmt,go vet ./psbt/...clean; fullpsbtpackage suite passes. Tested on go1.26.4.Closes #2424