The TCP option parser in TCP-SER-D02 (0x16CBA)¶
The routine at 0x16CBA in TCP-SER-B0-D02.BIN walks the options of an incoming TCP
segment. TCP_Input calls it. It is 404 bytes, and the catalog lists it as
TCP_rep_16CBA, layer FSMR-tcp-fsm. It is the source of the TCPP console message
047301B: TCPP0-TCP Invalid argument
Information: 047303B: Operation not supported on socket
that appears for every connection from a Windows client. Found 2026-09-27.
What the code does¶
Disassembled with capstone (68000 mode) from the image. Frame offsets are on a6:
$26 is the bytes left, $24 the index into the options, and $28 the current kind.
016CCC move.l $20(a6),d0 ; sub.l #$14,d0 bytes left = TCP header length - 20
016CE0 ble done
016CF6 move.b $20(a0,d1.l),d2 kind = option byte at index
016D04 beq done kind 0 = end of list
016D0A cmpi.w #2,$28(a6) ; bne notMss
... MSS: length byte must be >= 4 and fit in the bytes left, else ERROR
... on a SYN: read the 16-bit value, cap it at 0x400 (1024), store at TCB+$B4
016DC6 move.w $24(a6),d4
016DCA add.w $2a(a6),d4 index += length
016DCE addq.w #1,d4 index += 1 <-- one byte too far
016E06 notMss: cmpi.w #1,$28(a6) ; bne error
016E0E subq.w #1,$26(a6) NOP: bytes left - 1, index NOT moved
016E14 error: move.l #$4EC1,$56(a6) 20161 TcpEinval
016E2E move.l #$4EC3,$5A(a6) 20163 TcpEopnotsupp
016E36 jsr $A83C report to the host (TCPP prints it)
So:
- End of list (0) ends the walk.
- NOP (1) lowers the count but does not move the index. The same NOP is read again until the count reaches zero, so any NOP ends the walk quietly.
- MSS (2) is parsed, and then the index moves by length + 1, one byte past the next option.
- Anything else is reported as 20161 plus 20163. That covers window scale (3), SACK permitted (4) and timestamps (8).
Why Windows triggers it and Linux does not¶
After MSS the parser reads byte 5 of the option list, not byte 4.
| Sender | Option bytes | Byte 5 | What happens |
|---|---|---|---|
| Windows 11 | 02 04 05 b4 01 03 03 08 01 01 04 02 |
03 |
window-scale kind, so the message |
| Linux | 02 04 05 b4 04 02 08 0a <ts> <ecr> 01 03 03 0a |
02 |
read as MSS with length 08; the index then lands on a 00 in the echo field, which ends the walk |
How it was checked¶
Bare SYNs were injected on the host loopback adapter with scapy and npcap, and the TCPP lines
were counted in RetroCore's console log (console log FILE, stamped with host time).
Thirteen option layouts matched the code above. Three of them come out differently only
because of the off-by-one:
| Options | A correct parser | This parser | Seen |
|---|---|---|---|
02 04 05 b4 00 03 03 08 |
stops at the end-of-list byte | reads byte 5 = 03, error |
message |
02 04 05 b4 03 01 01 01 |
unknown kind 3, error | reads byte 5 = NOP, stops | quiet |
02 04 05 b4 01 01 01 01 |
quiet | quiet | quiet |
TTL, window size and source port were varied as well, and none of them change the result.
What the options walk can and cannot affect¶
This answers one question: if the walk stops early, or skips an option it does not know,
what goes wrong? Everything below was read from TCP_Input (0xBD2A) and TCP_ParseOptions
(0x16CBA) in TCP-SER-B0-D02.BIN, and the RFC lines are quoted from the RFC texts.
The data position does not come from the options walk¶
TCP_Input finds the data from the header's Data Offset field, not from the option
parser:
| Address | What it does |
|---|---|
0xBDB8-0xBDCA |
reads the Data Offset byte, keeps the top 4 bits, multiplies by 4: the header length in bytes, kept at $22(a6) |
0xBDCE-0xBDDA |
rejects the segment if the header length is larger than the segment |
0xC0DA-0xC0F8 |
calls TCP_ParseOptions only when the header is longer than 20 bytes, and passes the header length as a copy into the parser's own frame |
0xC064-0xC070 |
data length = segment length - header length |
0xC210-0xC234 |
data start: header length + 12 is added to the buffer offset and taken off the buffer length, which strips the headers |
TCP_ParseOptions never writes the header length back. It only changes its own frame and
the MSS in the connection record (the TCB, field $B4). When it reports the error it still
returns normally: the normal return skips the error slot after the report call, clears the
bytes-left count and leaves the loop. So TCP_Input carries on to the data code with the
right length.
That is the RFC 793 rule too: the Data Offset field gives "where the data begins", and
"the list of options may be shorter than the data offset field might imply". The same
shape - header length from Data Offset, options parsed on their own, then the header
stripped by that length - is how 4.2BSD tcp_input works, which SLIB says it is based on.
That match is our reading of the code, not something ND's manual states.
The ND uses one option: MSS¶
The parser has branches for kinds 0, 1 and 2 and nothing else. Window scale, SACK permitted and timestamps are never acted on, so not reading them costs nothing. They also cannot be switched on from one side:
- window scale: "both sides MUST send Window Scale options in their
segments to enable window scaling in either direction" (RFC 7323 section 2.2); - timestamps: a TCP "MAY send a TSopt in
only if it received a TSopt in the initial (RFC 7323 section 3.2);segment" - SACK: "If the data receiver has not received a SACK-Permitted option for a given connection, it MUST NOT send SACK options on that connection" (RFC 2018 section 4).
The ND has no code for any of them, so it cannot offer them, and the client cannot use them. The ND's own SYN-ACK has not been captured to confirm it carries none of these options; that conclusion comes from the parser code and the RFC rules.
Worst case if the walk stops at an unknown option¶
Only an MSS that comes after the unknown option is lost. Windows and Linux both send MSS first, so neither hits this. If a client did:
- the ND would not learn the client's maximum segment size. RFC 1122 section 4.2.2.6:
"If an MSS option is not received at connection setup, TCP MUST assume a default send MSS
of 536". What value the ND's TCB field
$B4actually starts with has not been read; - the result is smaller segments and slower transfers, not wrong data;
- the parser caps a received MSS at 1024 anyway (
cmpi.w #$400at 0x16DB8), so the most that can be lost is the step from 1024 down to the default.
Skipping an unknown option by its length byte, as RFC 1122 section 4.2.2.5 requires, avoids even that case, because the walk then reaches an MSS wherever it is.
Why ND never saw it: TCP options then and now¶
Every statement in this section is checked against the RFC text, listed under References. Quotes are exact.
What the rules say¶
RFC 793 (September 1981), section 3.1 "Header Format", the TCP standard of the time, defines three options:
Kind Length Meaning
---- ------ -------
0 - End of option list.
1 - No-Operation.
2 4 Maximum Segment Size.
Those are exactly the three kinds the ND parser knows. The same section says how far to
move past an option: "The option-length counts the two octets of option-kind and
option-length as well as the option-data octets." So the next option starts length bytes
further on. The parser moves length + 1, which is the off-by-one. RFC 793 also says of
NOP that "receivers must be prepared to process options even if they do not begin on a
word boundary", and that "A TCP must implement all options."
RFC 1122 (October 1989), section 4.2.2.5 added the rule for options a TCP does not know: "A TCP MUST ignore without error any TCP option it does not implement, assuming that the option has a length field (all TCP options defined in the future will have length fields)." RFC 9293 (August 2022), which replaces RFC 793, keeps it as MUST-6. The ND parser reports an error instead. Whether D02 was written before or after RFC 1122 is not known here: the build date of the firmware has not been found.
When the options that trip it arrived¶
| Option | Kind | First in | Status of that RFC | Now defined in |
|---|---|---|---|---|
| window scale | 3 | RFC 1072, October 1988 | experimental: "not proposed as an Internet standard at this time" | RFC 1323 (May 1992), then RFC 7323 (September 2014) |
| SACK permitted | 4 | RFC 1072, October 1988 | experimental, as above | RFC 2018 (October 1996, standards track) |
| timestamps | 8 | RFC 1323, May 1992 (standards track) | - | RFC 7323 |
RFC 1072 had "echo" options, kinds 6 and 7, not timestamps. Kind 8 first appears in RFC 1323. All three options in the table are sent in the SYN: RFC 1323 says window scale "is sent only in a SYN segment", and RFC 2018 says SACK permitted "may be sent in a SYN" and "MUST NOT be sent on non-SYN segments".
So until October 1988 MSS was the only option with data that a SYN could carry, and until 1992 the others were experimental. A SYN with only MSS leaves zero bytes after it, the walk ends, and the overshoot never reads anything. What hosts actually sent in the late 1980s is not recorded here; the RFCs only show what was defined. When Windows and Linux began to send these options on every connection is not known here either.
Why Windows prints the line and Linux does not¶
A modern client sends several options after MSS, so the overshoot now reads a real byte. Which byte that is depends on the order each operating system uses (see the table under "Why Windows triggers it and Linux does not" above, taken from captured SYNs):
- Windows has window scale (kind 3) at byte 5. The parser does not know kind 3 and reports it, so every Windows connection prints the TCPP line.
- Linux, WSL included, has the length byte of SACK permitted there. That byte is
02, which the parser takes as a second MSS with length08. It then lands in the timestamp echo field. RFC 1323 says that field "is only valid if the ACK bit is set" and "When TSecr is not valid, its value must be zero" (RFC 7323 makes it SHOULD), and a first SYN has no ACK bit. The parser reads that zero as end of list and stops quietly. So Linux is misread too, just without a message. WSL traffic leaves through Windows' address translation, which does not change TCP options, so the ND sees the Linux order.
The lesson for TCP work on SINTRAN: the D02 stack was written for the TCP of its time. When a modern client and the ND behave oddly together, first check whether the client sends something that the ND code predates. Options, window sizes and timers are the usual places.
References¶
All from the RFC Editor, https://www.rfc-editor.org/rfc/rfcNNNN.txt:
- RFC 793, Transmission Control Protocol, September 1981 - section 3.1, "Header Format", the Options field.
- RFC 1072, TCP Extensions for Long-Delay Paths, October 1988 - window scale (kind 3), SACK permitted (kind 4), echo (kinds 6 and 7).
- RFC 1122, Requirements for Internet Hosts -- Communication Layers, October 1989 - section 4.2.2.5, "TCP Options", and 4.2.2.6, "Maximum Segment Size Option" (the 536 default).
- RFC 1323, TCP Extensions for High Performance, May 1992 - window scale (kind 3), timestamps (kind 8). Replaces RFC 1072.
- RFC 2018, TCP Selective Acknowledgment Options, October 1996 - section 2, SACK permitted (kind 4); section 4, no SACK without SACK permitted.
- RFC 7323, TCP Extensions for High Performance, September 2014 - replaces RFC 1323; section 2.2 (window scale needs both sides), section 3.2 (timestamps in SYN-ACK only if received).
- RFC 9293, Transmission Control Protocol (TCP), August 2022 - replaces RFC 793; MUST-6, MUST-7 and MUST-68 on options.
Effect¶
After the report the walk ends and the routine returns normally, and the MSS has already been taken. The connection is set up normally and the data is found from the Data Offset field, so the only effect is the console line (see "What the options walk can and cannot affect"). The code is ND's firmware, run as written. This was not seen on real hardware, but the same image would do the same there.
A fix to the firmware itself is planned in PLAN-PATCH-TCP-OPTION-PARSER.md.