polycratia

Why a DER length is checked against its parent, not the buffer

· 9 min read

A length field is the first place two readers of the same certificate start to disagree. Before a single content byte gets examined, the length bytes already admit several readings: an indefinite marker, a long form carrying a small count, a byte X.690 reserves, a count that reaches past the element it sits in. Each lenient answer hands you a document slightly different from the one that was signed.

derstrict (https://github.com/polycratia/derstrict) is built around one decision about that field: the length is settled entirely, against the span the element sits in, before any content byte is taken. For a child element, the span it sits in is its parent's content, not the document buffer. Once that holds, a class of bug stops being something you have to remember to check for.

The length bytes alone decide everything#

Reading an element happens in a fixed order. next() takes the tag byte, refuses the high-tag-number form as error::unexpected_tag, and calls read_length. Nothing after that point is conditional on content.

A first byte below 0x80 is the length. Otherwise:

  • 0x80 is error::indefinite_length. It is BER. A DER document cannot contain one, and a reader that goes hunting for a terminator is deciding on its own where the element ends.
  • 0xFF is error::reserved_length. X.690 reserves it, so it announces no byte count at all. Reading it as "127 length bytes follow" is a parser inventing a meaning.
  • otherwise the low seven bits say how many length bytes follow. More than sizeof(std::size_t) of them is error::length_too_large, and the bytes themselves arrive through the cursor's take, which fails with error::truncated when the span does not hold them.
  • the value is then held to one spelling. A long form whose first byte is 0x00, or whose value sits below 0x80, is error::non_minimal_length, so 0x81 0x05 cannot stand in for 0x05.

Only after all of that does the count get measured against the bytes still readable:

cpp
[[nodiscard]] std::size_t remaining() const noexcept { return ok() ? size_ - offset_ : 0; }

/// Whether `count` more bytes can be read. Every read goes through here,
/// which is what makes a failure stick.
[[nodiscard]] bool has(std::size_t count) const noexcept { return count <= remaining(); }

[[nodiscard]] const std::uint8_t* take(std::size_t count) noexcept {
    if (!has(count)) {
        (void)fail(error::truncated);
        return nullptr;
    }
    const std::uint8_t* const at = data_ + offset_;
    offset_ += count;
    return at;
}

Four bytes, four places a lenient reader drifts:

Length bytes What a permissive reader does What this reader does
0x80 ends the element at a sentinel it goes looking for error::indefinite_length
0x81 0x05 reads 5, same as 0x05 error::non_minimal_length
0xFF reads "127 length bytes follow" error::reserved_length
0x84 0x7F 0xFF 0xFF 0xFF accepts a two-gigabyte count and reads until something stops it error::truncated

Those live in the corpus as vectors rather than as prose, because the property I care about is that the same walk reads a corpus vector and a fuzzer's bytes:

cpp
{"a length written the long way", of({0x04, 0x81, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05}),
 error::non_minimal_length},
{"the reserved length byte", of({0x04, 0xFF, 0x01, 0x02}), error::reserved_length},
{"a length counted past the end",
 of({0x04, 0x84, 0x7F, 0xFF, 0xFF, 0xFF, 0xAA, 0xAA}), error::truncated},

What read_length returns is a capability, not a claim#

The distinction worth being precise about: a number parsed out of a document is a claim, and a number the reader has already measured against its own span is a capability. read_length returns the second kind.

cpp
[[nodiscard]] std::optional<element> next() noexcept {
    const std::size_t start = cur_.position();
    const auto raw_tag = cur_.byte();
    if (!raw_tag) return std::nullopt;

    element out{};
    out.raw_tag = *raw_tag;

    // High-tag-number form (0x1F) is legal DER but no certificate field
    // this parser reaches uses it, so it is refused rather than guessed at.
    if ((out.raw_tag & 0x1F) == 0x1F) return fail(error::unexpected_tag);

    // The length is settled before a content byte is touched: what comes
    // back from here is a count this span can already cover.
    const auto length = read_length();
    if (!length) return std::nullopt;
    // ... out.header becomes the distance from the element's start, and the
    // content is taken.
}

Because the count is already covered, the content gets taken without a second bounds check, and nothing downstream needs one either. encoding() widens an element's content back over its own header to get the full encoded bytes, and it does that without re-measuring anything: the header distance was recorded while reading, and the length was covered when it was read. A long-form header just makes the header longer, and the span still starts at the tag byte:

cpp
auto long_form = bytes({0x04, 0x81, 0x80});
long_form.resize(3 + 128, 0xAA);
auto q = over(long_form);

const auto e = q.next();
// ...
CHECK(e->encoding() == long_form.data());
CHECK(e->encoded_size() == 131u);

That 0x81 0x80 is a long form and it is accepted, because 128 does not fit the short form. Minimality means one encoding per value, not a ban on a form.

A child's span is its parent's content#

into() builds a parser over e.content and e.length. That is the whole mechanism. A child's cursor is sized to its parent's content, so when read_length asks has(), the bytes it measures against are exactly the bytes the parent owns.

cpp
case 0x30:
case 0x31: {
    const auto e = p.next();
    if (!e) break;
    if (depth + 1 >= max_depth) break;
    auto inner = p.into(*e);
    children(inner, e->content, e->length, depth + 1);
    break;
}
cpp
void children(derstrict::parser& p, const std::uint8_t* data, std::size_t size,
              unsigned depth) {
    while (p.more()) value(p, data, size, depth);
    if (!p.at_end()) note(p.failure());
}

An inner element that claims more bytes than its parent holds is refused on sight as error::truncated. It does not reach bytes the parent fails to cover, and there is no rule anywhere saying "check the child against the parent": the span is the parent, so the ordinary length check is that rule. The same check catches the top-level case. {0x30, 0x7F, 0x02, 0x01, 0x01} is an element longer than the document, and it comes back truncated for the same reason, out of the same code.

The only extra guard in the descent is depth. A document made of nothing but SEQUENCE headers is well-formed at every level, so the corpus reader caps recursion at max_depth = 24: deep enough for any certificate, shallow enough that such a document cannot walk off the stack.

Typed readers inherit all of it#

This ordering is also why the typed readers carry no span arithmetic of their own. A boolean, a null, an OID and a time are all read out of an element, so a value whose length was spelled twice does not reach its content:

cpp
const auto boolean_document = bytes({0x01, 0x81, 0x01, 0xFF});
auto p = over(boolean_document);
CHECK(!p.boolean().has_value());
CHECK(p.failure() == error::non_minimal_length);

const auto null_document = bytes({0x05, 0x80});
auto q = over(null_document);
CHECK(!q.null_value());
CHECK(q.failure() == error::indefinite_length);

The failure that comes back is a length failure, not a boolean failure, and that is the honest answer: the document broke an encoding rule before it ever said anything about true or false. One place in the reader knows how far a span reaches, and every other reader sits downstream of it.

0x81 0x05 is a perfectly good BER length. Accepting it alongside 0x05 means accepting two documents where DER defines one, and a reader that normalises them has chosen which of the two it is checking. A signature covers the bytes on the wire, so a reader that repairs an encoding ends up verifying one document and reading another. Stopping at the first length with a second spelling is what keeps those the same document.

The same reasoning runs out the other end of the walk. The first refusal sticks and nothing gets read after it, so you can check a run of reads once at the end instead of at every step, and the check you skip that way is the one that mattered. And at_end() turns leftover bytes into a failure rather than something stepped over: bytes after the last element of a document, or after the last child of a constructed element, mean somebody else read this document differently from you.

What I would do differently#

I carried the opposite habit for years out of payments and on-chain work: parse the length into a struct, pass the struct around, bounds-check at the point of use. It survives two call sites, breaks on the third... and the version of it I would not write again is the one that validated a length against the whole buffer instead of against the enclosing element. That is correct for a top-level element and quietly wrong for a child, which is probably the case nobody writes a test for, because the buffer check passes and the bytes are sitting right there.

The fix was not more checks. It was making the span a parser gets handed be the only bytes it can reach, and settling the length against that span before touching content. After that, "a child cannot escape its parent" is not an invariant to maintain: it is a consequence of the only arithmetic in the file.

A length is the smallest field in the format and the one with the most available readings. Deciding it from the length bytes alone, against the span it sits in, before anything else happens, is what makes the rest of the reader boring, and boring is what I want from the thing standing between a signature and a document.

react

$ new-project --brief

or email hey@polycratia.com