Summary
history.c frames stored records with \x05 and \x06 as in-band sentinels, on the stated assumption that those bytes "neither occur in normal IRC payloads or in channel/nick names" (ircd/history.c:163). Nothing enforces that assumption on the default configuration, so a client can put the sentinels in its own message body and have the deserialiser promote attacker-controlled text into the message-tag prefix of a replayed message.
Reproduced against a stock ircv3.2-upgrade build over plain TCP. No WebSocket, no FEAT_UTF8ONLY, no special privileges.
Reproduction
Client A (any ordinary IRC client):
PRIVMSG #seance :\x06+evil/injected=owned\x06innocent looking text
Client B, with message-tags + draft/chathistory, then CHATHISTORY LATEST #seance * 5, receives:
@batch=hist44AAS;time=2026-08-28T18:34:34.952Z;msgid=ABAAAAAaBDmsja;+evil/injected=owned :victimA!victimA@172.17.0.1 PRIVMSG #seance :innocent looking text
+evil/injected=owned is not a tag client A sent — it is body text that the storage layer re-interpreted as a tag section. The visible message body is clean, so nothing looks wrong to a reader.
Mechanism
serialize_message() (ircd/history.c:152-200) writes [\x05<original_target>\x05][\x06<client_tags>\x06]<content> and does not escape or reject those bytes appearing inside content.
deserialize_message() (ircd/history.c:287-306) unconditionally lifts a leading \x06…\x06 span out of the content field into msg->client_tags.
m_chathistory.c:654 splices that verbatim into the outgoing tag prefix: ircd_snprintf(0, ctags_str, sizeof(ctags_str), ";%s", msg->client_tags), and :638 does the same for TAGMSG.
Nothing sanitises the content on the way in: check_utf8_text() (ircd/ircd_relay.c:104) returns early when FEAT_UTF8ONLY is off, which is the default (ircd_features.c:1006).
Related sentinels
The same class applies to the other in-band markers, which I have not built PoCs for:
\x05 forges original_target on PM-pair-keyed records (ircd/replay.c:325), which is what identifies the actual recipient for a nick1:nick2 key.
\x1F in replayed content is treated as a draft/multiline separator (m_chathistory.c:658) and reaches the paste listener's HTTP view (paste_listener.c:832).
\x1E + ml is read as a multiline content reference (ml_content.c:301).
Worth a look while in there: history.c:2171-2186 matches the +afternet.org/sid= PM-history authorisation token with an unanchored strstr over client_tags. I could not construct a clean bypass by reading alone — a forged section only lands in client_tags if no server-injected sid section and no original_target section precedes it, and PM records normally carry one — so this is flagged for analysis, not claimed as exploitable.
Suggested fix
Escape or reject \x05, \x06, \x1E and \x1F in serialize_message()'s content and client_tags fields rather than relying on them being absent, and anchor the sid match to a ;-delimited token.
Relationship to #102
Found while red-teaming #102. That PR is not the cause — the reproduction above is against a stock build — but the two interact and it seemed worth saying so explicitly. WebSocket clients on the text.ircv3.net subprotocol were accidentally shielded from this, because the UTF-8 sanitiser rewrote every one of these control bytes to U+FFFD before the line reached the parser. #102 fixes that sanitiser (those bytes are valid UTF-8 and it was destroying all IRC formatting and CTCP), which removes the accidental shield and puts WebSocket clients on the same footing as every other client. The right fix is escaping here, not a knowingly-wrong UTF-8 validator elsewhere.
🤖 Generated with Claude Code
Summary
history.cframes stored records with\x05and\x06as in-band sentinels, on the stated assumption that those bytes "neither occur in normal IRC payloads or in channel/nick names" (ircd/history.c:163). Nothing enforces that assumption on the default configuration, so a client can put the sentinels in its own message body and have the deserialiser promote attacker-controlled text into the message-tag prefix of a replayed message.Reproduced against a stock
ircv3.2-upgradebuild over plain TCP. No WebSocket, noFEAT_UTF8ONLY, no special privileges.Reproduction
Client A (any ordinary IRC client):
Client B, with
message-tags+draft/chathistory, thenCHATHISTORY LATEST #seance * 5, receives:+evil/injected=ownedis not a tag client A sent — it is body text that the storage layer re-interpreted as a tag section. The visible message body is clean, so nothing looks wrong to a reader.Mechanism
serialize_message()(ircd/history.c:152-200) writes[\x05<original_target>\x05][\x06<client_tags>\x06]<content>and does not escape or reject those bytes appearing insidecontent.deserialize_message()(ircd/history.c:287-306) unconditionally lifts a leading\x06…\x06span out of the content field intomsg->client_tags.m_chathistory.c:654splices that verbatim into the outgoing tag prefix:ircd_snprintf(0, ctags_str, sizeof(ctags_str), ";%s", msg->client_tags), and:638does the same for TAGMSG.Nothing sanitises the content on the way in:
check_utf8_text()(ircd/ircd_relay.c:104) returns early whenFEAT_UTF8ONLYis off, which is the default (ircd_features.c:1006).Related sentinels
The same class applies to the other in-band markers, which I have not built PoCs for:
\x05forgesoriginal_targeton PM-pair-keyed records (ircd/replay.c:325), which is what identifies the actual recipient for anick1:nick2key.\x1Fin replayed content is treated as adraft/multilineseparator (m_chathistory.c:658) and reaches the paste listener's HTTP view (paste_listener.c:832).\x1E+mlis read as a multiline content reference (ml_content.c:301).Worth a look while in there:
history.c:2171-2186matches the+afternet.org/sid=PM-history authorisation token with an unanchoredstrstroverclient_tags. I could not construct a clean bypass by reading alone — a forged section only lands inclient_tagsif no server-injected sid section and nooriginal_targetsection precedes it, and PM records normally carry one — so this is flagged for analysis, not claimed as exploitable.Suggested fix
Escape or reject
\x05,\x06,\x1Eand\x1Finserialize_message()'scontentandclient_tagsfields rather than relying on them being absent, and anchor the sid match to a;-delimited token.Relationship to #102
Found while red-teaming #102. That PR is not the cause — the reproduction above is against a stock build — but the two interact and it seemed worth saying so explicitly. WebSocket clients on the
text.ircv3.netsubprotocol were accidentally shielded from this, because the UTF-8 sanitiser rewrote every one of these control bytes to U+FFFD before the line reached the parser. #102 fixes that sanitiser (those bytes are valid UTF-8 and it was destroying all IRC formatting and CTCP), which removes the accidental shield and puts WebSocket clients on the same footing as every other client. The right fix is escaping here, not a knowingly-wrong UTF-8 validator elsewhere.🤖 Generated with Claude Code