Skip to content

utf8: C0 control characters are valid UTF-8 — stop rewriting them to U+FFFD - #102

Open
rubinlinux wants to merge 2 commits into
ircv3.2-upgradefrom
seance/utf8-control-chars
Open

utf8: C0 control characters are valid UTF-8 — stop rewriting them to U+FFFD#102
rubinlinux wants to merge 2 commits into
ircv3.2-upgradefrom
seance/utf8-control-chars

Conversation

@rubinlinux

@rubinlinux rubinlinux commented Aug 28, 2026

Copy link
Copy Markdown
Member

The bug

string_is_valid_utf8() (ircd/ircd_string.c) accepts only 0x09, 0x0A, 0x0D and 0x20–0x7E as single-byte sequences, so it reports every other C0 control byte as invalid UTF-8. string_sanitize_utf8() has the identical branch and overwrites each one with U+FFFD.

Every byte 0x01–0x7F is a well-formed single-byte UTF-8 sequence. The comment sitting in that very branch already says so — // use bytes[0] <= 0x7F to allow ASCII control characters — it was just never applied.

IRC carries the CTCP delimiter (0x01) and the mIRC formatting codes (bold 0x02, colour 0x03, reset 0x0F, monospace 0x11, reverse 0x16, italic 0x1D, strikethrough 0x1E, underline 0x1F) as bare control bytes, so the pair silently destroys formatting and CTCP in two places.

1. WebSocket clients on the text.ircv3.net subprotocol — both directions.

s_bsd.c:386 sanitises every outbound line before framing it; s_bsd.c:1270 sanitises every inbound frame before it reaches the IRC parser. So a browser client sees <U+FFFD>Nick<U+FFFD>: Pong! where services sent a bold nick, and its own /me waves leaves the server as <U+FFFD>ACTION waves<U+FFFD> — mangled for the whole channel, not just for the sender.

Fragmented text frames fare worse still: s_bsd.c:1229 kills the connection rather than sanitising, so a long enough formatted line from a browser is a disconnect.

2. Every client, on any network running FEAT_UTF8ONLY.

ircd_relay.c (PRIVMSG/NOTICE), m_topic.c, m_kick.c, m_part.c and m_quit.c all route user text through the same pair. Turning UTF8ONLY on today strips bold and colour from native IRC clients too.

Reproduction

Same client, same lines, only the subprotocol differs — against ircv3.2-upgrade at 94e4fbf:

text.ircv3.net    << :probe!p@… PRIVMSG probe :<U+FFFD>bold<U+FFFD> <U+FFFD>under<U+FFFD> <U+FFFD>04red<U+FFFD> done
                  << :probe!p@… PRIVMSG probe :<U+FFFD>ACTION waves<U+FFFD>
binary.ircv3.net  << :probe!p@… PRIVMSG probe :<U+0002>bold<U+0002> <U+001F>under<U+001F> <U+0003>04red<U+0003> done
                  << :probe!p@… PRIVMSG probe :<U+0001>ACTION waves<U+0001>

Binary mode is untouched because websocket_encode_frame(..., text_mode=0) writes raw bytes and the inbound sanitiser is gated on opcode == WS_OPCODE_TEXT. With this branch, text mode produces the second transcript too.

The fix

Accept 0x01–0x7F in the ASCII branch of both functions. 0x00 cannot reach it — both walk a NUL-terminated string and stop there.

Rejection of genuinely malformed encodings is untouched: lone continuation bytes, overlongs, surrogates, truncated sequences and 0xF5–0xFF leads are all still invalid, and RFC 6455 §8.1 is still satisfied (control characters are valid UTF-8; the RFC constrains encoding, not code points).

Behaviour elsewhere

  • Channel namesm_join.c's FEAT_VALID_UTF8_CHANNELS_ONLY gate goes through string_character_structure_is_sane(), which already admitted control bytes in pure-ASCII names via its !string_contains_non_ascii() arm. strIsIrcCh() runs first and remains the real filter. Only names mixing control bytes and valid non-ASCII change status.
  • m_metadata.c — values may now contain control bytes. That is what "valid UTF-8" means, and it matches what topics and messages already allow. A caller wanting printable-only text should check for that explicitly rather than borrowing the encoding validator; the doc comment now says so.
  • Nothing else in the tree depends on the printable-only side effect.

Tests

ircd/test/ircd_string_cmocka.c gains seven cases: the whole of 0x01–0x7F byte by byte, the CTCP and formatting codes as real lines, valid 2/3/4-byte sequences, the malformed encodings that must still be rejected, and string_sanitize_utf8()'s no-modification (-1) and replacement paths including the byte arithmetic of a U+FFFD substitution.

Full build plus all 21 cmocka suites green, then verified end to end against a live ircd built from this branch — the text.ircv3.net transcript above now matches the binary one byte for byte.

🤖 Generated with Claude Code


Second commit: an adjacent crash this makes easier to hit

A red-team pass over the first commit turned up a latent remote crash next door, which this branch now also fixes.

string_sanitize_utf8() returns -1 for "nothing modified", and that is reachable on a string that failed validation: its copy loop stops once the output buffer is nearly full (outlen < sizeof(out) - 4, ~508 bytes) while string_is_valid_utf8() scans the whole string. A longer line whose only malformed byte sits past that window validates as invalid and sanitises to -1, unchanged.

s_bsd.c assigned that straight into the frame length, and websocket_encode_frame() does an unchecked memcpy(frame + pos, data, data_len):

line_len=691  is_valid=0
sanitize returned -1  ->  websocket_encode_frame(data_len=-1)  ->  memcpy len 18446744073709551615

That is the two functions plus websocket_encode_frame driven verbatim from this tree with an ordinary 691-byte line — tags, 600 characters, one stray 0xFF. The inbound sibling at s_bsd.c:1273 already guards this (if (new_len > 0) ws_len = new_len;); the outbound path never did.

This is reachable on ircv3.2-upgrade as it stands — no formatting codes required, just a latin-1 client relaying a long line into a channel a WebSocket text client is sitting in. But the first commit widens it, so it belongs here rather than in a separate PR. Before it, any formatting byte in the first ~508 bytes set modified and produced a real length, so formatted lines were accidentally safe. Same 691-byte line with a leading \002:

plain + late 0xFF formatted + late 0xFF
before commit 1 sanitize = -1 ⚠️ sanitize = 508
after commit 1 sanitize = -1 ⚠️ sanitize = -1 ⚠️
after commit 2 length left alone length left alone

Fixes: take the sanitised length only when >= 0 and refuse to write a frame that failed to encode (s_bsd.c); reject a negative data_len rather than trusting the caller with an unchecked memcpy (websocket.c); document that -1 means "leave the string and its length alone", and why it can happen after a failed validation (ircd_string.c). Two more cmocka cases pin the contract.

Out of scope, reported separately

The same review surfaced a pre-existing message-tag injection via history.c's \x05/\x06 record sentinels, filed as its own issue. It is not caused by this branch — I reproduced it against a stock build over plain TCP, no WebSocket involved — so it should not gate this PR, but note the interaction: WebSocket text clients were accidentally shielded from it by the U+FFFD rewriting, and this branch removes that accident. The real fix is escaping in serialize_message(), not keeping a broken UTF-8 validator.

rubinlinux and others added 2 commits August 28, 2026 11:15
…U+FFFD

string_is_valid_utf8() accepted only 0x09, 0x0A, 0x0D and 0x20-0x7E as
single-byte sequences, so it reported every other C0 control byte as
invalid UTF-8, and string_sanitize_utf8() then overwrote each one with
U+FFFD.  Every byte 0x01-0x7F is a well-formed single-byte UTF-8 sequence.
The comment sitting in that very branch already said as much — "use
bytes[0] <= 0x7F to allow ASCII control characters" — it was just never
applied.

IRC carries the CTCP delimiter (0x01) and the mIRC formatting codes (bold
0x02, colour 0x03, reset 0x0F, monospace 0x11, reverse 0x16, italic 0x1D,
strikethrough 0x1E, underline 0x1F) as bare control bytes, so the pair
silently destroyed formatting and CTCP in two places:

- WebSocket clients on the text.ircv3.net subprotocol, both directions
  (s_bsd.c:386 outbound, :1270 inbound).  A browser saw
  "<U+FFFD>Nick<U+FFFD>: Pong!" where a services reply meant a bold nick,
  and its own /me left the server as "<U+FFFD>ACTION waves<U+FFFD>" —
  mangled for the whole channel, not just for the sender, because the
  damage happens before the line reaches the IRC parser.  Fragmented text
  frames fared worse still: s_bsd.c:1229 kills the connection instead of
  sanitizing, so a long enough formatted line from a browser was a
  disconnect.
- Every client, on any network running FEAT_UTF8ONLY: ircd_relay.c
  (PRIVMSG/NOTICE), m_topic.c, m_kick.c, m_part.c and m_quit.c all route
  user text through the same pair.

Fix the ASCII branch in both functions to accept 0x01-0x7F.  0x00 cannot
reach it — both walk a NUL-terminated string and stop there.

Behaviour elsewhere is unchanged in practice.  m_join.c's
FEAT_VALID_UTF8_CHANNELS_ONLY gate goes through
string_character_structure_is_sane(), which already admitted control bytes
in pure-ASCII names via its !string_contains_non_ascii() arm, and
strIsIrcCh() remains the real filter on channel names.  m_metadata.c now
accepts control bytes in values, which is what "valid UTF-8" means and
matches what topics and messages already allow.  Rejection of genuinely
malformed encodings — lone continuations, overlongs, surrogates, truncated
sequences, F5-FF leads — is untouched.

Verified against a browser WebSocket client on text.ircv3.net: before,
"\002bold\002 \037under\037" and "\001ACTION waves\001" came back with
every control byte replaced by U+FFFD; after, both round-trip byte for
byte.

ircd/test/ircd_string_cmocka.c gains seven cases covering the whole of
0x01-0x7F, the CTCP and formatting codes, valid multibyte text, the
malformed encodings that must still be rejected, and sanitize()'s
no-modification (-1) and replacement paths.  Full build plus all 21 cmocka
suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…length

string_sanitize_utf8() returns -1 for "nothing modified".  That is not only
the trivially-clean case: its copy loop stops once the output buffer is
nearly full (`outlen < sizeof(out) - 4`, i.e. ~508 bytes) while
string_is_valid_utf8() scans the entire string.  A line longer than that
whose only malformed byte sits past the window therefore validates as
invalid and sanitizes to -1, unchanged.

The outbound WebSocket text path assigned that straight into the frame
length:

    if (text_mode && !string_is_valid_utf8(irc_line))
      line_len = string_sanitize_utf8(irc_line);      /* may be -1 */
    frame_len = websocket_encode_frame(irc_line, line_len, ...);

websocket_encode_frame() then stores (unsigned char)-1 == 0xFF as the short
payload length and runs memcpy(frame + pos, data, (size_t)-1) — a SIZE_MAX
copy in a single-process daemon.  The inbound sibling a thousand lines down
already guards this (`if (new_len > 0) ws_len = new_len;`); the outbound one
never did.

Reproduced by driving the two functions and websocket_encode_frame verbatim
with a 691-byte line — ordinary tags plus 600 characters plus one stray 0xFF
— which yields is_valid=0, sanitize=-1, and a memcpy length of
18446744073709551615.  A malformed byte late in a long line needs no special
effort: any latin-1 client relaying into a channel that a WebSocket text
client is sitting in will do it.

This is reachable on ircv3.2-upgrade as it stands.  The preceding commit
widens it: before it, any mIRC formatting byte in the first ~508 bytes set
`modified` and returned a real length, so formatted lines were accidentally
safe; now only genuinely malformed bytes do, and a formatted long line with
a late bad byte hits the -1 path too.  Same 691-byte line with a leading
\002: sanitize returns 508 before that commit, -1 after.

  - s_bsd.c: take the sanitized length only when it is >= 0, and refuse to
    write a frame that failed to encode.
  - websocket.c: reject a negative data_len rather than trusting the caller
    with an unchecked memcpy.
  - ircd_string.c: document that -1 means "leave the string and its length
    alone", and why it can happen on a string that failed validation.

Two cmocka cases pin the contract: the past-the-window -1, and that an
in-window replacement still clamps to a whole-sequence boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MrLenin added a commit to MrLenin/nefarious2 that referenced this pull request Aug 28, 2026
…jection

evilnet#103 (Rubin, red-teaming evilnet#102).  serialize_message
frames records as [\x05target\x05][\x06tags\x06]content on the
assumption those bytes never appear in payloads; nothing enforces it
(check_utf8_text is a no-op unless FEAT_UTF8ONLY, default-off).  So a
client puts \x06+evil/tag=x\x06 in its own PRIVMSG body and the
deserializer lifts it into client_tags, which m_chathistory splices
verbatim into the replayed tag prefix -- attacker-controlled message
tags forged onto another user's replayed message, plain TCP, no privs.
The \x05 variant forges original_target on PM-pair-keyed records.

Fix: escape \x04/\x05/\x06 in every VALUE field (content,
client_tags, original_target) on serialize and reverse on deserialize;
the structural brackets stay raw.  Introducer \x04, escaped byte =
\x04,(b^0x40) -- results 'D'/'E'/'F' are never sentinels.  Legacy
records carry no \x04 escapes so they unescape to themselves
(backward compatible; already-stored poison can't be retro-cleaned but
no NEW injection lands).  bufsize grown to 2x the escapable fields.
mIRC formatting / CTCP bytes are deliberately NOT escaped -- only the
record sentinels are -- so evilnet#102's formatting restoration is unaffected.

Also anchor history_pm_target_has_sessid's +afternet.org/sid= match to
a tag-list boundary (start or ';'-preceded) instead of an unanchored
strstr over the (now un-injectable) client_tags.

Residue (separate analysis, per evilnet#103's own 'flagged not claimed'):
  - \x1E/\x1F multiline markers are structural in a DOWNSTREAM
    consumer and share this serialize path via ML_CONTENT_SENTINEL
    (0x1E); escaping them here would corrupt legitimate multiline
    records, so they need a coordinated escape+unescape across it.
  - a client can still legitimately NAME a client-only tag
    '+afternet.org/sid=...'; reserving that vendor namespace at store
    time is a distinct fix from the injection closed here.
Upstream ircv3.2-upgrade has the same defect (fix targets it).

Gate: full build; 21/21 cmocka suites (history suite +3 escape tests,
incl. the C-hex-escape-greediness trap in the escape-byte literal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant