本文最后更新于 2026年1月9日 晚上
happy2026 1 2 3 4 5 6 7 8 9 10 11 12 13 <?php error_reporting (0 );highlight_file (__FILE__ );$happy = $_GET ['happy' ];$new = $_GET ['new' ];$year = $_GET ['year' ];if ($year ==2026 && $year !==2026 && is_numeric ($year )){ include $happy [$new [$year ]]; }
利用php弱类型和数组嵌套
SafePassword 1 2 $expected = getExpectedHash($channelKey ); md5($accessKey ) == $expected // 校验机制
那么$expected是否可控?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 function buildExpectedHash ($channelKey ): string { try { if (!preg_match ('/[\x00-\x08\x0B\x0C\x0E-\x1F]/' , $channelKey ) && strlen ($channelKey ) < 64 ) { return md5 ('ctfshow:' . $channelKey . ':verify' . $secret_salt ); }else { throw new RuntimeException ('' , LENGTH_ERROR); } }catch (Throwable $e ){ throw new RuntimeException ('' , VERIFY_FAILED); } }function pickErrorCode (Throwable $e ): int { $code = (int )$e ->getCode (); if (inErrorCodes ($code )) { return $code ; } $idx = abs ((int )crc32 (get_class ($e ) . '|' . $e ->getMessage ())) % count (ERROR_CODES); return ERROR_CODES[$idx ]; }function getExpectedHash ($channelKey ) { try { return buildExpectedHash ($channelKey ); } catch (Throwable $e ) { return pickErrorCode ($e ); } }
通过传入channelKey可以触发异常抛出,使$expected=2025
校验进行弱比较,构造一个$accessKey md5后是2025+字母的即可
简单爆破
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <?php $targetPrefix = "2025" ;$accessKey = "" ;$found = false ;for ($i = 0 ; $i < 1000000 ; $i ++) { $testKey = (string )$i ; $hash = md5 ($testKey ); if (substr ($hash , 0 , 4 ) === $targetPrefix ) { $accessKey = $testKey ; $found = true ; break ; } }if ($found ) { echo "找到符合条件的accessKey:{$accessKey} \n" ; echo "其MD5值:" . md5 ($accessKey ) . "\n" ; } else { echo "未找到(可扩大枚举范围)\n" ; }
找到了434048
不过这个爆破只找了数字的,局限性比较大,wp里面的更全面一些
补充——PHP异常抛出 PHP异常处理
Throwable对象$e的用法
1 2 3 4 5 6 7 8 9 10 11 try { throw new RuntimeException ("连接失败" , 1001 ); } catch (Throwable $e ) { echo "错误消息: " . $e ->getMessage (); echo "错误码: " . $e ->getCode (); echo "文件: " . $e ->getFile (); echo "行号: " . $e ->getLine (); echo "堆栈:\n" . $e ->getTraceAsString (); }
SafeLock 有点难,我看了wp,只能简单写一下思路,全自动的python脚本目前我还写不出来:(
首先观察日志
BOOT 锁联网上线
开始初始化
使用出厂密钥和默认salt为123456 设置管理员卡
使用一次后就过期
SALT更新为随机8位数字
主要攻击线路
拉闸->迫使锁用电池->每次验证掉5%的电->电池掉光电->锁完全进入离线状态->恢复供电->开始初始化salt为123456->给自己刷管理员卡->开门禁系统
。。。。。。
回宿舍的时候我突然想到,虽然全自动的我不会写,但可以结合手动啊!
先访问/api/challenge获取nonce
去/api/power关电源
去/api/verify请求20次用完电量
再打开电源成功进入工厂模式
获取新的nonce,写一个小脚本计算CRC16签名
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from __future__ import annotations DEVICE_ID = "LOCK-X36D" FACTORY_SALT = "123456" ROLE = "admin" def crc16_ccitt_false (data: bytes ) -> int : crc = 0xFFFF for b in data: crc ^= (b << 8 ) for _ in range (8 ): if crc & 0x8000 : crc = ((crc << 1 ) ^ 0x1021 ) & 0xFFFF else : crc = (crc << 1 ) & 0xFFFF return crc & 0xFFFF def calc_sig (device_id: str , nonce: str , salt: str , role: str ) -> str : msg = f"{device_id} |{nonce} |{salt} |{role} " .encode("utf-8" ) return f"{crc16_ccitt_false(msg):04X} " def main (): nonce=input ("nonce:" ) sig = calc_sig(DEVICE_ID, nonce, FACTORY_SALT, ROLE) print (sig)if __name__ == "__main__" : raise SystemExit(main())
验证签名,成功!!!
补充——前端操作为什么不能实现? 我一开始想的是直接在前端操作,这样更方便,但是前端每次刷卡消耗1%的电量(上面写的每次消耗5%),而且burp拦截发现关闭打开电源并不会向/api/power发送请求,校验签名的response里电量一直是100%,当然也无法进入工厂模式
用ai分析了一下页面源代码
🔍 详细分析 1️⃣ 电量消耗逻辑在前端被硬编码为 1% 看这段关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 function batteryDrainOnce ( ) { const mode = getPowerMode (); if (mode !== "BATTERY" ) return ; state.battery = Math .max (0 , state.battery - 1 ); logLine ("meta" , `BATTERY → ${state.battery} %` ); if (state.battery <= 0 ) { state.powered = false ; logLine ("bad" , "BATTERY DEPLETED: device shutdown" ); logLine ("warn" , "HINT: restore mains power to reboot device" ); } }
无论后端 API 实际扣多少,前端 UI 每次只模拟 -1%
这个 batteryDrainOnce() 是在 verifySwipe() 中调用的:1 2 3 4 5 6 async function verifySwipe ( ) { batteryDrainOnce (); }
📌 所以前端显示的电量变化 和后端真实状态无关 !它只是本地模拟。
2️⃣ 恢复供电时,前端自动充满电,但不会触发 FACTORY_RESET 看断电/上电逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 function setMains (on ) { const prev = state.mains_on ; state.mains_on = !!on; if (!prev && state.mains_on ) { state.battery = 100 ; state.powered = true ; logLine ("meta" , "POWER RESTORED (MAINS)" ); logLine ("meta" , "BATTERY CHARGED → 100% (policy)" ); } }
前端在恢复主电源时,直接把 battery = 100
完全没有检查“是否是因为电量耗尽才离线”
因此 永远不会触发 FACTORY_RESET_OK 事件
💥 而后端的真实逻辑是:
只有当设备 因电池=0 而 powered=false ,
再收到 mains_on=true,
才会触发工厂重置,并重置 salt 为 123456
但前端完全忽略了这个条件!
3️⃣ 前端调用了 /api/verify,但没处理关键响应字段 虽然前端确实发了请求到 /api/verify,但它只关心 ok 和 flag ,忽略了:
state.battery(后端真实电量)
state.powered
events(比如 ["FACTORY_RESET_OK"])
rotate_nonce
前端始终用自己的 state.battery 显示,而不是用 API 返回的值!
例如:
1 2 const data = await r.json ();
所以即使后端已经把电量扣到 0,前端 UI 仍可能显示 80%(因为它是按 1% 扣的)。
4️⃣ 出题人的意图:引导你绕过前端 前端日志中其实有提示:
1 2 logLine ("meta" , "NOTE: verification runs on server-side (salt not available on client)" );logLine ("warn" , "HINT: after mains cut, device may enter battery mode; repeated attempts drain battery" );
但前端故意弱化了耗电效果 ,让你以为“刷 100 次就行”,实际上:
后端每次扣 5%,20 次就关机
前端要刷 100 次才显示关机,但此时后端早就关机了
而且前端恢复电源时直接满电,跳过了重置条件
🎯 所以正确做法是:不要用前端 UI 操作,直接调用 API
总结 这道题不看wp很难做出来
分析js代码找到前后端验证机制区别
找到隐藏的/api/power
根据json分析出CRC16签名
管理员卡