reject truncated and oversized tlv lengths when parsing keycard responses
What changed, and why it matters
This commit fixes a bug in how Sparrow Wallet reads data from Keycard hardware wallets. Previously, the code trusted the length declared inside a card response without checking whether that many bytes actually exist. A malicious or malfunctioning card could claim a huge length, causing the app to read past the end of the buffer and potentially return fabricated zero-filled data. The patch now rejects responses whose declared length is truncated, oversized, or malformed, and adds tests to confirm the new behavior.
Treat this as a security hardening fix for the Keycard integration. Review whether any other TLV parsers in the codebase perform similar unchecked length arithmetic, and ensure the new validation does not break legitimate Keycard responses. Users who interact with Keycards should upgrade to a release containing this commit, especially if they use cards from untrusted sources.
Security signals we found
Out-of-bounds read / buffer over-read in TLV length parsing
Truncated and oversized length values previously accepted
Potential memory-safety issue leading to zero-padded fabricated data
Input validation added for malformed BER-TLV length encoding
Indefinite length form (0x80) now rejected
Test cases demonstrate prior behavior: short body returned zero padding, oversized length returned megabytes of zeros
Evidence from the diff
TinyBERTLV is a minimal BER-TLV parser used when communicating with Keycard smartcards. Before this patch, readNum() parsed multi-byte length headers and returned a length plus an offset without validating that the offset plus length stayed within the supplied buffer. As a result, readPrimitive() and enterConstructed() could allocate or return arrays whose content was entirely or partly zero padding from an out-of-bounds read. The patch adds bounds checks for missing length bytes, unsupported length widths (1-4 bytes only), truncated length headers, negative lengths, and declared lengths exceeding remaining bytes. It also adds a comprehensive JUnit test suite covering these cases and normal parsing paths.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.javaKeycard hardware-wallet integrationApplicationInfo parsing from card SELECT responseInspect captured patch +160 / −5
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java
index a952dbc..56c2e7c 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java
@@ -15,16 +15,39 @@ public class TinyBERTLV {
private byte[] buffer;
private int pos;
+ /**
+ * Reads the length at the given offset, on one to four bytes. The returned length is guaranteed to be present in
+ * the given buffer.
+ *
+ * @param buf the buffer to read from
+ * @param off the offset of the length
+ * @return the length, and the offset of the body following it
+ * @throws IllegalArgumentException if the length is malformed, or declares more bytes than the buffer holds
+ */
public static int[] readNum(byte[] buf, int off) {
+ if(off >= buf.length) {
+ throw new IllegalArgumentException("Truncated TLV: no length byte at offset " + off);
+ }
+
int len = buf[off++] & 0xff;
int lenlen = 0;
if((len & 0x80) == 0x80) {
lenlen = len & 0x7f;
+ if(lenlen < 1 || lenlen > 4 || (off + lenlen) > buf.length) {
+ throw new IllegalArgumentException("Truncated TLV: length header of " + lenlen + " bytes is unsupported or exceeds the " + (buf.length - off) + " bytes remaining");
+ }
+
len = readVal(buf, off, lenlen);
}
- return new int[]{len, off + lenlen};
+ off += lenlen;
+
+ if(len < 0 || len > (buf.length - off)) {
+ throw new IllegalArgumentException("Truncated TLV: declared length of " + Integer.toUnsignedString(len) + " exceeds the " + (buf.length - off) + " bytes remaining");
+ }
+
+ return new int[]{len, off};
}
public static int readVal(byte[] val, int off, int len) {
@@ -76,7 +99,7 @@ public class TinyBERTLV {
*
* @param tag the tag to enter
* @return the length of the TLV
- * @throws IllegalArgumentException if the next tag does not match the given one
+ * @throws IllegalArgumentException if the next tag does not match the given one, or the TLV extends past the end of the buffer
*/
public int enterConstructed(int tag) throws IllegalArgumentException {
checkTag(tag, readTag());
@@ -88,7 +111,7 @@ public class TinyBERTLV {
*
* @param tag the tag to read
* @return the body of the TLV
- * @throws IllegalArgumentException if the next tag does not match the given one
+ * @throws IllegalArgumentException if the next tag does not match the given one, or the TLV extends past the end of the buffer
*/
public byte[] readPrimitive(int tag) throws IllegalArgumentException {
checkTag(tag, readTag());
@@ -148,9 +171,10 @@ public class TinyBERTLV {
}
/**
- * Reads the next tag. The current implementation only reads length on one and two bytes. Can be extended if needed.
+ * Reads the next length. The current implementation reads lengths on one to four bytes. Can be extended if needed.
*
- * @return the tag
+ * @return the length
+ * @throws IllegalArgumentException if the length is malformed, or declares more bytes than the buffer holds
*/
public int readLength() {
int[] len = TinyBERTLV.readNum(buffer, pos);
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLVTest.java b/src/test/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLVTest.java
new file mode 100644
index 0000000..bb512d4
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLVTest.java
@@ -0,0 +1,131 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.Utils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+
+public class TinyBERTLVTest {
+ @Test
+ public void testFourByteLengthRejected() {
+ //tag 0x80 declaring a ~2GiB body from six bytes of card response
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("80847fffffff"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testNegativeLengthRejected() {
+ //a four byte length with the sign bit set
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("8084ffffffff"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testShortBodyRejectedNotZeroPadded() {
+ //five bytes declared, two supplied - previously returned three zero bytes of padding
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("80050102"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testLongFormShortBodyRejectedNotZeroPadded() {
+ //a long form length of one million, four bytes supplied - previously returned a megabyte of zero padding
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("8084000f424001020304"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testMissingLengthByteRejected() {
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("80"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testTruncatedLengthHeaderRejected() {
+ //0x84 promises four length bytes, only one follows
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("808400"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testIndefiniteLengthRejected() {
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("8080"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testOversizedConstructedLengthRejected() {
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("a4847fffffff"));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> tlv.enterConstructed(ApplicationInfo.TLV_APPLICATION_INFO_TEMPLATE));
+ }
+
+ @Test
+ public void testShortFormLengthStillParses() {
+ TinyBERTLV tlv = new TinyBERTLV(Utils.hexToBytes("80020102"));
+ Assertions.assertArrayEquals(Utils.hexToBytes("0102"), tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testLongFormLengthStillParses() {
+ //a 200 byte body, which requires the 0x81 length form
+ byte[] body = new byte[200];
+ for(int i = 0; i < body.length; i++) {
+ body[i] = (byte)i;
+ }
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ baos.write(ApplicationInfo.TLV_PUB_KEY);
+ TinyBERTLV.writeNum(baos, body.length);
+ baos.writeBytes(body);
+
+ TinyBERTLV tlv = new TinyBERTLV(baos.toByteArray());
+ Assertions.assertArrayEquals(body, tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY));
+ }
+
+ @Test
+ public void testUninitializedCardSelectResponseStillParses() {
+ byte[] pubKey = new byte[65];
+ pubKey[0] = 0x04;
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ writePrimitive(baos, ApplicationInfo.TLV_PUB_KEY, pubKey);
+
+ ApplicationInfo applicationInfo = new ApplicationInfo(baos.toByteArray());
+ Assertions.assertFalse(applicationInfo.isInitializedCard());
+ Assertions.assertArrayEquals(pubKey, applicationInfo.getSecureChannelPubKey());
+ Assertions.assertTrue(applicationInfo.hasSecureChannelCapability());
+ }
+
+ @Test
+ public void testInitializedCardSelectResponseStillParses() {
+ byte[] instanceUID = new byte[16];
+ byte[] pubKey = new byte[65];
+ pubKey[0] = 0x04;
+ byte[] keyUID = new byte[32];
+
+ ByteArrayOutputStream template = new ByteArrayOutputStream();
+ writePrimitive(template, ApplicationInfo.TLV_UID, instanceUID);
+ writePrimitive(template, ApplicationInfo.TLV_PUB_KEY, pubKey);
+ writePrimitive(template, TinyBERTLV.TLV_INT, new byte[] {0x03, 0x01});
+ writePrimitive(template, TinyBERTLV.TLV_INT, new byte[] {0x05});
+ writePrimitive(template, ApplicationInfo.TLV_KEY_UID, keyUID);
+ writePrimitive(template, ApplicationInfo.TLV_CAPABILITIES, new byte[] {ApplicationInfo.CAPABILITIES_ALL});
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ writePrimitive(baos, ApplicationInfo.TLV_APPLICATION_INFO_TEMPLATE, template.toByteArray());
+
+ ApplicationInfo applicationInfo = new ApplicationInfo(baos.toByteArray());
+ Assertions.assertTrue(applicationInfo.isInitializedCard());
+ Assertions.assertEquals("3.1", applicationInfo.getAppVersionString());
+ Assertions.assertEquals(5, applicationInfo.getFreePairingSlots());
+ Assertions.assertArrayEquals(keyUID, applicationInfo.getKeyUID());
+ Assertions.assertEquals(ApplicationInfo.CAPABILITIES_ALL, applicationInfo.getCapabilities());
+ }
+
+ private void writePrimitive(ByteArrayOutputStream baos, byte tag, byte[] body) {
+ baos.write(tag);
+ TinyBERTLV.writeNum(baos, body.length);
+ baos.writeBytes(body);
+ }
+}
Why this scored 56/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.