CVE-2026-13608: OpenLDAP SASL authentication bypass
What the report claims
A malicious LDAP server can bypass SASL authentication in libcurl's OpenLDAP backend by sending a malformed SASL challenge, causing the client to proceed as if authentication succeeded and allowing injection of arbitrary LDAP results.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-13608 OpenLDAP SASL authentication bypass
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?
- The condition `progress != SASL_INPROGRESS` at lib/openldap.c:786 does not distinguish SASL_DONE from SASL_IDLE, confirmed by code inspection.
- SASL_CANCEL path (lib/curl_sasl.c:773-780) removes the failed mechanism and calls Curl_sasl_start, which returns CURLE_OK with progress=SASL_IDLE when no mechanisms remain (lib/curl_sasl.c:519).
- This allows a malicious server to send a malformed challenge, trigger cancellation of the only mechanism (GSSAPI), and have the client treat SASL_IDLE as successful authentication, transitioning to OLDAP_STOP.
- The vulnerability bypasses Kerberos mutual authentication — the client proceeds without verifying the server holds a valid keytab, enabling injection of arbitrary LDAP responses.
- All cited symbols and code paths exist at the reported revision (68720b483728). The reproduction steps are concrete and match the code flow.
- This is not covered by any 'not security issues' policy section: it is a practical authentication bypass reachable by a network attacker, not a local-only issue, not API misuse, not debug code, and not a non-released feature.
How this analysis ran 6 claims · 16 citations verified
Checked against curl permalink 68720b483728 (68720b483728). 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 detailed report. The vulnerability is confirmed: lib/openldap.c:786 treats SASL_IDLE the same as SASL_DONE, allowing a malicious server to cancel the only SASL mechanism (GSSAPI) and have the client proceed as authenticated. The fix should check `progress == SASL_DONE` explicitly and return CURLE_LOGIN_DENIED for SASL_IDLE. This affects all OpenLDAP+GSSAPI builds. We will prepare a patch.
Inspect all claims and source code 6 claims · 6 code excerpts
Report claims
The condition `progress != SASL_INPROGRESS` does not distinguish `SASL_DONE` from `SASL_IDLE`, allowing a malformed SASL challenge to be treated as successful authentication.
Read the reasoning
The code confirms the condition does not distinguish SASL_DONE from SASL_IDLE. When SASL_CANCEL triggers and no mechanisms remain, Curl_sasl_start returns CURLE_OK with progress=SASL_IDLE, which satisfies progress != SASL_INPROGRESS and causes transition to OLDAP_STOP.
The flaw occurs in `lib/openldap.c` at line 786 in the `oldap_state_sasl_resp()` function.
Read the reasoning
The flaw location is correctly identified in lib/openldap.c at line 786 inside oldap_state_sasl_resp().
A malicious LDAP server sends a malformed SASL challenge that causes `Curl_sasl_continue()` to return `CURLE_OK` with `progress == SASL_IDLE` after a `SASL_CANCEL` state.
Read the reasoning
The trigger path is confirmed: malformed challenge → CURLE_BAD_CONTENT_ENCODING → SASL_CANCEL → mechanism removed → Curl_sasl_start called with no mechanisms → returns CURLE_OK with progress=SASL_IDLE.
The client proceeds as if authentication succeeded, allowing the attacker to inject arbitrary LDAP results such as forged user entries or credentials.
Read the reasoning
When the condition passes, oldap_state transitions to OLDAP_STOP, marking the LDAP connection as authenticated and ready for queries, allowing attacker-injected results to be accepted.
The reproduction involves building curl with OpenLDAP and GSSAPI support, running a mock LDAP server that triggers `SASL_CANCEL`, and connecting with `curl --user "testuser@TEST.LOCAL:testpass" --login-options "AUTH=GSSAPI" ldap://localhost:3893/...`.
Read the reasoning
The reproduction steps align with the code path: OpenLDAP+GSSAPI build, mock server triggering SASL_CANCEL via malformed GSSAPI token, and curl command using AUTH=GSSAPI. The deterministic checks confirm all cited symbols exist.
The bypass nullifies mutual Kerberos authentication, enabling an attacker without a valid keytab to impersonate a server and inject arbitrary LDAP responses.
Read the reasoning
The bypass nullifies mutual Kerberos authentication: the failed GSSAPI mechanism is cancelled and removed, no mechanisms remain, SASL_IDLE is returned, and the client proceeds to OLDAP_STOP without verifying the server holds a valid keytab.
What the report says about other projects
confirmed Curl_sasl_continue() returns CURLE_OK with progress SASL_IDLE when no mechanisms remain.
“Internet-Draft SAML ECP SASL & GSS-API Mechanisms July 2019” source
confirmed SASL ECP and GSS-API mechanisms are defined for LDAP authentication.
“Internet-Draft SAML ECP SASL & GSS-API Mechanisms July 2019” source
not addressed The report's description of SASL_CANCEL behavior matches the SASL protocol specification.
Read the original report (excerpt)
Source code
| 761 | break; |
| 762 | } |
| 763 | return result; |
| C4: OLDAP_STOP indicates connection ready state | |
| 764 | } |
| 765 | |
| 766 | /* Handle a SASL bind response. */ |
| 767 | static CURLcode oldap_state_sasl_resp(struct Curl_easy *data, |
| C2: Function oldap_state_sasl_resp starts at line 767 | |
| 768 | LDAPMessage *msg, int code) |
| 769 | { |
| 770 | struct connectdata *conn = data->conn; |
| 771 | struct ldapconninfo *li = Curl_conn_meta_get(conn, CURL_META_LDAP_CONN); |
| 772 | CURLcode result = CURLE_OK; |
| 773 | saslprogress progress; |
| 774 | int rc; |
| 775 | |
| 776 | if(!li) |
| 777 | return CURLE_FAILED_INIT; |
| 778 | li->servercred = NULL; |
| 779 | rc = ldap_parse_sasl_bind_result(li->ld, msg, &li->servercred, 0); |
| 780 | if(rc != LDAP_SUCCESS) { |
| 781 | failf(data, "LDAP local: sasl ldap_parse_result %s", ldap_err2string(rc)); |
| 782 | result = oldap_map_error(rc, CURLE_LOGIN_DENIED); |
| 783 | } |
| 784 | else { |
| 785 | result = Curl_sasl_continue(&li->sasl, data, code, &progress); |
| 786 | if(!result && progress != SASL_INPROGRESS) |
| C1: Condition `progress != SASL_INPROGRESS` at line 786 evaluates true for both SASL_DONE and SASL_IDLE | |
| C2: The cited condition is at line 786 within oldap_state_sasl_resp | |
| C5: Vulnerable condition at line 786 | |
| 787 | oldap_state(data, li, OLDAP_STOP); |
| C4: oldap_state(data, li, OLDAP_STOP) called at line 787 when condition passes | |
| C6: OLDAP_STOP reached without successful authentication at line 787 | |
| 788 | } |
| 789 | |
| 790 | if(li->servercred) |
| 791 | ber_bvfree(li->servercred); |
| 792 | return result; |
| 793 | } |
| 794 | |
| 795 | /* Handle a simple bind response. */ |
| 796 | static CURLcode oldap_state_bind_resp(struct Curl_easy *data, LDAPMessage *msg, |
| 797 | int code) |
| 798 | { |
| 799 | struct connectdata *conn = data->conn; |
| 800 | struct ldapconninfo *li = Curl_conn_meta_get(conn, CURL_META_LDAP_CONN); |
| 801 | CURLcode result = CURLE_OK; |
| 802 | struct berval *bv = NULL; |
| 803 | int rc; |
| 804 | |
| 805 | if(!li) |
| 806 | return CURLE_FAILED_INIT; |
| 807 | |
| 808 | if(code != LDAP_SUCCESS) |
| 809 | return oldap_map_error(code, CURLE_LDAP_CANNOT_BIND); |
| 810 | |
| 811 | rc = ldap_parse_sasl_bind_result(li->ld, msg, &bv, 0); |
| 812 | if(rc != LDAP_SUCCESS) { |
| 494 | sctx->mech = SASL_MECH_STRING_LOGIN; |
| 495 | sctx->state1 = SASL_LOGIN; |
| 496 | sctx->state2 = SASL_LOGIN_PASSWD; |
| 497 | sctx->sasl->authused = SASL_MECH_LOGIN; |
| 498 | |
| 499 | if(sctx->sasl->force_ir || data->set.sasl_ir) |
| 500 | Curl_auth_create_login_message( |
| 501 | Curl_creds_user(sctx->conn->creds), &sctx->resp); |
| 502 | return TRUE; |
| 503 | } |
| 504 | return FALSE; |
| 505 | } |
| 506 | |
| 507 | /* |
| 508 | * Curl_sasl_start() |
| 509 | * |
| 510 | * Calculate the required login details for SASL authentication. |
| 511 | */ |
| 512 | CURLcode Curl_sasl_start(struct SASL *sasl, struct Curl_easy *data, |
| 513 | bool force_ir, saslprogress *progress) |
| 514 | { |
| 515 | struct sasl_ctx sctx; |
| 516 | |
| 517 | sasl->force_ir = force_ir; /* Latch for future use */ |
| 518 | sasl->authused = 0; /* No mechanism used yet */ |
| 519 | *progress = SASL_IDLE; |
| C1: Curl_sasl_start sets *progress = SASL_IDLE at line 519 when initializing | |
| C3: Curl_sasl_start sets progress = SASL_IDLE at line 519 when no mechanisms remain | |
| C5: SASL_IDLE initialization at line 519 | |
| C6: SASL_IDLE set when no mechanisms available at line 519 | |
| 520 | |
| 521 | memset(&sctx, 0, sizeof(sctx)); |
| 522 | sctx.sasl = sasl; |
| 523 | sctx.conn = data->conn; |
| 524 | Curl_bufref_init(&sctx.resp); |
| 525 | sctx.enabledmechs = sasl->authmechs & sasl->prefmech; |
| 526 | sctx.state1 = SASL_STOP; |
| 527 | sctx.state2 = SASL_FINAL; |
| 528 | |
| 529 | /* Calculate the supported authentication mechanism, by decreasing order of |
| 530 | security, as well as the initial response where appropriate */ |
| 531 | if(sasl_choose_external(data, &sctx) || |
| 532 | #ifdef USE_KERBEROS5 |
| 533 | sasl_choose_krb5(data, &sctx) || |
| 534 | #endif |
| 535 | #ifdef USE_GSASL |
| 536 | sasl_choose_gsasl(data, &sctx) || |
| 537 | #endif |
| 538 | #ifndef CURL_DISABLE_DIGEST_AUTH |
| 539 | sasl_choose_digest(data, &sctx) || |
| 540 | #endif |
| 541 | #ifdef USE_NTLM |
| 542 | sasl_choose_ntlm(data, &sctx) || |
| 543 | #endif |
| 544 | sasl_choose_oauth(data, &sctx) || |
| 545 | sasl_choose_oauth2(data, &sctx) || |
| 546 | sasl_choose_plain(data, &sctx) || |
| 547 | sasl_choose_login(data, &sctx)) { |
| 548 | /* selected, either we have a mechanism or a failure */ |
| 549 | DEBUGASSERT(sctx.mech || sctx.result); |
| 550 | } |
| 551 | |
| 552 | if(!sctx.result && sctx.mech) { |
| 553 | sasl->curmech = sctx.mech; |
| 554 | if(Curl_bufref_ptr(&sctx.resp)) |
| 555 | sctx.result = build_message(sasl, &sctx.resp); |
| 556 | |
| 557 | if(sasl->params->maxirlen && |
| 558 | strlen(sctx.mech) + Curl_bufref_len(&sctx.resp) > |
| 559 | sasl->params->maxirlen) |
| 560 | Curl_bufref_free(&sctx.resp); |
| 561 | |
| 562 | if(!sctx.result) |
| 563 | sctx.result = sasl->params->sendauth(data, sctx.mech, &sctx.resp); |
| 564 | |
| 565 | if(!sctx.result) { |
| 566 | *progress = SASL_INPROGRESS; |
| 567 | sasl_state(sasl, data, Curl_bufref_ptr(&sctx.resp) ? |
| 568 | sctx.state2 : sctx.state1); |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | Curl_bufref_free(&sctx.resp); |
| 573 | return sctx.result; |
| 574 | } |
| 748 | } |
| 749 | else |
| 750 | result = Curl_auth_create_xoauth_bearer_message(conn->creds, |
| 751 | &resp); |
| 752 | break; |
| 753 | |
| 754 | case SASL_OAUTH2_RESP: |
| 755 | /* The continuation is optional so check the response code */ |
| 756 | if(code == sasl->params->finalcode) { |
| 757 | /* Final response was received so we are done */ |
| 758 | *progress = SASL_DONE; |
| 759 | sasl_state(sasl, data, SASL_STOP); |
| 760 | return result; |
| 761 | } |
| 762 | else if(code == sasl->params->contcode) { |
| 763 | /* Acknowledge the continuation by sending a 0x01 response. */ |
| 764 | Curl_bufref_set(&resp, "\x01", 1, NULL); |
| 765 | break; |
| 766 | } |
| 767 | else { |
| 768 | *progress = SASL_DONE; |
| 769 | sasl_state(sasl, data, SASL_STOP); |
| 770 | return CURLE_LOGIN_DENIED; |
| 771 | } |
| 772 | |
| 773 | case SASL_CANCEL: |
| C1: SASL_CANCEL case at line 773-780 removes failed mechanism and calls Curl_sasl_start | |
| C3: SASL_CANCEL case removes mechanism and calls Curl_sasl_start at line 773-780 | |
| C5: SASL_CANCEL mechanism at line 773-780 | |
| 774 | /* Remove the offending mechanism from the supported list */ |
| 775 | sasl->authmechs &= (unsigned short)~sasl->authused; |
| C6: Mechanism removed from authmechs at line 775 | |
| 776 | sasl->authused = SASL_AUTH_NONE; |
| 777 | sasl->curmech = NULL; |
| 778 | |
| 779 | /* Start an alternative SASL authentication */ |
| 780 | return Curl_sasl_start(sasl, data, (bool)sasl->force_ir, progress); |
| 781 | default: |
| 782 | failf(data, "Unsupported SASL authentication mechanism"); |
| 783 | result = CURLE_UNSUPPORTED_PROTOCOL; /* Should not happen */ |
| 784 | break; |
| 785 | } |
| 786 | |
| 787 | Curl_bufref_free(&serverdata); |
| 788 | |
| 789 | switch(result) { |
| 790 | case CURLE_BAD_CONTENT_ENCODING: |
| 791 | /* Cancel dialog */ |
| C3: CURLE_BAD_CONTENT_ENCODING triggers SASL_CANCEL at line 791-793 | |
| 792 | result = sasl->params->cancelauth(data, sasl->curmech); |
| 793 | newstate = SASL_CANCEL; |
| 794 | break; |
| 795 | case CURLE_OK: |
| 796 | result = build_message(sasl, &resp); |
| 797 | if(!result) |
| 798 | result = sasl->params->contauth(data, sasl->curmech, &resp); |
| 799 | break; |
| 800 | default: |
| 801 | newstate = SASL_STOP; /* Stop on error */ |
| 802 | *progress = SASL_DONE; |
| 803 | break; |
| 804 | } |
| 805 |
Show 161 lines (not cited by the judge)
| 581 | CURLcode Curl_sasl_continue(struct SASL *sasl, struct Curl_easy *data, |
| 582 | int code, saslprogress *progress) |
| 583 | { |
| 584 | CURLcode result = CURLE_OK; |
| 585 | struct connectdata *conn = data->conn; |
| 586 | saslstate newstate = SASL_FINAL; |
| 587 | struct bufref resp; |
| 588 | const char *hostname; |
| 589 | int port; |
| 590 | struct bufref serverdata; |
| 591 | |
| 592 | Curl_conn_get_current_host(data, FIRSTSOCKET, &hostname, &port); |
| 593 | Curl_bufref_init(&serverdata); |
| 594 | Curl_bufref_init(&resp); |
| 595 | *progress = SASL_INPROGRESS; |
| 596 | |
| 597 | if(sasl->state == SASL_FINAL) { |
| 598 | if(code != sasl->params->finalcode) |
| 599 | result = CURLE_LOGIN_DENIED; |
| 600 | *progress = SASL_DONE; |
| 601 | sasl_state(sasl, data, SASL_STOP); |
| 602 | return result; |
| 603 | } |
| 604 | |
| 605 | if(sasl->state != SASL_CANCEL && sasl->state != SASL_OAUTH2_RESP && |
| 606 | code != sasl->params->contcode) { |
| 607 | *progress = SASL_DONE; |
| 608 | sasl_state(sasl, data, SASL_STOP); |
| 609 | return CURLE_LOGIN_DENIED; |
| 610 | } |
| 611 | |
| 612 | switch(sasl->state) { |
| 613 | case SASL_STOP: |
| 614 | *progress = SASL_DONE; |
| 615 | return result; |
| 616 | case SASL_PLAIN: |
| 617 | result = Curl_auth_create_plain_message(conn->creds, &resp); |
| 618 | break; |
| 619 | case SASL_LOGIN: |
| 620 | Curl_auth_create_login_message(Curl_creds_user(conn->creds), &resp); |
| 621 | newstate = SASL_LOGIN_PASSWD; |
| 622 | break; |
| 623 | case SASL_LOGIN_PASSWD: |
| 624 | Curl_auth_create_login_message(Curl_creds_passwd(conn->creds), &resp); |
| 625 | break; |
| 626 | case SASL_EXTERNAL: |
| 627 | Curl_auth_create_external_message(Curl_creds_user(conn->creds), &resp); |
| 628 | break; |
| 629 | #ifdef USE_GSASL |
| 630 | case SASL_GSASL: |
| 631 | result = get_server_message(sasl, data, &serverdata); |
| 632 | if(!result) { |
| 633 | struct gsasldata *gsasl = Curl_auth_gsasl_get(conn); |
| 634 | result = !gsasl ? CURLE_OUT_OF_MEMORY : |
| 635 | Curl_auth_gsasl_token(data, &serverdata, gsasl, &resp); |
| 636 | } |
| 637 | if(!result && Curl_bufref_len(&resp) > 0) |
| 638 | newstate = SASL_GSASL; |
| 639 | break; |
| 640 | #endif |
| 641 | #ifndef CURL_DISABLE_DIGEST_AUTH |
| 642 | case SASL_CRAMMD5: |
| 643 | result = get_server_message(sasl, data, &serverdata); |
| 644 | if(!result) |
| 645 | result = Curl_auth_create_cram_md5_message(&serverdata, conn->creds, |
| 646 | &resp); |
| 647 | break; |
| 648 | case SASL_DIGESTMD5: |
| 649 | result = get_server_message(sasl, data, &serverdata); |
| 650 | if(!result) |
| 651 | result = Curl_auth_create_digest_md5_message(data, &serverdata, |
| 652 | conn->creds, |
| 653 | sasl->params->service, |
| 654 | &resp); |
| 655 | if(!result && (sasl->params->flags & SASL_FLAG_BASE64)) |
| 656 | newstate = SASL_DIGESTMD5_RESP; |
| 657 | break; |
| 658 | case SASL_DIGESTMD5_RESP: |
| 659 | /* Keep response NULL to output an empty line. */ |
| 660 | break; |
| 661 | #endif |
| 662 | |
| 663 | #ifdef USE_NTLM |
| 664 | case SASL_NTLM: { |
| 665 | /* Create the type-1 message */ |
| 666 | struct ntlmdata *ntlm = Curl_auth_ntlm_get(conn, FALSE); |
| 667 | result = !ntlm ? CURLE_OUT_OF_MEMORY : |
| 668 | Curl_auth_create_ntlm_type1_message(data, conn->creds, |
| 669 | sasl->params->service, hostname, |
| 670 | ntlm, &resp); |
| 671 | newstate = SASL_NTLM_TYPE2MSG; |
| 672 | break; |
| 673 | } |
| 674 | case SASL_NTLM_TYPE2MSG: { |
| 675 | /* Decode the type-2 message */ |
| 676 | struct ntlmdata *ntlm = Curl_auth_ntlm_get(conn, FALSE); |
| 677 | result = !ntlm ? CURLE_OUT_OF_MEMORY : |
| 678 | get_server_message(sasl, data, &serverdata); |
| 679 | if(!result) |
| 680 | result = Curl_auth_decode_ntlm_type2_message(data, &serverdata, ntlm); |
| 681 | if(!result) |
| 682 | result = Curl_auth_create_ntlm_type3_message(data, conn->creds, |
| 683 | ntlm, &resp); |
| 684 | break; |
| 685 | } |
| 686 | #endif |
| 687 | |
| 688 | #ifdef USE_KERBEROS5 |
| 689 | case SASL_GSSAPI: { |
| 690 | struct kerberos5data *krb5 = Curl_auth_krb5_get(conn); |
| 691 | result = !krb5 ? CURLE_OUT_OF_MEMORY : |
| 692 | Curl_auth_create_gssapi_user_message(data, conn->creds, |
| 693 | sasl->params->service, |
| 694 | conn->origin->hostname, |
| 695 | (bool)sasl->mutual_auth, NULL, |
| 696 | krb5, &resp); |
| 697 | newstate = SASL_GSSAPI_TOKEN; |
| 698 | break; |
| 699 | } |
| 700 | case SASL_GSSAPI_TOKEN: |
| 701 | result = get_server_message(sasl, data, &serverdata); |
| 702 | if(!result) { |
| 703 | struct kerberos5data *krb5 = Curl_auth_krb5_get(conn); |
| 704 | if(!krb5) |
| 705 | result = CURLE_OUT_OF_MEMORY; |
| 706 | else if(sasl->mutual_auth) { |
| 707 | /* Decode the user token challenge and create the optional response |
| 708 | message */ |
| 709 | result = Curl_auth_create_gssapi_user_message(data, NULL, |
| 710 | NULL, NULL, |
| 711 | (bool)sasl->mutual_auth, |
| 712 | &serverdata, |
| 713 | krb5, &resp); |
| 714 | newstate = SASL_GSSAPI_NO_DATA; |
| 715 | } |
| 716 | else |
| 717 | /* Decode the security challenge and create the response message */ |
| 718 | result = Curl_auth_create_gssapi_security_message( |
| 719 | data, Curl_creds_sasl_authzid(conn->creds), &serverdata, |
| 720 | krb5, &resp); |
| 721 | } |
| 722 | break; |
| 723 | case SASL_GSSAPI_NO_DATA: |
| 724 | /* Decode the security challenge and create the response message */ |
| 725 | result = get_server_message(sasl, data, &serverdata); |
| 726 | if(!result) { |
| 727 | struct kerberos5data *krb5 = Curl_auth_krb5_get(conn); |
| 728 | if(!krb5) |
| 729 | result = CURLE_OUT_OF_MEMORY; |
| 730 | else |
| 731 | result = Curl_auth_create_gssapi_security_message( |
| 732 | data, Curl_creds_sasl_authzid(conn->creds), &serverdata, |
| 733 | krb5, &resp); |
| 734 | } |
| 735 | break; |
| 736 | #endif |
| 737 | |
| 738 | case SASL_OAUTH2: |
| 739 | /* Create the authorization message */ |
| 740 | if(sasl->authused == SASL_MECH_OAUTHBEARER) { |
| 741 | result = Curl_auth_create_oauth_bearer_message(conn->creds, |
Show 25 lines (not cited by the judge)
| 122 | static void oldap_state(struct Curl_easy *data, struct ldapconninfo *li, |
| 123 | ldapstate newstate) |
| 124 | { |
| 125 | #if defined(DEBUGBUILD) && defined(CURLVERBOSE) |
| 126 | /* for debug purposes */ |
| 127 | static const char * const names[] = { |
| 128 | "STOP", |
| 129 | "SSL", |
| 130 | "STARTTLS", |
| 131 | "TLS", |
| 132 | "MECHS", |
| 133 | "SASL", |
| 134 | "BIND", |
| 135 | "BINDV2", |
| 136 | /* LAST */ |
| 137 | }; |
| 138 | |
| 139 | if(li->state != newstate) |
| 140 | infof(data, "LDAP %p state change from %s to %s", |
| 141 | (void *)li, names[li->state], names[newstate]); |
| 142 | #else |
| 143 | (void)data; |
| 144 | #endif |
| 145 | li->state = newstate; |
| 146 | } |
Show 1 lines (not cited by the judge)
| 97 | #define gss_init_sec_context Curl_gss_init_sec_context_a |