Net::IMAP: Command Injection via non-synchronizing literal in "raw" argument
Description
Several Net::IMAP commands accept a "raw data" argument that is sent verbatim after validation to prevent command injection. However, if a server does not support non-synchronizing literals, it may still be possible to inject arbitrary IMAP commands inside non-synchronizing literals.
Details
Raw data arguments support embedded literal values, both synchronizing and non-synchronizing. Non-synchronizing literals can only be safely sent when the server advertises any of the LITERAL+, LITERAL-, or IMAP4rev2 capabilities. But raw data arguments do not verify server support for non-synchronizing literals prior to sending.
Servers without support for non-synchronizing literals could handle them in several different ways: If a server sees a "}\r\n" byte sequence but can't parse the literal bytesize, it _may_ cautiously decide to close the connection, blocking any command injection attacks. However, a server without support for non-synchronizing literals may instead interpret the "+}\r\n" as the end of a malformed command line and respond with a tagged BAD. In that case, the contents of the literal will be interpreted as one or more new pipelined commands, allowing a CRLF command injection attack to succeed.
This affects the following commands' string arguments: * criteria for #search and #uid_search * search_keys for #sort, #thread, #uid_sort, and #uid_thread * attr for #fetch and #uid_fetch
Prior to net-imap v0.6.4, v0.5.14, and v0.4.24, raw data arguments were not validated in _any_ way, so they were also vulnerable to this attack. See CVE-2026-42257 (GHSA-hm49-wcqc-g2xg).
Impact
Fortunately, LITERAL- is supported by most modern IMAP servers. Even without support for non-synchronizing literals, cautious servers may handle invalid literal bytesize by closing the connection . However, servers which handle a non-synchronizing literal just like any other malformed command will enable this vulnerability.
If a developer passes an unvalidated user-controlled input for one of these method arguments, an attacker can append CRLF sequence followed by a new IMAP command (like DELETE mailbox). Although this does not directly enable data exfiltration, it could be combined with other attack vectors or knowledge of the target system's attributes, e.g.: shared mail folders or the application's installed response handlers.
Mitigation
Update to a version of net-imap which validates server support for non-synchronizing literals before sending them.
If upgrading net-imap is not possible: * Explicitly validate user-controlled inputs to prevent embedded non-synchronizing literals unless the server supports them. * For a simpler, more cautious approach: all embedded literals can be unconditionally prohibited, by checking that string inputs do not contain any CR or LF bytes. * Verify that the server advertises any of the LITERAL+, LITERAL-, or IMAP4rev2 capabilities before using untrusted string inputs for the affected "raw data" arguments.
Affected products
1Patches
4a2f61af6b7a7🍒 pick 62a0da6d (#701): 🥅 Validate non-synchronizing literals support
2 files changed · +68 −2
lib/net/imap/command_data.rb+17 −1 modified@@ -86,12 +86,19 @@ def send_binary_literal(*a, **kw); send_literal(*a, **kw, binary: true) end # `non_sync` is an optional tri-state flag: # * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior. - # TODO: raise or warn when capabilities don't allow non_sync. + # NOTE: raises DataFormatError when server doesn't support + # non-synchronizing literal, or literal is too large for LITERAL-. # * `false` -> Force normal synchronizing literal behavior. # * `nil` -> (default) Currently behaves like `false` (will be dynamic). # TODO: Dynamic, based on capabilities and bytesize. def send_literal(str, tag = nil, binary: false, non_sync: nil) synchronize do + if non_sync && !non_sync_literal_allowed?(str.bytesize) + # TODO: check in Printer, so we don't need to close the connection. + @sock.close + raise DataFormatError, "Connection closed: " \ + "Cannot send non-synchronizing literal without known server support" + end prefix = "~" if binary plus = "+" if non_sync put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n") @@ -113,6 +120,15 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil) end end + def non_sync_literal_allowed?(bytesize) + return unless capabilities_cached? + return "+" if capable?("LITERAL+") + return "-" if capable_literal_minus? && bytesize <= 4096 + false + end + + def capable_literal_minus? = capable?("LITERAL-") || capable?("IMAP4rev2") + # NOTE: +num+ should already be an Integer def send_number_data(num) put_string(Integer(num).to_s)
test/net/imap/test_imap.rb+51 −1 modified@@ -801,7 +801,7 @@ def test_raw_data end test("send literal args") do - with_fake_server do |server, imap| + with_fake_server(with_extensions: %w[LITERAL-]) do |server, imap| server.on "TEST", &:done_ok send_args = ->(*args) do imap.__send__(:send_command, "TEST", *args) @@ -850,6 +850,56 @@ def test_raw_data end end + test("send non-synchronizing literals with LITERAL+") do + with_fake_server( + with_extensions: %w[LITERAL+], greeting_capabilities: true, + ) do |server, imap| + def imap.send_test_args(*args) = send_command("TEST", *args) + server.on "TEST", &:done_ok + + # imap.config.max_non_synchronizing_literal = 5_000 + # NOTE: support for automatic non-synchronizing literals added in v0.6 + large = "\xff".b * 5_000 + imap.send_test_args Net::IMAP::Literal[large, nil] + assert_equal("{5000}\r\n#{large}".b, server.commands.pop.args) + + large = "\xff".b * 10_000 + imap.send_test_args Net::IMAP::Literal[large, nil] + assert_equal("{10000}\r\n#{large}".b, server.commands.pop.args) + + imap.send_test_args Net::IMAP::Literal[large, true] + assert_equal("{10000+}\r\n#{large}".b, server.commands.pop.args) + end + end + + test("send non-synchronizing literal that's too large for LITERAL-") do + with_fake_server( + with_extensions: %w[LITERAL-], greeting_capabilities: true, + ignore_abrupt_eof: true + ) do |server, imap| + def imap.send_test_args(*args) = send_command("TEST", *args) + server.on "TEST", &:done_ok + assert_raise(Net::IMAP::DataFormatError) do + imap.send_test_args Net::IMAP::Literal["\xff".b * 5000, true] + end + assert imap.disconnected? + end + end + + test("send non-synchronizing literal without known server support") do + with_fake_server( + with_extensions: %w[LITERAL+], greeting_capabilities: false, + ignore_abrupt_eof: true + ) do |server, imap| + def imap.send_test_args(*args) = send_command("TEST", *args) + server.on "TEST", &:done_ok + assert_raise(Net::IMAP::DataFormatError) do + imap.send_test_args Net::IMAP::Literal["\xff".b * 100, true] + end + assert imap.disconnected? + end + end + def test_disconnect server = create_tcp_server port = server.addr[1]
69da4a490dd1🍒 pick 8d9397ab (#698): 🥅 Validate QuotedString contains only valid bytes
2 files changed · +113 −18
lib/net/imap/command_data.rb+21 −8 modified@@ -150,8 +150,8 @@ def validate end end - # Represents IMAP +text+ data, which covers everything in the IMAP grammar, - # except for +literal+, +literal8+, and the concluding +CRLF+. + # Represents IMAP +text+ or +quoted+ data, which share the same + # validations of decoded #data, and differ only in how they are formatted. # # +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+. # Any multibyte +UTF-8+ character is also allowed when the connection @@ -164,7 +164,7 @@ def validate # # The string's bytes must be valid ASCII or valid UTF-8. The string's # reported encoding is ignored, but the string is _not_ transcoded. - class RawText < CommandData # :nodoc: + class ValidNonLiteralData < CommandData def initialize(data:) data = String(data.to_str) unless data.encoding in Encoding::ASCII | Encoding::UTF_8 @@ -189,7 +189,17 @@ def validate def ascii_only? = data.ascii_only? - def send_data(imap, tag) = imap.__send__(:put_string, data) + def send_data(imap, tag = nil) = imap.__send__(:put_string, formatted) + end + + # Represents IMAP +text+ data, which covers everything in the IMAP grammar, + # except for +literal+, +literal8+, and the concluding +CRLF+. + # + # NOTE: The current implementation does not verify that the connection + # supports UTF-8. Future versions may validate this. + class RawText < ValidNonLiteralData # :nodoc: + # raw: no formatting necessary + alias formatted data end class RawData < CommandData # :nodoc: @@ -268,10 +278,13 @@ def send_data(imap, tag) end end - class QuotedString < CommandData # :nodoc: - def send_data(imap, tag) - imap.__send__(:send_quoted_string, data) - end + # Represents a IMAP +quoted+ string, which can encode any valid ASCII or + # UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes. + # + # NOTE: The current implementation does not verify that the connection + # supports UTF-8. Future versions may validate this. + class QuotedString < ValidNonLiteralData # :nodoc: + def formatted = %("#{data.gsub(/["\\]/, "\\\\\\&")}") end class Literal < Data.define(:data, :non_sync) # :nodoc:
test/net/imap/test_command_data.rb+92 −10 modified@@ -8,6 +8,7 @@ class CommandDataTest < Net::IMAP::TestCase Atom = Net::IMAP::Atom Flag = Net::IMAP::Flag + QuotedString = Net::IMAP::QuotedString Literal = Net::IMAP::Literal Literal8 = Net::IMAP::Literal8 RawText = Net::IMAP::RawText @@ -155,6 +156,83 @@ def send_data(*data, tag: TAG) ], imap.output end + class QuotedStringTest < CommandDataTest + test "quotes ASCII strings (no specials)" do + assert_equal '"INBOX"', QuotedString["INBOX"].formatted + imap.send_data( + QuotedString["INBOX"], + QuotedString["etc"] + ) + assert_equal [ + Output.put_string('"INBOX"'), + Output.put_string('"etc"'), + ], imap.output + imap.clear + end + + test "quotes ASCII strings (atom specials)" do + [ + " with spaces in string ", + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\x7fcontrol_char", + %{(){}[]%*'}, + ].each do |string| + imap.send_data QuotedString[string] + end + assert_equal [ + Output.put_string('" with spaces in string "'), + Output.put_string('"with_parens()"'), + Output.put_string('"with_list_wildcards*"'), + Output.put_string('"with_list_wildcards%"'), + Output.put_string('"with_resp_special]"'), + Output.put_string(%{"with\x7fcontrol_char"}), + Output.put_string(%Q{"(){}[]%*'"}), + ], imap.output + end + + test "escapes quoted specials" do + [ + '"with" "quoted" "specials"', + "\\with\\quoted\\specials\\", + %{(){}[]%*"'\\}, + ].each do |string| + imap.send_data QuotedString[string] + end + assert_equal [ + Output.put_string('"\"with\" \"quoted\" \"specials\""'), + Output.put_string('"\\\\with\\\\quoted\\\\specials\\\\"'), + Output.put_string(%q{"(){}[]%*\"'\\\\"}), + ], imap.output + end + + test "ASCII compatible string with another encodings" do + imap.send_data QuotedString.new("foo bar".encode("cp1252")) + assert_equal [ + Output.put_string('"foo bar"'), + ], imap.output + end + + test "allows ASCII control chars" do + text = QuotedString.new("beep\b beep\b escape!\e delete this:\x1f") + imap.send_data text + assert_equal [ + Output.put_string(%{"beep\b beep\b escape!\e delete this:\x1f"}), + ], imap.output + end + + test "quotes valid UTF-8 multibyte chars" do + imap.send_data QuotedString.new("föó bär") + imap.send_data QuotedString.new("ほげ ふが ぴよ") + assert_equal [ + Output.put_string('"föó bär"'), + Output.put_string('"ほげ ふが ぴよ"'), + ], imap.output + end + end + class RawTextTest < CommandDataTest test "allows ASCII strings with no specials" do imap.send_data( @@ -229,14 +307,20 @@ class RawTextTest < CommandDataTest Output.put_string("ほげ ふが ぴよ"), ], imap.output end + end + SharedValidNonLiteralDataTests = ->(data_type) do data( "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i], "CR" => ["with \r CR", /CR\b.+\bbyte/i], "LF" => ["with \n LF", /LF\b.+\bbyte/i], ) test "invalid ASCII byte" do |(text, error_message)| - try_multiple_encodings(error_message, text) + with_multiple_encodings(text) do |encoded| + assert_raise_with_message(DataFormatError, error_message) do + data_type[encoded] + end + end end # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard: @@ -253,7 +337,11 @@ class RawTextTest < CommandDataTest "windows-1252" => "åêïõü".encode("windows-1252"), ) test "invalid UTF-8" do |text| - try_multiple_encodings(/invalid UTF-8/i, text) + with_multiple_encodings(text) do |encoded| + assert_raise_with_message(DataFormatError, /invalid UTF-8/i) do + data_type[encoded] + end + end end def with_multiple_encodings(data) @@ -262,15 +350,9 @@ def with_multiple_encodings(data) yield data.dup.force_encoding("UTF-8") yield data.dup.force_encoding("cp1252") end - - def try_multiple_encodings(error_message, data) - with_multiple_encodings(data) do |encoded| - assert_raise_with_message(DataFormatError, error_message) do - RawText[encoded] - end - end - end end + QuotedStringTest.class_exec QuotedString, &SharedValidNonLiteralDataTests + RawTextTest .class_exec RawText, &SharedValidNonLiteralDataTests class RawDataTest < CommandDataTest test "simple raw text" do
2354465f9836🍒 pick 0d508fa7 (#681): 🥅 Validate server's literal byte size format
4 files changed · +71 −13
lib/net/imap/response_parser.rb+20 −12 modified@@ -2189,10 +2189,7 @@ def next_token if $1 return Token.new(T_SPACE, $+) elsif $2 - len = $+.to_i - val = @str[@pos, len] - @pos += len - return Token.new(T_LITERAL8, val) + literal_token($+, T_LITERAL8) elsif $3 && $7 # greedily match ATOM, prefixed with NUMBER, NIL, or PLUS. return Token.new(T_ATOM, $3) @@ -2220,10 +2217,7 @@ def next_token elsif $15 return Token.new(T_RBRA, $+) elsif $16 - len = $+.to_i - val = @str[@pos, len] - @pos += len - return Token.new(T_LITERAL, val) + literal_token($+) elsif $17 return Token.new(T_PERCENT, $+) elsif $18 @@ -2249,10 +2243,7 @@ def next_token elsif $4 return Token.new(T_QUOTED, Patterns.unescape_quoted($+)) elsif $5 - len = $+.to_i - val = @str[@pos, len] - @pos += len - return Token.new(T_LITERAL, val) + literal_token($+) elsif $6 return Token.new(T_LPAR, $+) elsif $7 @@ -2267,6 +2258,23 @@ def next_token else parse_error("invalid @lex_state - %s", @lex_state.inspect) end + rescue DataFormatError => error + parse_error error.message + end + + def literal_token(len, type = T_LITERAL) + len = coerce_number64 len.to_i + val = @str[@pos, len] + @pos += len + Token.new(type, val) + end + + # copied/adapted from NumValidator in v0.6 + def coerce_number64(num) + int = num.to_i + return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff + raise DataFormatError, + "number64 must be unsigned 63-bit integer: #{num}" end end
lib/net/imap/response_reader.rb+14 −1 modified@@ -4,6 +4,8 @@ module Net class IMAP # See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2 class ResponseReader # :nodoc: + include NumValidator + attr_reader :client def initialize(client, sock) @@ -35,7 +37,10 @@ def done? = line_done? && !literal_size def line_done? = buff.end_with?(CRLF) def get_literal_size(buff) - buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i + buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && + coerce_number64($1) + rescue DataFormatError + raise DataFormatError, format("invalid response literal size (%s)", $1) end def read_line @@ -74,6 +79,14 @@ def max_response_remaining! ) end + # copied/adapted from NumValidator in v0.6 + def coerce_number64(num) + int = num.to_i + return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff + raise DataFormatError, + "number64 must be unsigned 63-bit integer: #{num}" + end + end end end
test/net/imap/fixtures/response_parser/quirky_behaviors.yml+16 −0 modified@@ -7,6 +7,22 @@ data: raw_data: "* NOOP\r\n" + "literal numeric formatted with zero-prefix": + :response: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: FETCH + data: !ruby/struct:Net::IMAP::FetchData + seqno: 20367 + attr: + BODY[HEADER.FIELDS (Foo)]: "Foo: bar\r\n\r\n" + raw_data: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n" + + "invalid literal numeric format (too large)": + :test_type: :assert_parse_failure + :message: "number64 must be unsigned 63-bit integer: 99999999999999999999" + :response: + "* 20367 FETCH (BODY[] {99999999999999999999}\r\nwon't parse this)\r\n" + test_invalid_noop_response_with_unparseable_data: :response: "* NOOP froopy snood\r\n" :expected: !ruby/struct:Net::IMAP::IgnoredResponse
test/net/imap/test_response_reader.rb+21 −0 modified@@ -25,6 +25,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" zero_literal = "tag ok #{literal ""} #{literal ""}\r\n" illegal_crs = "tag ok #{many_crs} #{many_crs}\r\n" illegal_lfs = "tag ok #{literal "\r"}\n#{literal "\r"}\n\r\n" + zero_padded = "+ {010}\r\n1234567890\r\n" # NOTE: it's decimal, not octal! + goofy_zero = "+ {000}\r\n\r\n" io = StringIO.new([ simple, long_line, @@ -33,6 +35,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" zero_literal, illegal_crs, illegal_lfs, + zero_padded, + goofy_zero, simple, ].join) rcvr = Net::IMAP::ResponseReader.new(client, io) @@ -43,6 +47,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" assert_equal zero_literal, rcvr.read_response_buffer.to_str assert_equal illegal_crs, rcvr.read_response_buffer.to_str assert_equal illegal_lfs, rcvr.read_response_buffer.to_str + assert_equal zero_padded, rcvr.read_response_buffer.to_str + assert_equal goofy_zero, rcvr.read_response_buffer.to_str assert_equal simple, rcvr.read_response_buffer.to_str assert_equal "", rcvr.read_response_buffer.to_str end @@ -82,4 +88,19 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" end end + data( + bad_int64: "+ {99999999999999999999}\r\ndon't even try to read this...", + ) + test "#read_response_buffer with invalid literal size" do |invalid| + client = FakeClient.new + client.config.max_response_size = nil # any size is allowed! + io = StringIO.new(invalid, "rb") + rcvr = Net::IMAP::ResponseReader.new(client, io) + assert_raise Net::IMAP::DataFormatError do + result = rcvr.read_response_buffer + flunk "Got result: %p" % [result] + end + # assert io.closed? + end + end
134b68269e89Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3News mentions
0No linked articles in our index yet.