reject truncated and oversized tlv lengths when parsing keycard responses

This commit is contained in:
Craig Raw
2026-08-04 16:31:07 +02:00
parent fa0d48415f
commit b0b9cc23d4
2 changed files with 160 additions and 5 deletions
@@ -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);
@@ -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);
}
}