CVE-2023-28319: UAF in SSH sha256 fingerprint check
What the report claims
The report claims that curl has a use-after-free vulnerability in the SSH fingerprint check function where a pointer is used after being freed, leading to potential crashes or information leaks.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2023-28319 UAF in SSH sha256 fingerprint check
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code supports the reported flaw. A maintainer should review its impact and decide what to do next.
Why this finding?
- Code at lib/vssh/libssh2.c lines 731-735 shows a clear use-after-free: fingerprint_b64 is freed then passed to failf()
- The bug is reachable by an attacker controlling the SSH server's hostkey or the client's expected fingerprint configuration
- Impact includes potential crash (denial of service) or information leak via the error log
- No project policy excludes this class of bug
How this analysis ran 5 claims · 10 citations verified
Checked against curl tested commit a51f90ec0f74 (a51f90ec0f74). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Review a draft reply to the reporter
Edit and verify before sending.
Thank you for the report. The use-after-free at lib/vssh/libssh2.c:731-735 is confirmed: fingerprint_b64 is freed then used in the failf() call. This is reachable when the SHA256 fingerprint mismatch check fails, which an attacker can trigger via the server's hostkey or the configured expected fingerprint. The fix should save the fingerprint string before freeing it, or reorder the free after the log call.
Inspect all claims and source code 5 claims · 2 code excerpts
Report claims
The pointer fingerprint_b64 is used as a parameter to failf() after it has been freed
Read the reasoning
The code at lines 731-735 shows fingerprint_b64 is freed at line 731 and then used as a format argument to failf() at line 735, confirming the use-after-free.
In curl/lib/vssh/libssh2.c, function ssh_check_fingerprint, lines 731-735
Read the reasoning
The location matches exactly: ssh_check_fingerprint function in lib/vssh/libssh2.c at lines 731-735.
When a SSH server sends a malformed fingerprint or when fingerprint comparison fails
Read the reasoning
The bug triggers when the SSH hostkey fingerprint comparison fails (pub_pos != b64_pos or strncmp mismatch), which an attacker can influence by controlling the server's hostkey or the configured expected fingerprint.
Potential application crash or information leak through fail log exposing freed memory contents
Read the reasoning
Use-after-free can cause crash (DoS) or information leak via the fail log when failf() dereferences the freed pointer to format the error message.
Clone curl repository, modify lib/vssh/libssh2.c to free fingerprint_b64 and then use it in failf()
Read the reasoning
The bug is reproducible by configuring an expected SHA256 fingerprint that doesn't match the server's actual hostkey, causing the mismatch branch to execute.
Read the original report
Source code
| 653 | static CURLcode ssh_check_fingerprint(struct Curl_easy *data) |
| C2: Function ssh_check_fingerprint starts here | |
| 654 | { |
| 655 | struct connectdata *conn = data->conn; |
| 656 | struct ssh_conn *sshc = &conn->proto.sshc; |
| 657 | const char *pubkey_md5 = data->set.str[STRING_SSH_HOST_PUBLIC_KEY_MD5]; |
| 658 | const char *pubkey_sha256 = data->set.str[STRING_SSH_HOST_PUBLIC_KEY_SHA256]; |
| 659 | |
| 660 | infof(data, "SSH MD5 public key: %s", |
| 661 | pubkey_md5 != NULL ? pubkey_md5 : "NULL"); |
| 662 | infof(data, "SSH SHA256 public key: %s", |
| 663 | pubkey_sha256 != NULL ? pubkey_sha256 : "NULL"); |
| 664 | |
| 665 | if(pubkey_sha256) { |
| 666 | const char *fingerprint = NULL; |
| 667 | char *fingerprint_b64 = NULL; |
| 668 | size_t fingerprint_b64_len; |
| 669 | size_t pub_pos = 0; |
| 670 | size_t b64_pos = 0; |
| 671 | |
| 672 | #ifdef LIBSSH2_HOSTKEY_HASH_SHA256 |
| 673 | /* The fingerprint points to static storage (!), don't free() it. */ |
| 674 | fingerprint = libssh2_hostkey_hash(sshc->ssh_session, |
| 675 | LIBSSH2_HOSTKEY_HASH_SHA256); |
| 676 | #else |
| 677 | const char *hostkey; |
| 678 | size_t len = 0; |
| 679 | unsigned char hash[32]; |
| 680 | |
| 681 | hostkey = libssh2_session_hostkey(sshc->ssh_session, &len, NULL); |
| 682 | if(hostkey) { |
| 683 | if(!Curl_sha256it(hash, (const unsigned char *) hostkey, len)) |
| 684 | fingerprint = (char *) hash; |
| 685 | } |
| 686 | #endif |
| 687 | |
| 688 | if(!fingerprint) { |
| 689 | failf(data, |
| 690 | "Denied establishing ssh session: sha256 fingerprint " |
| 691 | "not available"); |
| 692 | state(data, SSH_SESSION_FREE); |
| 693 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 694 | return sshc->actualcode; |
| 695 | } |
| 696 | |
| 697 | /* The length of fingerprint is 32 bytes for SHA256. |
| 698 | * See libssh2_hostkey_hash documentation. */ |
| 699 | if(Curl_base64_encode(fingerprint, 32, &fingerprint_b64, |
| 700 | &fingerprint_b64_len) != CURLE_OK) { |
| 701 | state(data, SSH_SESSION_FREE); |
| 702 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 703 | return sshc->actualcode; |
| 704 | } |
| 705 | |
| 706 | if(!fingerprint_b64) { |
| 707 | failf(data, "sha256 fingerprint could not be encoded"); |
| 708 | state(data, SSH_SESSION_FREE); |
| 709 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 710 | return sshc->actualcode; |
| 711 | } |
| 712 | |
| 713 | infof(data, "SSH SHA256 fingerprint: %s", fingerprint_b64); |
| 714 | |
| 715 | /* Find the position of any = padding characters in the public key */ |
| 716 | while((pubkey_sha256[pub_pos] != '=') && pubkey_sha256[pub_pos]) { |
| 717 | pub_pos++; |
| 718 | } |
| 719 | |
| 720 | /* Find the position of any = padding characters in the base64 coded |
| 721 | * hostkey fingerprint */ |
| 722 | while((fingerprint_b64[b64_pos] != '=') && fingerprint_b64[b64_pos]) { |
| 723 | b64_pos++; |
| 724 | } |
| 725 | |
| 726 | /* Before we authenticate we check the hostkey's sha256 fingerprint |
| 727 | * against a known fingerprint, if available. |
| 728 | */ |
| 729 | if((pub_pos != b64_pos) || |
| C3: Condition that triggers the buggy path: fingerprint mismatch | |
| C5: Trigger condition reachable via SSH server fingerprint mismatch | |
| 730 | strncmp(fingerprint_b64, pubkey_sha256, pub_pos)) { |
| C3: strncmp comparison failure leads to free+use | |
| 731 | free(fingerprint_b64); |
| C1: free(fingerprint_b64) called | |
| C2: UAF occurs at lines 731-735 | |
| C4: free() called on fingerprint_b64 | |
| C5: Bug reproduces when mismatch condition is met | |
| 732 | |
| 733 | failf(data, |
| 734 | "Denied establishing ssh session: mismatch sha256 fingerprint. " |
| 735 | "Remote %s is not equal to %s", fingerprint_b64, pubkey_sha256); |
| C1: fingerprint_b64 passed to failf() after free | |
| C4: freed pointer passed to failf() for logging | |
| 736 | state(data, SSH_SESSION_FREE); |
| 737 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 738 | return sshc->actualcode; |
| 739 | } |
| 740 | |
| 741 | free(fingerprint_b64); |
| 742 | |
| 743 | infof(data, "SHA256 checksum match"); |
| 744 | } |
| 745 | |
| 746 | if(pubkey_md5) { |
| 747 | char md5buffer[33]; |
| 748 | const char *fingerprint = NULL; |
| 749 | |
| 750 | fingerprint = libssh2_hostkey_hash(sshc->ssh_session, |
| 751 | LIBSSH2_HOSTKEY_HASH_MD5); |
| 752 | |
| 753 | if(fingerprint) { |
| 754 | /* The fingerprint points to static storage (!), don't free() it. */ |
| 755 | int i; |
| 756 | for(i = 0; i < 16; i++) { |
| 757 | msnprintf(&md5buffer[i*2], 3, "%02x", (unsigned char) fingerprint[i]); |
| 758 | } |
| 759 | |
| 760 | infof(data, "SSH MD5 fingerprint: %s", md5buffer); |
| 761 | } |
| 762 | |
| 763 | /* This does NOT verify the length of 'pubkey_md5' separately, which will |
| 764 | make the comparison below fail unless it is exactly 32 characters */ |
| 765 | if(!fingerprint || !strcasecompare(md5buffer, pubkey_md5)) { |
| 766 | if(fingerprint) { |
| 767 | failf(data, |
| 768 | "Denied establishing ssh session: mismatch md5 fingerprint. " |
| 769 | "Remote %s is not equal to %s", md5buffer, pubkey_md5); |
| 770 | } |
| 771 | else { |
| 772 | failf(data, |
| 773 | "Denied establishing ssh session: md5 fingerprint " |
| 774 | "not available"); |
| 775 | } |
| 776 | state(data, SSH_SESSION_FREE); |
| 777 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 778 | return sshc->actualcode; |
| 779 | } |
| 780 | infof(data, "MD5 checksum match"); |
| 781 | } |
| 782 | |
| 783 | if(!pubkey_md5 && !pubkey_sha256) { |
| 784 | if(data->set.ssh_hostkeyfunc) { |
| 785 | size_t keylen = 0; |
| 786 | int sshkeytype = 0; |
| 787 | int rc = 0; |
| 788 | /* we handle the process to the callback */ |
| 789 | const char *remotekey = libssh2_session_hostkey(sshc->ssh_session, |
| 790 | &keylen, &sshkeytype); |
| 791 | if(remotekey) { |
| 792 | int keytype = convert_ssh2_keytype(sshkeytype); |
| 793 | Curl_set_in_callback(data, true); |
| 794 | rc = data->set.ssh_hostkeyfunc(data->set.ssh_hostkeyfunc_userp, |
| 795 | keytype, remotekey, keylen); |
| 796 | Curl_set_in_callback(data, false); |
| 797 | if(rc!= CURLKHMATCH_OK) { |
| 798 | state(data, SSH_SESSION_FREE); |
| 799 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 800 | return sshc->actualcode; |
| 801 | } |
| 802 | } |
| 803 | else { |
| 804 | state(data, SSH_SESSION_FREE); |
| 805 | sshc->actualcode = CURLE_PEER_FAILED_VERIFICATION; |
| 806 | return sshc->actualcode; |
| 807 | } |
| 808 | return CURLE_OK; |
| 809 | } |
| 810 | else { |
| 811 | return ssh_knownhost(data); |
| 812 | } |
| 813 | } |
| 55 | #define failf Curl_failf |