
Exploit for CVE-2025-44203 targeting a race condition in HotelDruid 3.0.0/3.0.7 that leaks admin credentials and causes denial of service. Includes a brute-force script for offline password recovery.
HotelDruid 3.0.0 and 3.0.7 contain a race condition on the creadb.php setup endpoint, reachable before the setup is finished. It has two impacts, and both come from the same lost race: a sensitive information disclosure (through verbose SQL error messages) and a denial of service.
By sending many POST requests at once, an attacker can trigger a race condition and read sensitive data from the response, including the administrator username, password hash and salt. If the password chosen during the Debian package configuration is weak, it can then be recovered offline with a wordlist.
When the attack succeeds, the administrator is left unable to log in with the credentials they set during installation (denial of service).
The Debian package leaves creadb.php reachable without a login until the setup is finished, and with the package default C_UTILIZZA_SEMPRE_DEFAULTS="AUTO" it re-runs the whole database creation on every request. That routine issues over 100 SQL statements (creating and populating the tables) with no lock around it, so several requests that arrive together run it at the same time. That's the race.
On a slow or busy machine, the parallel runs overlap and collide on the SQLite database. Once a request has started creating the tables and rows, a later request's statements fail in bulk for various reasons like rows already existing or the database being locked by another write. On each failure, HotelDruid's query wrapper esegui_query() prints the full failing statement into the HTTP response. This means that a single response comes back with dozens of those SQL errors, and one of them contains the sensitive information for the admin account.
update ...utenti set password = '<hash>', salt = '<salt>', tipo_pass = '5' where idutenti = '1'
That is pretty much how the exploit detects success. It searches the response for set password, which only appears when that precise statement is echoed, which never happens in creadb.php's normal output.
The lockout comes from the same failure. The admin row is first inserted with tipo_pass='n', and only the UPDATE above turns it into a working account (tipo_pass='5' with a password set). The code that runs right after the UPDATE never checks whether it worked. It creates the abilita_login file, which switches login on, deletes ini.php, which held the admin credentials, and later writes ultimo_accesso, which closes the setup for good. So when the UPDATE loses the race, the account stays at tipo_pass='n', which the login page always rejects even with the correct password, while login is switched on and the credentials file is gone. At that point there's no way back without reinstalling.
This is why a successful leak and the lockout are really the same event. Both happen when that one UPDATE loses the race. When you get only the username, it means an earlier statement collided while the password UPDATE still went through, so nothing was locked out.
Run the exploit against the target from the attacker machine:
python3 exploit.py 192.168.1.1
where 192.168.1.1 is the IP address of the machine running HotelDruid.
If it works, use brute.py with a wordlist to try to recover the plaintext password. Open brute.py, set salt to the salt you obtained and final_hash to the hash you obtained, then run:
python3 brute.py rockyou.txt
where rockyou.txt is your wordlist.
Note that even with the correct username and password you still won't be able to log in, and neither will the administrator. The password you recover is correct, but the successful race left the account stuck at tipo_pass='n', so the login page rejects it. See the Root cause section.
The vulnerability was fixed in HotelDruid 3.0.8. Here's the changelog, simply search for "CVE-2025-44203".
The lock helper it uses (crea_lock_file(), a blocking flock(LOCK_EX)) was already present in version 3.0.7 and was used in other parts of the code. However, creadb.php never called it. The patch does two things:
creadb.php: it grabs the lock just before the admin provisioning and drops it with distruggi_lock_file() afterwards. What makes it work is the check right after the lock is taken. The request that gets in first deletes ini.php while it still holds the lock, and every request re-reads whether ini.php exists before provisioning, so the ones queued behind it take the lock, find the file already gone, and skip the whole block. By the time the second one runs, the setup is already done and it does nothing.1, to those two esegui_query() calls, the one that sets the username and the one that sets the password and salt. That flag tells the query wrapper to stay quiet when a query fails. It still records the error in the server log, but it skips the echo that would otherwise put the failing statement, hash and salt included, into the page.creadb.php:
879: esegui_query("update $tableutenti set nome_utente = '".aggslashdb($admin)."' where idutenti = '1'",1);
897: esegui_query("update $tableutenti set password = '$passw', salt = '$salt', tipo_pass = '5' where idutenti = '1'",1);