Clear the output buffer when a PSBT field read is rejected
What changed, and why it matters
This commit fixes a security hygiene issue in Ledger's Bitcoin app when reading PSBT (Partially Signed Bitcoin Transaction) data fields. Previously, if a field was missing or malformed, the memory buffer meant to hold the field value could be left containing leftover or partially written data controlled by the host/device communicating with the Ledger. The change ensures the buffer is explicitly wiped to zero and its reported length is reset in those failure cases, so callers cannot accidentally use stale or incorrect data. The commit message itself states this avoids leaving 'host-controlled bytes in memory in cases of failure.'
Treat as a security-hardening fix with potential information-disclosure/use-of-stale-data implications. Review whether any callers of these PSBT field helpers previously relied on buffer contents after an ABSENT/ERROR status, and ensure the fix is included in the next release. No immediate emergency response is indicated, but the change should be merged and regression-tested.
Security signals we found
explicit_bzero added to clear output buffer on failure paths
length out-parameter reset to 0 on failure paths
commit message explicitly describes avoiding host-controlled bytes remaining in memory on failure
unit tests added/updated to assert buffer clearing on rejected reads
failure to clear buffers could lead to use of stale or partially attacker-controlled data
Evidence from the diff
In src/handler/sign_psbt/psbt_fields.c, the internal read_var() and read_fixed() helpers now call explicit_bzero() on the output buffer and set *out_len to 0 whenever they return PSBT_FIELD_ABSENT or PSBT_FIELD_ERROR. Previously, read_var() returned early on MAP_VALUE_ABSENT without clearing out, and read_fixed() returned PSBT_FIELD_ERROR on length mismatch without clearing the buffer. The patch also adds unit tests verifying that a rejected short txid and an over-long output script leave the caller buffer zeroed and length reset. This is a defensive fix against use of uninitialized or partially host-controlled memory after failed PSBT field reads.
Changed components
src/handler/sign_psbt/psbt_fields.cunit-tests/test_psbt_fields.cPSBT field reading functions (read_var, read_fixed, and their callers)Inspect captured patch +36 / −8
### src/handler/sign_psbt/psbt_fields.c
@@ -15,6 +15,8 @@
* limitations under the License.
*****************************************************************************/
+#include <string.h>
+
#include "psbt_fields.h"
/* SDK headers */
@@ -42,6 +44,8 @@
/**
* Reads a value of variable length (up to `out_cap` bytes) into `out`, writing its length to
* `*out_len`. A value longer than `out_cap` is reported as PSBT_FIELD_ERROR.
+ *
+ * On any non-PRESENT outcome `out` is left zeroed and `*out_len` is 0.
*/
static psbt_field_status_t read_var(dispatcher_context_t *dc,
const merkleized_map_commitment_t *map,
@@ -50,11 +54,12 @@ static psbt_field_status_t read_var(dispatcher_context_t *dc,
size_t out_cap,
size_t *out_len) {
int res = call_get_merkleized_map_value(dc, map, &key_type, 1, out, out_cap);
- if (res == MAP_VALUE_ABSENT) {
- return PSBT_FIELD_ABSENT;
- }
if (res < 0) {
- return PSBT_FIELD_ERROR;
+ // on absent or error, zero out the output buffer and length,
+ // preventing the caller from possibly using uninitialized data
+ explicit_bzero(out, out_cap);
+ *out_len = 0;
+ return res == MAP_VALUE_ABSENT ? PSBT_FIELD_ABSENT : PSBT_FIELD_ERROR;
}
*out_len = (size_t) res;
return PSBT_FIELD_PRESENT;
@@ -63,6 +68,8 @@ static psbt_field_status_t read_var(dispatcher_context_t *dc,
/**
* Reads a value that must be exactly `len` bytes into `out`. A present value of any other length
* is malformed, hence PSBT_FIELD_ERROR.
+ *
+ * On any non-PRESENT outcome `out` is left zeroed.
*/
static psbt_field_status_t read_fixed(dispatcher_context_t *dc,
const merkleized_map_commitment_t *map,
@@ -74,7 +81,13 @@ static psbt_field_status_t read_fixed(dispatcher_context_t *dc,
if (status != PSBT_FIELD_PRESENT) {
return status;
}
- return read_len == len ? PSBT_FIELD_PRESENT : PSBT_FIELD_ERROR;
+ if (read_len != len) {
+ // on error, zero out the output buffer, preventing the caller from
+ // possibly using uninitialized data
+ explicit_bzero(out, len);
+ return PSBT_FIELD_ERROR;
+ }
+ return PSBT_FIELD_PRESENT;
}
/** Reads a value that must be exactly 4 bytes, decoded as a little-endian unsigned 32-bit int. */
### unit-tests/test_psbt_fields.c
@@ -27,6 +27,13 @@
/* ---------- Helpers ---------- */
+/** Asserts that a rejected read left nothing of the caller's sentinel, nor of the client's data. */
+static void assert_cleared(const uint8_t *buf, size_t len) {
+ for (size_t i = 0; i < len; i++) {
+ assert_int_equal(buf[i], 0);
+ }
+}
+
/**
* Registers a map holding a single (key_type, value) pair and returns its commitment.
*/
@@ -225,7 +232,9 @@ static void test_prevout_txid_absent(void **state) {
}
/**
- * A short txid must be rejected rather than accepted with an uninitialized tail.
+ * A short txid must be rejected rather than accepted with an uninitialized tail. The 31 bytes the
+ * client did send are proved, so read_var reports success and only read_fixed can clear them: they
+ * must not be left in the caller's buffer alongside an uninitialized 32nd byte.
*/
static void test_prevout_txid_short_is_error(void **state) {
mock_dispatcher_t *mock = *state;
@@ -237,10 +246,12 @@ static void test_prevout_txid_short_is_error(void **state) {
map_with_one_field(mock, PSBT_IN_PREVIOUS_TXID, txid, sizeof(txid), &map);
uint8_t got[32];
+ memset(got, 0xEE, sizeof(got));
psbt_field_status_t status =
psbt_get_input_prevout_txid(mock_dispatcher_get_dc(mock), &map, got);
assert_int_equal(status, PSBT_FIELD_ERROR);
+ assert_cleared(got, sizeof(got));
}
static void test_output_amount_present(void **state) {
@@ -371,7 +382,8 @@ static void test_output_script_absent(void **state) {
}
/**
- * A script longer than the caller's buffer is an error, not an absent field.
+ * A script longer than the caller's buffer is an error, not an absent field. Neither the buffer nor
+ * the length may be left holding anything a caller could mistake for a value.
*/
static void test_output_script_too_long_is_error(void **state) {
mock_dispatcher_t *mock = *state;
@@ -383,11 +395,14 @@ static void test_output_script_too_long_is_error(void **state) {
map_with_one_field(mock, PSBT_OUT_SCRIPT, script, sizeof(script), &map);
uint8_t got[16];
- size_t got_len = 0;
+ memset(got, 0xEE, sizeof(got));
+ size_t got_len = 123;
psbt_field_status_t status =
psbt_get_output_script(mock_dispatcher_get_dc(mock), &map, got, sizeof(got), &got_len);
assert_int_equal(status, PSBT_FIELD_ERROR);
+ assert_int_equal(got_len, 0);
+ assert_cleared(got, sizeof(got));
}
static void test_redeem_script_present(void **state) {Why this scored 52/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.