
HotelDruid 3.0.0 和 3.0.7 在 creadb.php 设置端点上存在一个竞争条件,该端点在设置完成前可被访问。它有两种影响,均源于同一次掉入的竞争:敏感信息泄露(通过详细的 SQL 错误消息)和拒绝服务。
通过一次性发送大量 POST 请求,攻击者可触发竞争条件并从响应中读取敏感数据,包括管理员用户名、密码哈希和盐值。如果在 Debian 软件包配置期间选择的密码较弱,则可通过词表离线恢复。
当攻击成功时,管理员将无法使用他们在安装期间设置的凭据登录(拒绝服务)。
Debian 软件包使 creadb.php 在设置完成前无需登录即可访问,并且使用软件包默认值 C_UTILIZZA_SEMPRE_DEFAULTS="AUTO" 时,它会在每个请求上重新运行整个数据库创建过程。该例程发出超过 100 条 SQL 语句(创建和填充表),且没有加锁,因此同时到达的多个请求会同时运行它。这就是竞争条件。
在慢速或繁忙的机器上,并行运行会重叠并在 SQLite 数据库上发生冲突。一旦一个请求开始创建表和行,后续请求的语句会因各种原因(如行已存在或数据库被另一个写入锁定)而批量失败。每次失败时,HotelDruid 的查询包装器 esegui_query() 会将完整的失败语句打印到 HTTP 响应中。这意味着单个响应会返回数十个这样的 SQL 错误,其中一个包含管理员账户的敏感信息。
update ...utenti set password = '<hash>', salt = '<salt>', tipo_pass = '5' where idutenti = '1'
这几乎就是利用程序检测成功的方式。它在响应中搜索 set password,该短语仅在回显该特定语句时出现,而在 creadb.php 的正常输出中从未出现。
锁定来自同样的失败。管理员行首先以 tipo_pass='n' 插入,只有上述 UPDATE 语句将其变为可用的账户(tipo_pass='5' 并设置密码)。UPDATE 之后立即运行的代码从不检查它是否成功。它会创建 abilita_login 文件(开启登录),删除 ini.php(其中保存了管理员凭据),稍后写入 ultimo_accesso(永久关闭设置)。因此,当 UPDATE 输掉竞争时,账户保持 tipo_pass='n',登录页面即使输入正确密码也始终拒绝,而此时登录已开启、凭据文件已不存在。此时,除了重新安装,无法恢复。
这就是为什么成功泄露和锁定实际上是同一事件。两者都发生在那个 UPDATE 输掉竞争时。当只获得用户名时,意味着更早的语句发生了冲突,但密码 UPDATE 仍然通过,因此没有发生锁定。
从攻击者机器针对目标运行利用程序:
python3 exploit.py 192.168.1.1
其中 192.168.1.1 是运行 HotelDruid 的机器的 IP 地址。
如果成功,使用 brute.py 加上词表尝试恢复明文密码。打开 brute.py,将 salt 设置为你获得的盐值,将 final_hash 设置为你获得的哈希值,然后运行:
python3 brute.py rockyou.txt
其中 rockyou.txt 是你的词表。
请注意,即使使用正确的用户名和密码,你和管理员仍然无法登录。你恢复的密码是正确的,但成功的竞争使账户卡在 tipo_pass='n' 状态,因此登录页面拒绝它。请参见根本原因部分。
该漏洞已在 HotelDruid 3.0.8 中修复。这是变更日志,只需搜索“CVE-2025-44203”即可。
它使用的锁辅助函数(crea_lock_file(),一个阻塞的 flock(LOCK_EX))在 3.0.7 版本中已存在,并在其他代码部分使用。然而,creadb.php 从未调用它。补丁做了两件事:
creadb.php 中最终调用该辅助函数来实现:在管理员配置之前获取锁,之后使用 distruggi_lock_file() 释放锁。使其生效的关键是获取锁后立即进行检查。最先进入的请求在仍然持有锁的情况下删除 ini.php,而每个请求在配置前都会重新读取 ini.php 是否存在,因此排在后面的请求获取锁后发现文件已删除,从而跳过整个块。当第二个请求运行时,设置已经完成,它什么也不做。esegui_query() 调用添加第二个参数 1 来实现,一个设置用户名,另一个设置密码和盐值。该标志告诉查询包装器在查询失败时保持静默。它仍然在服务器日志中记录错误,但跳过了 echo,否则该 echo 会将失败的语句(包括哈希和盐值)放入页面。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);