XMLWordPrintable

    • Type: Incident report
    • Resolution: Unresolved
    • Priority: Trivial
    • None
    • Affects Version/s: 7.0.27
    • Component/s: None
    • None

      [BUG] LDAP bind_password becomes empty string in CUserDirectory::validateTest(), causing all LDAP logins to fail with generic "Incorrect username or password" while manual ldap_bind() with identical parameters succeeds

      Summary

      On Zabbix 7.0.27, LDAP authentication fails for every login attempt — both first-time JIT-provisioned users and pre-existing users with LDAP-based frontend access — with the generic error:

      Incorrect user name or password or account is temporarily blocked.

      Root-cause tracing (via temporary error_log() instrumentation, no functional code changes) shows that bind_password is an empty string immediately after DB::select('userdirectory_ldap', ['output' => ['bind_password'], ...]) inside CUserDirectory::validateTest(), even though:

      • The same column, read via direct SQL (SELECT bind_password FROM userdirectory_ldap), contains the correct 19-character plaintext value at all times during testing.
      • A standalone PHP script calling ldap_connect() / ldap_bind() / ldap_search() with the exact same host, port, Bind DN, Bind password (taken from the DB), Base DN, search filter, and LDAP protocol (both LDAPS/636 and plain LDAP/389 were tested) succeeds at every step: service bind, user search (returns exactly 1 entry with correct DN), and final rebind with the found DN + the real user's password.

      Because bind_password arrives empty, CLdap::initBindAttributes() falls back to BIND_ANONYMOUS (bind_type=1) instead of BIND_CONFIG_CREDENTIALS (bind_type=2), since that method requires both bind_dn and bind_password to be non-empty. The anonymous bind against Active Directory succeeds trivially, but the subsequent ldap_search() fails with Operations error because AD does not allow anonymous search — which CLdap::checkCredentials() then reports as ERR_USER_NOT_FOUND, surfaced to the user as the generic "incorrect username or password" message.

      Environment

      • Zabbix version: 7.0.27 (frontend + server, MySQL backend)
      • OS: Ubuntu 24.04
      • Web server: Apache 2.4.58 with mod_php 8.3.6
      • Database: MySQL 8.0.46 (not MariaDB)
      • LDAP server: Microsoft Active Directory (Windows Server), tested against both LDAPS (port 636, with a certificate issued by an internal enterprise CA, verified trusted via openssl verify) and plain LDAP (port 389) — identical symptom on both
      • PHP LDAP extension: installed and functional (php -m | grep ldap confirms; ldap_connect/ldap_bind/ldap_search all work correctly when called directly)

      LDAP configuration (as stored in userdirectory_ldap, userdirectoryid=2)

      Field Value
      host ldaps://ad-server.example.net (also tested as ad-server.example.net on port 389)
      port 636 (also tested: 389)
      base_dn DC=example,DC=net
      search_attribute sAMAccountName
      bind_dn CN=svc-ldap-bind,OU=service,OU=IT,DC=example,DC=net
      bind_password 19-character value, confirmed correct via direct SQL (SELECT HEX(bind_password)) throughout testing
      • Enable LDAP authentication: Yes
      • Enable JIT provisioning: Yes
      • Group configuration: memberOf
      • User group mapping: LDAP group DN → Zabbix user group / Super admin role (confirmed present in userdirectory_idpgroup table, exact DN match verified byte-for-byte against the real memberOf value returned by AD)
      • Default authentication: LDAP (config.authentication_type=1, config.ldap_auth_enabled=1, config.ldap_userdirectoryid=2 — all confirmed correct via direct SQL)

      Steps to Reproduce

      1. Configure an LDAP server under Users → Authentication → LDAP settings with a dedicated bind account (Bind DN + Bind password), Base DN, search attribute sAMAccountName, and JIT provisioning enabled with a group mapping.
      2. Attempt to log in as an LDAP user who does not yet have a Zabbix account (first-time JIT login path, via CUser::tryToCreateLdapProvisionedUser()).
        • Result: "Incorrect user name or password or account is temporarily blocked."
      3. Alternatively, manually create a Zabbix user with the same username, put them in a user group whose Frontend access is set to LDAP (bypassing JIT entirely, forcing the CUser::verifyLdapCredentials() path instead).
        • Result: identical failure, identical message.
      4. Both paths ultimately call API::UserDirectory()->test(['userdirectoryid' => ..., 'test_username' => ..., 'test_password' => ...]), i.e. the exact same method backing the "Test" button in the LDAP server configuration dialog.
      5. Independently confirm the LDAP layer itself is fully functional by running a standalone script with the same credentials pulled directly from the database:

       

      {{<?php
      $ds = ldap_connect("ldaps://ad-server.example.net", 636);
      ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
      ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);

      // Using the REAL bind_password value read directly from MySQL
      ldap_bind($ds, "CN=svc-ldap-bind,OU=service,OU=IT,DC=example,DC=net", "<real_password_from_db>");
      // -> succeeds

      $sr = ldap_search($ds, "DC=example,DC=net", "(sAMAccountName=someuser)", ["dn"]);
      $entries = ldap_get_entries($ds, $sr);
      // -> returns exactly 1 entry with the correct DN

      ldap_bind($ds, $entries[0]["dn"], "<real_user_password>");
      // -> succeeds}}

      This script, run both via CLI (as the invoking user and as www-data) and reproducing the exact rebind-on-same-connection pattern used by CLdap::checkCredentials(), succeeds at every step.

      Root-cause trace (instrumentation added, no functional changes)

      Temporary error_log() calls were added at three points to trace the value of bind_password through the call chain, then removed.

      1. In include/classes/api/services/CUserDirectory.php, validateTest(), immediately after:

       

      {{$userdirectory += reset($db_userdirectory);
      $userdirectory += DB::select('userdirectory_ldap', [
      'output' => ['bind_password'],
      'userdirectoryids' => [$userdirectory['userdirectoryid']]
      ])[0];}}

      Logged output during a real login attempt:

       

      ZABBIX_UD_DEBUG line1198 after bind_password DB::select merge: key_exists=YES len=0

      The key exists (so the array union itself isn't the problem — there's no pre-existing bind_password key colliding), but the value is an empty string, immediately after the DB::select() call that is supposed to fetch it. userdirectoryid was confirmed to be 2 (the correct, only existing LDAP directory) at this exact point.

      2. In include/classes/ldap/CLdap.php, __construct(), immediately after zbx_array_merge():

       

      ZABBIX_LDAP_DEBUG __construct: INCOMING config keys=...,bind_password,... incoming_has_bind_password_key=YES incoming_bind_password_len=0 MERGED_bind_password_len=0

      Confirms the empty value propagates unchanged from CUserDirectory::test() into the CLdap constructor.

      3. Consequence in initBindAttributes():

       

      ZABBIX_LDAP_DEBUG initBindAttributes(): bind_type=1 bind_dn=[NULL]

      bind_type=1 is BIND_ANONYMOUS. Since bind_dn was correctly populated (confirmed non-empty in the same construct log) but bind_password was not, the && condition in initBindAttributes() fails and the class silently falls back to anonymous binding instead of raising any error about the missing credential.

      What has been ruled out

      • Password not actually stored correctly in the DB — ruled out. Verified via SELECT CHAR_LENGTH(bind_password) and SELECT HEX(bind_password) directly against MySQL; value is correct and stable throughout all tests. (Note: separately, the frontend's "Change password" field in the LDAP server edit dialog did not reliably persist a new password when editing an existing record — deleting and recreating the userdirectory record from scratch was needed to get a fresh, correctly-stored password. This may be a related but distinct frontend bug; flagging in case it's useful context, but the core issue reported here reproduces identically on a freshly created userdirectory record.)
      • LDAPS/TLS/certificate problems — ruled out. openssl s_client confirms full TLS handshake and correct certificate chain; manual ldap_bind() over LDAPS succeeds; and the exact same empty-password symptom reproduces identically when the directory is reconfigured to use plain LDAP on port 389 instead, which rules out anything TLS-related.
      • Network/firewall — ruled out. Both ports 389 and 636 confirmed reachable via nc/openssl s_client from the Zabbix server to the AD server.
      • PHP-LDAP extension missing or misconfigured — ruled out. Confirmed loaded and functional under both CLI and the Apache/www-data context.
      • memberOf group mapping string mismatch — ruled out. The AD-returned memberOf DN and the configured LDAP group pattern were compared character-for-character and are identical.
      • A generic "secret masking" layer in the framework — searched for in schema.inc.php, include/classes/db/DB.php, and CApiService.php; no masking logic affecting DB::select() return values was found (the only secret-masking code found is scoped to CAudit.php's log-formatting logic, and is never invoked during test()).
      • Browser/password-manager autofill interference — ruled out. The identical failure reproduces via a server-side curl -X POST directly against index.php with the credentials as literal POST fields, entirely bypassing any browser.
      • MySQL trigger on userdirectory_ldap — ruled out via SHOW TRIGGERS.
      • File encoding on the bind_password column — ruled out; standard utf8mb4/utf8mb4_bin, consistent with the rest of the schema.

      Impact

      LDAP authentication (both classic pre-provisioned-user and JIT-provisioned) is completely unusable on this installation. The "Test" button in the LDAP server configuration dialog exhibits the identical failure, so there is no way to validate the configuration from within the UI either — the only way to prove the LDAP layer itself is sound was to bypass the application entirely with standalone ldap_bind()/ldap_search() calls using credentials read directly from the database.

      Request

      Could you confirm whether this is a known regression in 7.0.x, and if not, whether the DB::select() call in CUserDirectory::validateTest() (around the bind_password output field) is expected to behave any differently depending on API call context (e.g. some sanitization/whitelisting applied to CUserDirectory::get()'s underlying query builder that unintentionally also affects the raw DB::select() call on userdirectory_ldap)? Happy to provide additional logs, run further instrumented tests, or apply a patch if one is suggested.


      Reporter's note: the debug logs above were captured with error_log() calls temporarily added to CLdap.php and CUserDirectory.php (reverted after testing, no functional changes were made to production code). Line numbers referenced correspond to the stock 7.0.27 package files (/usr/share/zabbix/include/classes/...). Happy to share full request-scoped logs (sanitized) if useful.

            Assignee:
            Aleksejs Brizgalovs
            Reporter:
            herve
            Votes:
            0 Vote for this issue
            Watchers:
            2 Start watching this issue

              Created:
              Updated:

                Estimated:
                Original Estimate - Not Specified
                Not Specified
                Remaining:
                Remaining Estimate - Not Specified
                Not Specified
                Logged:
                Time Spent - 1h
                1h