Skip to content

Re-serialising a parsed HIP PUZZLE or SOLUTION loses leading zero octets: a len=20 parameter rebuilds as len=6 #653

Description

@JarryShaw

_make_param_puzzle and _make_param_solution recompute the width of Random #I and Puzzle solution #J from int.bit_length(), and the data model carries no width to recompute it from. So a parameter that arrived on the wire with an 8-octet Random #I of 0x0000000000000001 re-serialises into a single octet: the value survives, the field width does not. A SOLUTION parsed with Length = 20 rebuilds as Length = 6.

This matters because the field width is not decoration in this parameter. RHASH_len is the output length of the puzzle hash, and both fields are defined as exactly that wide — RFC 7401 §5.2.5 gives Random #I as a "random number of size RHASH_len bits" and Length as 4 + RHASH_len / 4. Narrowing the field changes the implied RHASH_len, so the rebuilt parameter describes a different puzzle from the one that was received.

The sites

pcapkit/protocols/internet/hip.py:3195-3196, in _make_param_solution's param is not None branch — the path taken when re-serialising something that was parsed:

random = param.random
solution = param.solution

pcapkit/protocols/internet/hip.py:3212, sizing from those ints and nothing else:

len=4 + 2 * math.ceil(max(random.bit_length(), solution.bit_length()) / 8),

The sibling _make_param_puzzle has the same shape at :3152 and :3160:

len=4 + math.ceil(random.bit_length() / 8),

There is nothing wrong with bit_length() as such — the problem is that it is the only source of width available. pcapkit/protocols/data/internet/hip.py:196 and :213 define the data models, and neither carries a width field:

Data_SolutionParameter fields = ['type', 'critical', 'length', 'index', 'lifetime', 'opaque', 'random', 'solution']

length is the record length, not the field width, and it is not consulted by either builder. The reader discards the width on the way in: pcapkit/protocols/schema/internet/hip.py:454 and :456 size the two fields at (pkt['len'] - 4) // 2 each and hand back plain ints, so by the time _read_param_solution builds the data object the octet count is gone.

Measured

On origin/main (a62aed134), CPython 3.14.7, importing from an immutable git archive snapshot with pcapkit.__file__ asserted against it:

MEASURED pcapkit.__file__ = /tmp/hipissues/tA62/pcapkit/__init__.py
CPython 3.14.7

A HIPv2 SOLUTION was built with full-width 64-bit fields so that Length is 20, then the two 8-octet payload fields were overwritten with leading-zero values of the same width. Length stays 20; only the contents change. Parsed, then rebuilt through HIP.make. The parameter octets, before:

01 41  00 14  00  20  00 00  00 00 00 00 00 00 00 01  00 00 00 00 00 00 00 01  00 00 00 00
type   len=20 #K  Res opaque Random #I (8 octets)     solution #J (8 octets)   padding

and after:

01 41  00 06  00  20  00 00  01  01  00 00
type   len=6  #K  Res opaque R#I sol padding
parsed  : random = 1, solution = 1
rebuilt : wire Length field  before: 20   after: 6
          parameter octets   before: 56   after: 24    (two copies per packet)
          bytes identical across the round trip? False
re-parsed: random = 1, solution = 1

The integers survive; the eight-octet fields become one octet each. A conformant peer reading the rebuilt parameter sees RHASH_len = 8 bits where the original said 64.

It reproduces on 375e9d411 too, but masked

On 375e9d411a62aed134's parent, before #629 landed — the packet-level rebuild does not get far enough to show the width loss:

rebuild raised pcapkit.utilities.exceptions.ProtocolError: HIPv2: [ParamNo 321] invalid format
  builder called with param=<parsed> -> schema.len = 5 (wire Length was 20)

That ProtocolError is #608: with the old ceil(bits / 4) formula the rebuild produced len = 5, and (5 - 4) % 2 != 0 tripped the reader's parity guard first. Calling the builder directly shows the width loss is there regardless — 20 down to 5 rather than 20 down to 6. So this defect predates #629 and was hidden behind #608's louder failure; fixing #608 is what made it observable end to end. Worth stating plainly because it inverts the usual reading: the round trip now succeeds and silently produces a different parameter, where before it failed noisily.

Why nothing caught it

The round-trip suite builds every parameter from keyword arguments and never from octets with leading zeros. examples/generators/options.py:975-977 passes only {'lifetime': 1} for both PUZZLE and SOLUTION, so random and solution default to 0 — the one value for which no width can be lost, because there is no width. The parse-then-rebuild path is exercised, and the comparison is pcapkit against pcapkit, so a width that both sides narrow identically compares equal.

Catching it needs a parameter whose octets came from somewhere other than this builder, carrying a value narrower than its field — which no HIP fixture has, because every HIP fixture was generated by the code under test. #608's own write-up records the same gap in two neighbouring cases it did verify, #591's repair-path bit lengths and #601's nonce: 0xFFFFFF; I have not re-checked those two here, so that part is its claim rather than mine.

Reachability

Reachable from ordinary conformant input, and not only from a crafted one. RFC 7401 §2.3 defines RHASH_len as "the natural output length of RHASH in bits", where RHASH is the hash of the Responder's HIT Suite; §5.2.10 makes RSA,DSA/SHA-256 the REQUIRED suite, so on the mandatory path RHASH_len is 256 bits and Random #I is a 32-octet field. Any value with a zero top octet — one in 256, for a random number — loses at least one octet on re-serialisation, and a value with two zero top octets loses two. A pcapkit-based tool that parses a HIP base exchange and re-emits it will corrupt roughly that fraction of SOLUTION and PUZZLE parameters, silently.

What is not established is whether anything in this repository re-serialises HIP parameters outside the test suite. The defect is on a public construction path (HIP.make with a parsed param=), so a consumer can reach it, but I have not traced an internal caller.

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions