Merge pull request #697 from Foundation-Devices/SFT-8171-bip39-prefix-bounds
What changed, and why it matters
This commit fixes two related bugs in the BIP39 word lookup feature used when entering seed words on the Passport hardware wallet. First, the code could write past the end of its result buffer when many words matched a short prefix, because it did not reserve space for the comma separator and string terminator. Second, a negative 'max matches' value from the Python side would be treated as a huge positive number, potentially causing an out-of-bounds write. The patch adds proper bounds checks, rejects negative counts, and adds unit tests to verify safe behavior.
Treat this as a security fix and include it in the next firmware release. Verify the new unit tests pass on device, and consider fuzzing `get_words_matching_prefix()` with random prefixes, buffer sizes, and match limits to ensure no further off-by-one issues remain.
Security signals we found
Out-of-bounds write in C string buffer during prefix matching
Integer signedness issue: negative max_matches wraps to large unsigned value
Missing terminator/separator accounting in length check
Unit tests added to verify bounds invariants and negative input rejection
Evidence from the diff
In get_words_matching_prefix() in extmod/foundation/bip39_utils.c, the loop appended matched words to matches without accounting for the trailing comma and final null terminator before writing. The old check total_written + len > matches_len - 1 only ensured room for the word and terminator, not the separator, and word_info_to_string() was called before the bounds test. The patch computes len = strlen(candidate_keypad_digits), checks total_written + len + 1 > matches_len before writing, increments total_written by len + 1, and stops when num_matches >= max_matches. It also rejects matches_len == 0. In modfoundation-bip39.h, the Python binding now rejects negative max_matches to avoid unsigned wrap-around in the C function. New unit tests exercise buffer limits, truncation, zero/negative counts, and both BIP39 and Bytewords lists.
Changed components
extmod/foundation/bip39_utils.cextmod/foundation/modfoundation-bip39.hBIP39 seed word entry / predictive text featureBytewords prefix matching (shared implementation)Inspect captured patch +115 / −11
### extmod/foundation/bip39_utils.c
@@ -98,26 +98,32 @@ void get_words_matching_prefix(char* prefix,
uint32_t num_matches = 0;
uint32_t total_written = 0;
- for (uint32_t i = 0; i < num_words; i++) {
+ if (matches_len == 0) {
+ // Not even room for the terminator
+ return;
+ }
+
+ // Don't do more work than requested
+ for (uint32_t i = 0; i < num_words && num_matches < max_matches; i++) {
snprintf(candidate_keypad_digits, MAX_WORD_LEN + 1, "%"PRIu32, word_info[i].keypad_digits);
if (starts_with(candidate_keypad_digits, prefix)) {
- // This is a match, so convert the offsets to a real string and append to the buffer
- uint32_t len = word_info_to_string(candidate_keypad_digits, word_info[i].offsets, pnext_match);
- if (total_written + len > matches_len - 1) {
+ uint32_t len = strlen(candidate_keypad_digits);
+
+ // Room for the word plus the separator that follows it, which the terminator
+ // later overwrites, so this accounts for the terminator too
+ if (total_written + len + 1 > matches_len) {
// Don't write this one, as there is not enough room
break;
}
- total_written += len;
+
+ // This is a match, so convert the offsets to a real string and append to the buffer
+ word_info_to_string(candidate_keypad_digits, word_info[i].offsets, pnext_match);
+ total_written += len + 1;
pnext_match += len;
*pnext_match = ',';
pnext_match++;
num_matches++;
-
- // Don't do more work than requested
- if (num_matches == max_matches) {
- break;
- }
}
}
### extmod/foundation/modfoundation-bip39.h
@@ -28,7 +28,12 @@ STATIC mp_obj_t mod_foundation_bip39_get_words_matching_prefix(size_t n_args, co
mp_check_self(mp_obj_is_str_or_bytes(args[0]));
GET_STR_DATA_LEN(args[0], prefix_str, prefix_len);
- int max_matches = mp_obj_get_int(args[1]);
+ mp_int_t max_matches = mp_obj_get_int(args[1]);
+ if (max_matches < 0) {
+ // The count is unsigned in get_words_matching_prefix(), so a negative value
+ // would become an effectively unlimited one
+ mp_raise_ValueError(MP_ERROR_TEXT("max_matches must not be negative"));
+ }
// Must be "bip39" or "bytewords"
mp_check_self(mp_obj_is_str_or_bytes(args[2]));
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -40,6 +40,10 @@ def test_psbt_sighash(test):
assert test('psbt_sighash.py') == b'OK'
+def test_bip39_prefix_matching(test):
+ assert test('bip39_prefix_matching.py') == b'OK'
+
+
def test_psbt_multisig_approval(test):
assert test('psbt_multisig_approval.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/bip39_prefix_matching.py
@@ -0,0 +1,89 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# The bounds accounting in get_words_matching_prefix() has to hold for any buffer
+# size and any requested match count. The binding hands it a 160 byte buffer, so
+# the longest string it can return is 159 characters.
+
+from foundation import bip39
+
+MATCHES_LEN = 160
+MAX_RESULT_LEN = MATCHES_LEN - 1
+
+# Longest BIP39 word, so the largest entry is MAX_WORD_LEN + 1 with its separator.
+MAX_WORD_LEN = 8
+
+KEYPAD_LETTERS = ('abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz')
+
+LETTER_TO_DIGIT = {}
+for _digit, _letters in enumerate(KEYPAD_LETTERS):
+ for _letter in _letters:
+ LETTER_TO_DIGIT[_letter] = str(_digit + 2)
+
+
+def to_digits(word):
+ return ''.join([LETTER_TO_DIGIT[letter] for letter in word])
+
+
+def must_reject(call):
+ try:
+ call()
+ except ValueError:
+ return
+
+ raise RuntimeError('expected ValueError')
+
+
+def matching(prefix, max_matches, word_list='bip39'):
+ '''Call the binding and check the invariants that hold for every result.'''
+
+ result = bip39.get_words_matching_prefix(prefix, max_matches, word_list)
+
+ assert len(result) <= MAX_RESULT_LEN, \
+ 'result is {} bytes, the buffer is {}'.format(len(result) + 1, MATCHES_LEN)
+
+ if result == '':
+ return []
+
+ words = result.split(',')
+ for word in words:
+ assert word != '', 'empty entry in {}'.format(result)
+ assert to_digits(word).startswith(prefix), \
+ '{} does not match prefix {}'.format(word, prefix)
+ return words
+
+
+# Ordinary predictive entry is unaffected.
+assert matching(to_digits('abandon'), 5) == ['abandon']
+assert 'cat' in matching(to_digits('cat'), 10)
+# Shorter words sort first, so an exact match leads the list it shares with longer ones.
+assert matching(to_digits('zoo'), 10)[0] == 'zoo'
+
+# A prefix no word can match yields an empty list.
+assert matching('999999999', 10) == []
+
+# A broad prefix has far more matches than fit, so the result is truncated.
+for prefix in ('2', '7', '22'):
+ truncated = matching(prefix, 2048)
+ # Truncation happens at the end of the buffer rather than well before it: the
+ # match that did not fit needs at most MAX_WORD_LEN + 1 bytes.
+ assert len(truncated) >= 25, \
+ 'only {} matches for prefix {}'.format(len(truncated), prefix)
+ assert MAX_RESULT_LEN - len(','.join(truncated)) <= MAX_WORD_LEN
+
+# max_matches still caps the result, and zero now means zero rather than unlimited.
+assert len(matching('2', 10)) == 10
+assert len(matching('2', 1)) == 1
+assert matching('2', 0) == []
+
+# A negative count would otherwise wrap to an effectively unlimited unsigned one.
+must_reject(lambda: bip39.get_words_matching_prefix('2', -1, 'bip39'))
+
+# Bytewords share the implementation and the same buffer.
+assert len(matching(to_digits('acid'), 10, word_list='bytewords')) >= 1
+assert len(matching('2', 256, word_list='bytewords')) >= 25
+
+# An unrecognised word list selects no table at all.
+assert bip39.get_words_matching_prefix('2', 10, 'not-a-word-list') is None
+
+return_value.write(b'OK')Why this scored 59/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.