[<prev] [next>] [<thread-prev] [day] [month] [year] [list]
Message-ID: <722453025.72311.1740136584118@privateemail.com>
Date: Fri, 21 Feb 2025 12:16:24 +0100 (CET)
From: Jordy Zomer <jordy@...ing.systems>
To: Qualys Security Advisory <qsa@...lys.com>,
Qualys Security Advisory via Fulldisclosure <fulldisclosure@...lists.org>
Subject: Re: [FD] MitM attack against OpenSSH's VerifyHostKeyDNS-enabled
client
Hey all,
First of all, cool findings! I've been working on the CodeQL query and have a revised version that I think improves accuracy and might offer some performance gains (though I haven't done rigorous benchmarking). The key change is the use of `StackVariableReachability` and making sure that there's a path wher e `var` is not reassigned before taking a `goto _;`. Ran it on an older database, found some of the same bugs with no false-positives so far.
This is the revised query.
```
import cpp
import semmle.code.cpp.controlflow.StackVariableReachability
// A call that can return 0
class CallReturningOK extends FunctionCall {
CallReturningOK() {
exists(ReturnStmt ret | this.getTarget() = ret.getEnclosingFunction() and ret.getExpr().getValue().toInt() = 0)
}
}
class GotoWrongRetvalConfiguration extends StackVariableReachability {
GotoWrongRetvalConfiguration() { this = "GotoWrongRetvalConfiguration" }
// Source is an assigment of an "OK" return value to an access of v
// To not get FP's we get a false successor
override predicate isSource(ControlFlowNode node, StackVariable v) {
exists(AssignExpr ae, IfStmt ifst | ae.getRValue() instanceof CallReturningOK
and v.getAnAccess() = ae.getLValue() and ifst.getCondition().getAChild() = ae and
ifst.getCondition().getAFalseSuccessor() = node)
}
// Our intermediate sink is a `goto _` statement, but it should have a successor that's a return of `v`
override predicate isSink(ControlFlowNode node, StackVariable v) {
exists(ReturnStmt ret | ret.getExpr() = v.getAnAccess() and
node instanceof GotoStmt and node.getASuccessor+() = ret.getExpr())
}
// We don't want `v` to be reassigned
override predicate isBarrier(ControlFlowNode node, StackVariable v) {
exists(AssignExpr ae | ae.getLValue() = node and v.getAnAccess() = node)
}
}
from ControlFlowNode source, ControlFlowNode sink, GotoWrongRetvalConfiguration conf, Variable v, Expr retval
where
// We want a call that can `return 0` to reach a goto that has a ret of `v` sucessor
conf.reaches(source, v, sink)
and
// We don't want `v` to be reassigned after the goto
not conf.isBarrier(sink.getASuccessor+(), v)
// this goes from our intermediate sink to retval
and sink.getASuccessor+() = retval
// Just making sure that it's returning v
and exists(ReturnStmt ret | ret.getExpr() = retval and retval = v.getAnAccess())
select retval.getEnclosingFunction(), source, sink, retval
```
Hope that's helpful, please reach out if you have any questions :)
Cheers,
Jordy
> On 02/18/2025 11:28 AM CET Qualys Security Advisory via Fulldisclosure <fulldisclosure@...lists.org> wrote:
>
>
> Qualys Security Advisory
>
> CVE-2025-26465: MitM attack against OpenSSH's VerifyHostKeyDNS-enabled
> client
>
> CVE-2025-26466: DoS attack against OpenSSH's client and server
>
>
> ========================================================================
> Contents
> ========================================================================
>
> Summary
> Background
> Experiments
> Results
> MitM attack against OpenSSH's VerifyHostKeyDNS-enabled client
> DoS attack against OpenSSH's client and server (memory consumption)
> DoS attack against OpenSSH's client and server (CPU consumption)
> Proof of concept
> Acknowledgments
> Timeline
>
>
> ========================================================================
> Summary
> ========================================================================
>
> We discovered two vulnerabilities in OpenSSH:
>
> - The OpenSSH client is vulnerable to an active machine-in-the-middle
> attack if the VerifyHostKeyDNS option is enabled (it is disabled by
> default): when a vulnerable client connects to a server, an active
> machine-in-the-middle can impersonate the server by completely
> bypassing the client's checks of the server's identity.
>
> This attack against the OpenSSH client succeeds whether
> VerifyHostKeyDNS is "yes" or "ask" (it is "no" by default), without
> user interaction, and whether the impersonated server actually has an
> SSHFP resource record or not (an SSH fingerprint stored in DNS). This
> vulnerability was introduced in December 2014 (shortly before OpenSSH
> 6.8p1) by commit 5e39a49 ("Add RevokedHostKeys option for the client
> to allow text-file or KRL-based revocation of host keys"). For more
> information on VerifyHostKeyDNS:
>
> https://man.openbsd.org/ssh_config#VerifyHostKeyDNS
> https://man.openbsd.org/ssh#VERIFYING_HOST_KEYS
>
> Note: although VerifyHostKeyDNS is disabled by default, it was enabled
> by default on FreeBSD (for example) from September 2013 to March 2023;
> for more information:
>
> https://cgit.freebsd.org/src/commit/?id=83c6a52
> https://cgit.freebsd.org/src/commit/?id=41ff5ea
>
> - The OpenSSH client and server are vulnerable to a pre-authentication
> denial-of-service attack: an asymmetric resource consumption of both
> memory and CPU. This vulnerability was introduced in August 2023
> (shortly before OpenSSH 9.5p1) by commit dce6d80 ("Introduce a
> transport-level ping facility").
>
> On the server side, this attack can be easily mitigated by mechanisms
> that are already built in OpenSSH: LoginGraceTime, MaxStartups, and
> more recently (OpenSSH 9.8p1 and newer) PerSourcePenalties; for more
> information:
>
> https://man.openbsd.org/sshd_config#LoginGraceTime
> https://man.openbsd.org/sshd_config#MaxStartups
> https://man.openbsd.org/sshd_config#PerSourcePenalties
>
>
> ========================================================================
> Background
> ========================================================================
>
> OpenSSH heavily uses the following idiom throughout its code base:
>
> ------------------------------------------------------------------------
> 1387 int
> 1388 sshkey_to_base64(const struct sshkey *key, char **b64p)
> 1389 {
> 1390 int r = SSH_ERR_INTERNAL_ERROR;
> ....
> 1398 if ((r = sshkey_putb(key, b)) != 0)
> 1399 goto out;
> 1400 if ((uu = sshbuf_dtob64_string(b, 0)) == NULL) {
> 1401 r = SSH_ERR_ALLOC_FAIL;
> 1402 goto out;
> 1403 }
> ....
> 1409 r = 0;
> 1410 out:
> ....
> 1413 return r;
> 1414 }
> ------------------------------------------------------------------------
>
> - at line 1390, the return value r is safely initialized to a non-zero
> error code (to prevent sshkey_to_base64() from mistakenly returning
> success, i.e. zero);
>
> - at line 1398, if sshkey_putb() fails (if it returns a non-zero error
> code), then r is automatically set to this error code and immediately
> returned at line 1413;
>
> - at line 1400, if sshbuf_dtob64_string() fails (if it returns NULL),
> then r is manually reset to a non-zero error code at line 1401 and
> immediately returned at line 1413;
>
> - if no error occurs at all in sshkey_to_base64(), then at line 1409 r
> is set to zero (success) and eventually returned at line 1413.
>
> This idiom left us pondering: what if the manual reset of the return
> value r at line 1401 were missing? Then sshkey_to_base64() would
> mistakenly return zero (success) if sshbuf_dtob64_string() fails at line
> 1400, because r was automatically set to zero at line 1398 (because, to
> reach line 1400, sshkey_putb() necessarily succeeded, at line 1398).
>
> The consequences of such a mistake (a function that returns success
> although it clearly failed) would of course depend on the exact nature
> of the affected function and its callers; but we began to suspect that,
> in a large code base such as OpenSSH, some of the manual resets of the
> return values r were inevitably missing, and some of these mistakes may
> very well have security consequences.
>
> Note: the same basic idea also underlies Kevin Backhouse's beautiful
> CVE-2023-2283, an authentication bypass in libssh; for more information:
>
> https://securitylab.github.com/advisories/GHSL-2023-085_libssh/
> https://x.com/kevin_backhouse/status/1666459308941357056
>
>
> ========================================================================
> Experiments
> ========================================================================
>
> To confirm our suspicion, we adopted a dual strategy:
>
> - we manually audited all of OpenSSH's functions that use "goto", for
> missing resets of their return value;
>
> - we wrote a CodeQL query that automatically searches for functions that
> "goto out" without resetting their return value in the corresponding
> "if" code block.
>
> Warning: our rudimentary CodeQL query (below) might hurt the eyes of
> experienced CodeQL programmers; if you, dear reader, are able to write a
> query that runs faster or produces less false positives, please post it
> to the public oss-security mailing list!
>
> ------------------------------------------------------------------------
> /**
> * @kind problem
> * @id cpp/test
> * @problem.severity error
> */
>
> import cpp
>
> Stmt getRecChild(Stmt s) {
> result = s or
> result = getRecChild(s.getChildStmt())
> }
>
> ControlFlowNode getRecPredecessor(ControlFlowNode n) {
> result = n or
> result = getRecPredecessor(n.getAPredecessor())
> }
>
> from Function f, ReturnStmt r, LocalVariable v, IfStmt i, Stmt b
> where f.getBlock().getLastStmtIn() = r and
> exists(VariableAccess a | a.getTarget() = v and not a.isModified() and a.getEnclosingStmt() = getRecChild(r)) and
> v.getType() instanceof IntType and
> i.getEnclosingFunction() = f and
> i.getThen() = b and
> (b instanceof GotoStmt or b instanceof BlockStmt and ((BlockStmt)b).getLastStmtIn() instanceof GotoStmt) and
> not exists(Assignment a | a.getEnclosingFunction() = f and a.getLValue().toString() = v.getName() and a.getEnclosingStmt() = getRecChild(i)) and
> not exists(Assignment a | a.getEnclosingFunction() = f and a.getLValue().toString() = v.getName() and a.getEnclosingStmt() = getRecChild(b)) and
> exists(Assignment a | a.getEnclosingFunction() = f and a.getLValue().toString() = v.getName() and
> a.getLocation().toString() != v.getDefinitionLocation().toString() and
> a.getBasicBlock().getANode() = getRecPredecessor(i.getBasicBlock().getANode()))
> select f, b.getLocation().toString()
> ------------------------------------------------------------------------
>
>
> ========================================================================
> Results
> ========================================================================
>
> Against OpenSSH 9.9p1, this CodeQL query produces 50 results. Among
> these, on closer inspection, 37 are false positives:
>
> ------------------------------------------------------------------------
> | addr_match_list | addrmatch.c:91:5:91:17 |
> | hostkeys_foreach_file | hostfile.c:806:5:806:13 |
> | hostkeys_foreach_file | hostfile.c:819:25:823:4 |
> | hostkeys_foreach_file | hostfile.c:832:26:837:5 |
> | hostkeys_foreach_file | hostfile.c:856:36:860:3 |
> | hostkeys_foreach_file | hostfile.c:874:55:876:4 |
> | hostkeys_foreach_file | hostfile.c:884:5:884:13 |
> | hostkeys_foreach_file | hostfile.c:895:5:895:13 |
> | sshkey_ecdsa_fixup_group | ssh-ecdsa.c:81:4:81:12 |
> | sshkey_ecdsa_fixup_group | ssh-ecdsa.c:88:3:88:11 |
> | sshkey_ecdsa_fixup_group | ssh-ecdsa.c:94:3:94:11 |
> | ssh_packet_read_poll2 | packet.c:1696:5:1696:13 |
> | sshkey_load_private_type | authfile.c:133:3:133:11 |
> | kex_exchange_identification | kex.c:1332:32:1336:4 |
> | kex_exchange_identification | kex.c:1342:55:1345:4 |
> | kex_exchange_identification | kex.c:1358:25:1363:3 |
> | input_kex_gen_init | kexgen.c:331:3:331:11 |
> | input_kex_gen_reply | kexgen.c:207:3:207:11 |
> | process_config_line_depth | readconf.c:2444:14:2448:2 |
> | parse_args | sftp.c:1511:4:1511:21 |
> | delete_file | ssh-add.c:193:3:193:11 |
> | delete_file | ssh-add.c:199:64:203:2 |
> | add_file | ssh-add.c:401:3:401:11 |
> | add_file | ssh-add.c:405:60:410:2 |
> | add_file | ssh-add.c:412:43:417:2 |
> | add_file | ssh-add.c:420:47:424:2 |
> | add_file | ssh-add.c:425:50:429:2 |
> | add_file | ssh-add.c:434:50:438:2 |
> | _ssh_read_banner | ssh_api.c:366:5:366:13 |
> | _ssh_read_banner | ssh_api.c:370:5:370:13 |
> | ssh_create_socket | sshconnect.c:384:28:388:3 |
> | ssh_create_socket | sshconnect.c:389:20:392:3 |
> | ssh_create_socket | sshconnect.c:397:40:401:3 |
> | ssh_create_socket | sshconnect.c:404:47:408:3 |
> | ssh_create_socket | sshconnect.c:414:58:417:2 |
> | ssh_create_socket | sshconnect.c:418:66:421:2 |
> | identity_sign | sshconnect2.c:1257:42:1265:3 |
> ------------------------------------------------------------------------
>
> For example, the line 1696 in packet.c is obviously a false positive
> (although the "if" code block at lines 1695-1696 does not reset the
> return value r to a non-zero error code, our CodeQL query fails to
> notice that, to reach line 1695, r was necessarily set to a non-zero
> error code at lines 1691-1694):
>
> ------------------------------------------------------------------------
> 1557 int
> 1558 ssh_packet_read_poll2(struct ssh *ssh, u_char *typep, u_int32_t *seqnr_p)
> 1559 {
> ....
> 1691 if (!mac->etm && (r = mac_check(mac, state->p_read.seqnr,
> 1692 sshbuf_ptr(state->incoming_packet),
> 1693 sshbuf_len(state->incoming_packet),
> 1694 sshbuf_ptr(state->input), maclen)) != 0) {
> 1695 if (r != SSH_ERR_MAC_INVALID)
> 1696 goto out;
> ....
> 1791 out:
> 1792 return r;
> 1793 }
> ------------------------------------------------------------------------
>
> The remaining 13 results are all true positives (functions that return
> success although they clearly failed), but to the best of our knowledge,
> they have either no or minor security consequences:
>
> ------------------------------------------------------------------------
> | ssh_krl_from_blob | krl.c:1061:38:1064:2 |
> | revoked_certs_generate | krl.c:676:41:679:4 |
> | sshsk_load_resident | ssh-sk-client.c:440:48:443:3 |
> | sshsk_load_resident | ssh-sk-client.c:451:32:454:3 |
> | process_ext_session_bind | ssh-agent.c:1742:48:1745:2 |
> | parse_key_constraint_extension | ssh-agent.c:1209:22:1212:3 |
> | parse_key_constraint_extension | ssh-agent.c:1218:46:1221:4 |
> | parse_key_constraint_extension | ssh-agent.c:1235:23:1238:3 |
> | parse_key_constraint_extension | ssh-agent.c:1246:40:1249:4 |
> | input_userauth_pk_ok | sshconnect2.c:700:61:703:2 |
> | input_userauth_pk_ok | sshconnect2.c:708:27:713:2 |
> | input_userauth_pk_ok | sshconnect2.c:726:28:732:2 |
> | cert_filter_principals | sshsig.c:875:61:878:2 |
> ------------------------------------------------------------------------
>
> For example, the missing resets of the return value r at lines
> 1209-1212, 1218-1221, 1235-1238, and 1246-1249 in ssh-agent.c could (in
> theory at least) result in parse_key_constraint_extension() returning
> success (because, to reach these lines, r was necessarily set to zero,
> at line 1187 for example), but without actually constraining the key
> that is being added to the ssh-agent:
>
> ------------------------------------------------------------------------
> 1176 static int
> 1177 parse_key_constraint_extension(struct sshbuf *m, char **sk_providerp,
> 1178 struct dest_constraint **dcsp, size_t *ndcsp, int *cert_onlyp,
> 1179 struct sshkey ***certs, size_t *ncerts)
> 1180 {
> ....
> 1187 if ((r = sshbuf_get_cstring(m, &ext_name, NULL)) != 0) {
> 1188 error_fr(r, "parse constraint extension");
> 1189 goto out;
> 1190 }
> ....
> 1209 if (*dcsp != NULL) {
> 1210 error_f("%s already set", ext_name);
> 1211 goto out;
> 1212 }
> ....
> 1218 if (*ndcsp >= AGENT_MAX_DEST_CONSTRAINTS) {
> 1219 error_f("too many %s constraints", ext_name);
> 1220 goto out;
> 1221 }
> ....
> 1235 if (*certs != NULL) {
> 1236 error_f("%s already set", ext_name);
> 1237 goto out;
> 1238 }
> ....
> 1246 if (*ncerts >= AGENT_MAX_EXT_CERTS) {
> 1247 error_f("too many %s constraints", ext_name);
> 1248 goto out;
> 1249 }
> ....
> 1265 out:
> ....
> 1268 return r;
> 1269 }
> ------------------------------------------------------------------------
>
>
> ========================================================================
> MitM attack against OpenSSH's VerifyHostKeyDNS-enabled client
> ========================================================================
>
> Our manual audit (of all the functions that use "goto") allowed us to
> verify that our CodeQL query does not produce false negatives (which
> would be worse than false positives), but it also allowed us to review
> code that is similar but not identical to the idiom presented in the
> "Background" section.
>
> In OpenSSH's client, the following code, which checks the server's
> identity (the server's host key), naturally caught our attention:
>
> ------------------------------------------------------------------------
> 93 static int
> 94 verify_host_key_callback(struct sshkey *hostkey, struct ssh *ssh)
> 95 {
> ...
> 101 if (verify_host_key(xxx_host, xxx_hostaddr, hostkey,
> 102 xxx_conn_info) == -1)
> 103 fatal("Host key verification failed.");
> 104 return 0;
> 105 }
> ------------------------------------------------------------------------
> 1470 int
> 1471 verify_host_key(char *host, struct sockaddr *hostaddr, struct sshkey *host_key,
> 1472 const struct ssh_conn_info *cinfo)
> 1473 {
> ....
> 1538 if (options.verify_host_key_dns) {
> ....
> 1543 if ((r = sshkey_from_private(host_key, &plain)) != 0)
> 1544 goto out;
> ....
> 1571 out:
> ....
> 1580 return r;
> 1581 }
> ------------------------------------------------------------------------
>
> - in verify_host_key() (when VerifyHostKeyDNS is enabled, at line 1538),
> if sshkey_from_private() returns any non-zero error code (at line
> 1543), then this error code is immediately returned to
> verify_host_key()'s caller (at line 1580);
>
> - unfortunately, in verify_host_key_callback() (verify_host_key()'s
> caller), only the return value -1 is treated as an error (at lines
> 101-103);
>
> - any other return value (for example, -2) is ignored, and zero
> (success) is mistakenly returned to verify_host_key_callback()'s
> caller (at line 104), as if no error had occurred, and without
> checking the server's host key at all.
>
> The question, then, is: to impersonate this server, how can an active
> machine-in-the-middle force the client to return an error code other
> than -1 (SSH_ERR_INTERNAL_ERROR) at line 1543?
>
> In theory, sshkey_from_private() can return many different error codes:
> -10 (SSH_ERR_INVALID_ARGUMENT), -14 (SSH_ERR_KEY_TYPE_UNKNOWN), etc. In
> practice, however, the host-key structure that is copied at line 1543
> was originally created by sshkey_fromb(), which would have fatally
> failed already if the server's host key were malformed.
>
> The only error code that we were able to eventually force out of
> sshkey_from_private() is -2 (SSH_ERR_ALLOC_FAIL), an out-of-memory
> error. (If you, dear reader, find another solution to this problem,
> please post it to the public oss-security mailing list!) Consequently,
> to carry out this machine-in-the-middle attack in practice (to
> successfully impersonate the real server), we must:
>
> - make our fake server's host key as large as possible, to maximize our
> chances of exhausting the client's memory inside sshkey_from_private()
> at line 1543 -- but we are limited to ~256KB (PACKET_MAX_SIZE) because
> packet compression is not supported before the end of the initial key
> exchange (not even in the client);
>
> - find a memory leak in the client's code (or at least an unlimited
> allocation of memory that is not freed before the end of the initial
> key exchange), to consume as much of the client's memory as possible
> before the call to sshkey_from_private() at line 1543.
>
> And so began our quest for a pre-authentication memory leak in OpenSSH's
> client.
>
>
> ========================================================================
> DoS attack against OpenSSH's client and server (memory consumption)
> ========================================================================
>
> As yet another testimony to OpenSSH's code quality, we actually failed
> to find a pre-authentication memory leak. Instead, we found an unlimited
> allocation of memory that is not freed until the very end of the initial
> key exchange:
>
> ------------------------------------------------------------------------
> 1796 ssh_packet_read_poll_seqnr(struct ssh *ssh, u_char *typep, u_int32_t *seqnr_p)
> 1797 {
> ....
> 1863 case SSH2_MSG_PING:
> 1864 if ((r = sshpkt_get_string_direct(ssh, &d, &len)) != 0)
> 1865 return r;
> 1866 DBG(debug("Received SSH2_MSG_PING len %zu", len));
> 1867 if ((r = sshpkt_start(ssh, SSH2_MSG_PONG)) != 0 ||
> 1868 (r = sshpkt_put_string(ssh, d, len)) != 0 ||
> 1869 (r = sshpkt_send(ssh)) != 0)
> 1870 return r;
> 1871 break;
> ------------------------------------------------------------------------
> 2827 sshpkt_send(struct ssh *ssh)
> 2828 {
> ....
> 2831 return ssh_packet_send2(ssh);
> ------------------------------------------------------------------------
> 1343 ssh_packet_send2(struct ssh *ssh)
> 1344 {
> ....
> 1360 if ((need_rekey || state->rekeying) && !ssh_packet_type_is_kex(type)) {
> ....
> 1368 p->payload = state->outgoing_packet;
> 1369 TAILQ_INSERT_TAIL(&state->outgoing, p, next);
> 1370 state->outgoing_packet = sshbuf_new();
> ....
> 1381 return 0;
> 1382 }
> ....
> 1388 if ((r = ssh_packet_send2_wrapped(ssh)) != 0)
> ------------------------------------------------------------------------
> 1163 ssh_packet_send2_wrapped(struct ssh *ssh)
> 1164 {
> ....
> 1276 if ((r = sshbuf_reserve(state->output,
> 1277 sshbuf_len(state->outgoing_packet) + authlen, &cp)) != 0)
> ------------------------------------------------------------------------
>
> - every time a PING packet is received (at lines 1863-1864), a PONG
> packet is produced (at lines 1867-1868) and buffered (at line 1869),
> but not immediately sent out (because ssh_packet_write_wait() is not
> called explicitly);
>
> - outside a key exchange (at line 1388), such a PONG packet (the
> "outgoing_packet") is simply appended to the "output" buffer (at lines
> 1276-1277): the memory allocated for these PONG packets is limited,
> because the number of PONG packets that can be buffered is limited by
> the maximum size of the "output" buffer, 128MB (SSHBUF_SIZE_MAX);
>
> - on the other hand, during a key exchange (at lines 1360-1382), such a
> PONG packet (the current "outgoing_packet") is appended to a *list* of
> outgoing packets (at lines 1368-1369), and a new "outgoing_packet" is
> allocated (at line 1370): the memory allocated for these PONG packets
> is unlimited, because the number of PONG packets that can be buffered
> is unlimited.
>
> This uncontrolled consumption of memory affects both the client and the
> server, and is also asymmetrical: for every 16B PING packet received, a
> PONG buffer of 256B (SSHBUF_SIZE_INIT) is allocated, and not freed until
> the very end of the key exchange. On the server side, this vulnerability
> is mitigated by the default LoginGraceTime: after 2 minutes, any memory-
> consuming connection is automatically killed by SIGALRM; however, since
> 100 concurrent connections are allowed by default, the MaxStartups and
> PerSourcePenalties options are also needed for a full mitigation.
>
> On the client side, no mitigations exist for this vulnerability. In
> fact, it is ideal for our machine-in-the-middle attack: we use it to
> force the client to run out of memory inside sshkey_from_private(), and
> as soon as the initial key exchange with our fake server ends (and the
> real server is impersonated), all the allocated memory is freed, which
> guarantees that the client does not run out of memory later during the
> lifetime of its connection with our fake server.
>
> We will demonstrate our machine-in-the-middle attack in the "Proof of
> concept" section below, but we must first discuss a second, unforeseen
> aspect of this vulnerability: an asymmetric resource consumption of CPU.
>
>
> ========================================================================
> DoS attack against OpenSSH's client and server (CPU consumption)
> ========================================================================
>
> As soon as the initial key exchange ends (at lines 1392-1416), every
> PONG packet that was buffered (not sent out) is removed from the list of
> outgoing packets (at lines 1395-1410) and re-buffered (at line 1413), by
> appending it to the "output" buffer (at lines 1276-1277). Unfortunately,
> this re-buffering has a quadratic time complexity (O(n^2)): for each and
> every PONG packet of 256B (SSHBUF_SIZE_INC, at line 352), a new "output"
> buffer is malloc()ated (at line 73), and the entire contents of the old
> buffer (the already re-buffered PONG packets) are copied into the new
> buffer (at line 78):
>
> ------------------------------------------------------------------------
> 1343 ssh_packet_send2(struct ssh *ssh)
> 1344 {
> ....
> 1392 if (type == SSH2_MSG_NEWKEYS) {
> ....
> 1395 while ((p = TAILQ_FIRST(&state->outgoing))) {
> ....
> 1409 state->outgoing_packet = p->payload;
> 1410 TAILQ_REMOVE(&state->outgoing, p, next);
> ....
> 1413 if ((r = ssh_packet_send2_wrapped(ssh)) != 0)
> 1414 return r;
> 1415 }
> 1416 }
> ------------------------------------------------------------------------
> 1163 ssh_packet_send2_wrapped(struct ssh *ssh)
> 1164 {
> ....
> 1276 if ((r = sshbuf_reserve(state->output,
> 1277 sshbuf_len(state->outgoing_packet) + authlen, &cp)) != 0)
> ------------------------------------------------------------------------
> 372 sshbuf_reserve(struct sshbuf *buf, size_t len, u_char **dpp)
> 373 {
> ...
> 381 if ((r = sshbuf_allocate(buf, len)) != 0)
> ------------------------------------------------------------------------
> 329 sshbuf_allocate(struct sshbuf *buf, size_t len)
> 330 {
> ...
> 352 rlen = ROUNDUP(buf->alloc + need, SSHBUF_SIZE_INC);
> ...
> 357 if ((dp = recallocarray(buf->d, buf->alloc, rlen, 1)) == NULL) {
> ------------------------------------------------------------------------
> 38 recallocarray(void *ptr, size_t oldnmemb, size_t newnmemb, size_t size)
> 39 {
> ..
> 73 newptr = malloc(newsize);
> ..
> 78 memcpy(newptr, ptr, oldsize);
> ------------------------------------------------------------------------
>
> For example, if we send 128MB (SSHBUF_SIZE_MAX) of PING packets (i.e.,
> 128MB / 256B = 2^19 packets), then the re-buffering of the corresponding
> PONG packets would in theory end up copying 32TB in total (approximately
> 256B / 2 * (2^19)^2 = 2^45 bytes).
>
> In practice, if we send roughly 16MB of PING packets to the server
> (i.e., 16MB / 256B = 2^16 packets), and directly disconnect from the
> server, then we leave the server CPU spinning at 100% for 2 minutes (the
> default LoginGraceTime). Once again, because 100 concurrent connections
> are allowed by default, the MaxStartups and PerSourcePenalties options
> are also needed for a full mitigation.
>
>
> ========================================================================
> Proof of concept
> ========================================================================
>
> In the following example, "client" is running a VerifyHostKeyDNS-enabled
> OpenSSH client (version 9.6p1, from Ubuntu 24.04) whose available memory
> is limited to 256MB by an RLIMIT_DATA resource limit. "client" is trying
> to connect to the SSH server "real-server", but it is currently under an
> active machine-in-the-middle attack carried out by a "fake-server".
>
> If this "fake-server" tries to impersonate the "real-server" without
> implementing the out-of-memory attack discussed in the three previous
> sections, then "client" immediately detects that "real-server"s host key
> has changed and aborts:
>
> ------------------------------------------------------------------------
> client$ ulimit -S -a
> ...
> data seg size (kbytes, -d) 262144
> ...
>
> client$ /usr/bin/ssh -o VerifyHostKeyDNS=yes john@...l-server
> @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
> @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
> @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
> IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
> Someone could be eavesdropping on you right now (man-in-the-middle attack)!
> ...
> Host key for real-server has changed and you have requested strict checking.
> Host key verification failed.
> ------------------------------------------------------------------------
>
> However, if "fake-server" does implement the out-of-memory attack
> discussed previously (by allocating a 1024-bit RSA key and a ~140KB
> certificate extension, plus ~234MB of PONG packets -- but many other
> combinations work), then the "client"s call to sshkey_from_private()
> returns SSH_ERR_ALLOC_FAIL, and its checks of the "real-server"s host
> key are completely bypassed, thus allowing the "fake-server" to
> successfully impersonate the "real-server":
>
> ------------------------------------------------------------------------
> fake-server# /usr/local/sbin/sshd -o KexAlgorithms=curve25519-sha256 -h /root/exploit_234925474_140877_1024_ssh-rsa
> ------------------------------------------------------------------------
> client$ /usr/bin/ssh -o VerifyHostKeyDNS=yes john@...l-server
> john@...l-server's password:
> fake-server$
> ------------------------------------------------------------------------
>
> Side note: this password prompt takes longer than usual to be displayed,
> because of the quadratic re-buffering of the numerous PONG packets.
>
>
> ========================================================================
> Acknowledgments
> ========================================================================
>
> We thank the OpenSSH developers (in particular Markus Friedl, Damien
> Miller, and Theo de Raadt) for their outstanding work on this release
> and on OpenSSH in general. We also thank the members of distros@...nwall
> (in particular Salvatore Bonaccorso, Marco Benatto, and Solar Designer).
>
> Finally, we thank Bad Sector Labs for their kind words about our work
> and for their excellent "Last Week in Security" blog posts:
>
> https://blog.badsectorlabs.com/last-week-in-security-lwis-2024-11-25.html
>
>
> ========================================================================
> Timeline
> ========================================================================
>
> 2025-01-31: Advisory and proofs of concepts sent to openssh@...nssh.
>
> 2025-02-10: Advisory and patches sent to distros@...nwall.
>
> 2025-02-18: Coordinated release.
> _______________________________________________
> Sent through the Full Disclosure mailing list
> https://nmap.org/mailman/listinfo/fulldisclosure
> Web Archives & RSS: https://seclists.org/fulldisclosure/
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/
Powered by blists - more mailing lists