polycratia

Why leniency in a DER parser becomes a signature bypass

· 9 min read

Two implementations can read the same X.509 certificate and disagree about what it says. The cause is almost never the cryptography. BER permits several encodings of one value, DER permits exactly one, and a parser that quietly accepts the alternates has handed the document a second meaning, one the signature covers just as well as the first.

Most of my production work since 2018 has been payments, crypto rails and compliance tooling, and a good part of it involved documents that have to hold up as evidence after the fact: legally binding e-signature services and the document workflow around them, multi-party deal signing, on-chain tokens that accumulate signed state from listing to closing. The lesson that kept repeating there has nothing to do with algorithms. A signature attests to bytes. Between bytes and meaning sits a parser, and if that parser admits more than one reading, the signature attests to less than you think it does.

I wrote derstrict (https://github.com/polycratia/derstrict) as a header-only C++17 reader that refuses everything DER already forbids. What follows is the reasoning behind that, not an API tour.

One value, four encodings

Here is the integer 5 inside a SEQUENCE, written four ways. Only the first is DER.

text
30 03 02 01 05           SEQUENCE { INTEGER 5 }        valid DER
30 80 02 01 05 00 00     indefinite length             BER only
30 81 03 02 01 05        length written long-form      not minimal
30 04 02 02 00 05        INTEGER 00 05                 padded

The same holds one level down. An OBJECT IDENTIFIER arc is a base-128 number, and base 128 has no leading zero digit any more than base ten does, so 80 01 and 01 are one arc written two ways.

None of these are corrupt. Each one decodes, under BER, to exactly the value the first one holds, and that is the whole problem. A parser that accepts all four has not become more robust than a parser that accepts one: it has agreed to four documents where the standard defines one.

Leniency is a claim about every other parser in the system

"Be liberal in what you accept" is advice for a transport. It inverts for a signed document, because there the parse result is the thing being attested, and the attestation is shared with implementations you did not write and cannot inspect.

A signature bypass of this family does not require anyone to break a hash. It requires only that two components in one pipeline read the same bytes differently: the component that checks a name, an extension or a constraint reads encoding A, the component that acts on the value reads encoding B, and both verify the signature successfully, because the signature is over the bytes and the bytes never changed. The attacker does not need to control what the document means. They need the two readings to differ, and every alternate encoding you tolerate is one more place where that difference can be manufactured.

So the question a DER reader has to answer is not "can I make sense of this?" It is "is there exactly one sense to make?" Refusal is the mechanism that forces two independent implementations to agree, and I think that is worth more than any accommodation I could add on top.

The length rules are decided before any content is read

What matters structurally is ordering. Every length rule in derstrict is settled against the enclosing element before a single content byte is touched, so an inner element cannot reach into bytes its parent does not cover. Containment becomes arithmetic done up front, instead of a check somebody remembers to perform after the fact.

Four things are refused at that point. Indefinite length (0x80), because it is BER and because guessing where an element ends is precisely how parsers diverge. A long-form length where the short form fits, because 81 05 and 05 mean the same thing and only one encoding may exist. The reserved length byte 0xFF, because X.690 reserves it and it therefore announces no byte count at all: reading it as "127 length bytes follow" would be a parser inventing a meaning. And a length that runs past the end of the enclosing element, measured before descent.

The content rules apply the same idea one layer in: a leading 0x00 in an INTEGER is a sign byte only in front of a set top bit and a leading 0xFF is sign extension only in front of a clear one, an OID arc may not end with the continuation bit still set, a BIT STRING's unused-bits count is at most seven, is zero when there is no last byte, and counts bits that are themselves zero.

console
$ make demo
well formed                accepted: 1.2.840.113549.1.1.11
indefinite length          refused: indefinite length is BER, not DER
length written long-form   refused: the length is encoded the long way
oid ending mid-arc         refused: the object identifier ends mid-arc
bytes after the element    refused: bytes remain after the element

A cursor that cannot lie about how much it read

The last refusal in that list is the one people skip, and it carries the thesis most directly. Bytes after the outermost element mean somebody else read this document differently from you. There is no benign version of that.

Enforcing it means knowing exactly how many bytes were consumed, which is why the cursor carries a sticky failure and an exact remaining(). remaining() never underflows, and it is zero once a read has failed, because a failed parser reads nothing further. That is what makes a whole run of reads checkable at the end instead of at every step:

cpp
#include "derstrict/derstrict.hpp"

derstrict::parser outer{data, size};
const auto seq = outer.expect(derstrict::tag::sequence);
if (!seq) return derstrict::describe(outer.failure());

auto inner = outer.into(*seq);
const auto algorithm = inner.oid();
if (!inner.at_end() || !outer.at_end()) return "trailing data";

The two at_end() calls are not defensive noise. The inner one says the constructed element contained what it claimed and nothing more, the outer one says the document did. Without an exact byte count you have neither statement available, and "I parsed it and it looked fine" is not the same claim at all.

Nothing is copied and nothing is owned: content points into the caller's buffer. For a library whose entire job is to decide what a specific byte range means, that is a correctness property as much as a performance one, because there is no second copy that could drift from the bytes the signature covers.

Integers, where exactness usually dies

unsigned_integer() handles the small fields: versions, counts, the things that fit in 64 bits. Serial numbers and key material do not fit, so integer() reads one of any width or sign and hands back a view of the encoding: negative(), and magnitude() for a non-negative value's bytes where they already lie.

A negative value is the interesting case, and it is where I made the deliberate ergonomic sacrifice. Its magnitude is not in the document (the document holds the two's complement), so producing the magnitude means writing bytes somewhere, and this library owns no memory to write them into. magnitude_into() therefore writes into a buffer the caller owns. That is less convenient than returning a big integer, and it is the right trade: the alternative is allocation inside a parser that is supposed to be a pure decision about bytes that already exist.

Refusing something that is legal

High-tag-number form is valid DER. derstrict refuses it anyway, because no field it currently reaches uses it, and a parser that half-supports a construct is worse than one that declines it: the half-support is exactly the region where two implementations diverge. Refusing something legal is a smaller error than guessing at it, and it is a loud error rather than a quiet one.

The same logic governs what is not there yet: UTCTime and GeneralizedTime, string types with their character-set rules, context-specific tags. Those have real encoding rules, and shipping a lenient version of them to look complete would undo the point of the library. Every refusal listed above has a test built from hand-written bytes, 181 checks under AddressSanitizer and UndefinedBehaviorSanitizer, because in a library like this the tests for what it declines are the tests that carry the value.

What I would do differently

The pressure I would resist harder, and earlier, is convenience. Every request a strict reader receives is a request to accept one more thing: a certificate from a device fleet that emits long-form lengths, an old signer that pads its integers. Each one arrives as a small compatibility fix, and each one is a permanent statement that this document has two readings.

The honest way to carry that pressure is scope. derstrict reads the encoding and not the semantics: there is no certificate structure in it, it builds no chains, it verifies no signatures, and pretending otherwise would be the dangerous kind of convenience. It is not a replacement for a reviewed library in a codebase that already has one. It is for the case where the alternative is a hand-rolled loop over a buffer, and that is a case I have walked into more than once...

The framing I would keep is the one I started with. A parser's permissiveness is not a local property. It is a claim about every other implementation that will read the same bytes, and in a signed document that claim is the security boundary.

react

$ new-project --brief

or email hey@polycratia.com