家里装修送了个杂牌智能门锁,自带的临时密码功能需要通过微信小程序“临时密码生成器”来生成,如下图:

和标准 TOTP 不同的是,这个小程序还支持自定义 OTP 的有效时长,这点设计还算实用。
我随手搜了下这个小程序的名称,结果发现好几个不同品牌的智能锁说明书里都提到了它:

看了下这个小程序的主体是个人,猜测这些锁大概率用的是同一套公版方案,小程序也是方案商统一做的。考虑到哪天小程序不续费下架了就没法用临时密码了,我决定把它的算法逆向出来自己实现。在 Claude Code 的帮助下很快就搞定了,纯 Python 实现的代码已经开源在 GitHub:mrchi/reverse-smart-doorlock-otp。
逆向步骤
整个过程非常简单:
- 使用
wedecode工具解包微信小程序,支持 Mac 端微信 4.0+ 版本 - 解包后把代码丢给 Claude Code,直接要求用 Python 重写计算逻辑就行
npm install -g wedecode
wedecode
算法完整流程
整体原理和标准 TOTP 大体一致,但在时间起点、容错处理、密码格式等地方做了大量自定义修改。
1. 时间计数器计算
和标准 TOTP 用 1970 年作为纪元不同,这个实现自定义了时间起点——2000 年 1 月 1 日 0 点,而且全程不处理时区:
EPOCH = datetime.datetime(2000, 1, 1, tzinfo=None)
首先计算当前时间距离这个自定义纪元的总秒数。这里有个非常离谱的坑:原小程序的时间计算有 bug,会额外多加上"当月总天数 + 1 天"的秒数,我们逆向的时候必须原样补上才能保证兼容:
month_days = calendar.monthrange(dt.year, dt.month)[1]
total_seconds = int((dt - EPOCH).total_seconds()) + (month_days + 1) * 86400
然后根据密码的有效时长计算时间计数器:
time_counter = total_seconds // (60 * duration)
比如有效期是 5 分钟的话,时间步长就是 300 秒,每过 5 分钟计数器加 1。
2. HMAC-SHA1 计算
接下来用管理员密码作为密钥,时间计数器作为消息,计算 HMAC-SHA1 摘要:
hmac_result = list(
hmac.new(
struct.pack("<I", seed_int), struct.pack("<I", time_counter), hashlib.sha1
).digest()
)
这里有两个细节要注意:
- 管理员密码和时间计数器都要以小端模式(little-endian)打包成 4 字节整数
- 哈希算法固定使用 SHA1,输出 20 字节的摘要结果
3. 动态截断生成 32 位整数
这部分和标准 TOTP 的截断逻辑基本一致:
# 取最后一个字节的低 4 位作为偏移量
offset = 0x0F & hmac_result[-1]
# 从偏移量位置提取 4 个字节组合成 32 位整数
code = (
(hmac_result[offset] & 0x7F) << 24
| (hmac_result[offset + 1] & 0xFF) << 16
| (hmac_result[offset + 2] & 0xFF) << 8
| (hmac_result[offset + 3] & 0xFF)
)
- 用 HMAC 结果最后一个字节的低 4 位作为偏移量,范围 0~15
- 从偏移量位置开始连续取 4 个字节,组合成 32 位整数
- 最高位和
0x7F做与运算,避免有符号整数的问题
4. 格式化为 8 位动态密码
这部分是完全自定义的逻辑,和标准 TOTP 完全不一样:
PASSWORD_MAIN_LENGTH = 8
# 整数转 8 位字符串,补前导 0 后反转
password = f"{code:0{PASSWORD_MAIN_LENGTH}d}"[::-1]
# 截取前 7 位,最后一位固定补 0
return password[: PASSWORD_MAIN_LENGTH - 1] + "0"
步骤拆解:
- 把 32 位整数格式化为 8 位字符串,长度不够的话前面补 0
- 反转整个字符串
- 只保留前 7 位,最后一位固定补 0,得到 8 位动态密码
5. 拼接有效期得到最终密码
最后把 2 位的有效时长(分钟,不足补 0)拼接到 8 位动态密码后面,得到最终的 10 位密码:
DURATION_LENGTH = 2
password += f"{duration:0{DURATION_LENGTH}d}"
比如有效期 5 分钟的话,最后两位就是05。
安全性分析
这个算法看似巧妙,实则自作聪明,存在严重安全隐患。
时间计算 bug 引入的问题
时间计算的秒数 = 当前时间 + 当月天数 + 1 天
不同月份的天数不同。如果后一个月的天数少于前一个月,就会计算出相同的秒数,进而得到相同的 time counter,密码也相同。
举个例子:
2010-01-30 12:00 的时间秒数
10 年 + 29.5 天 + 31 天 + 1 天
2010-02-02 12:00 的时间秒数
10 年 + 32.5 天 + 28 天 + 1 天
结果相等,也就是说 2010.1.30 签发的临时密码,在 2010.2.2 的相同时刻仍然有效。
自定义有效期导致的重用攻击
只要管理员密码和时间计数器相同,HMAC-SHA1 的结果就相同,密码的前 7 位也必然相同。
标准 TOTP 算法中,时间步长是固定的,每个时间计数器值在时间线上只会出现一次。但这个实现的时间步长是动态的(由密码有效期决定),导致相同的时间计数器值会在不同时间点重复出现。
我写了一个简单脚本,计算当前密码的重用时间:
❯ python verify_exploit.py 123456 20
当前时间:2026-03-17 23:09:38
初始密码(20分钟): 9765900020
找到 36 个可利用的时间段:
有效期 | 时间 | 修改后密码
--------------------------------------------------------------------------------
21分钟 | 2027-07-11 04:57 | 9765900021
22分钟 | 2028-11-03 11:06 | 9765900022
24分钟 | 2031-06-21 22:48 | 9765900024
25分钟 | 2032-10-13 04:45 | 9765900025
27分钟 | 2035-05-31 16:39 | 9765900027
28分钟 | 2036-09-23 22:52 | 9765900028
29分钟 | 2038-01-16 04:33 | 9765900029
30分钟 | 2039-05-11 10:30 | 9765900030
31分钟 | 2040-09-03 16:44 | 9765900031
32分钟 | 2041-12-26 22:24 | 9765900032
33分钟 | 2043-04-22 04:33 | 9765900033
...
下次重用时间与当前时间的间隔,和时间计数器大小成正比;而密码有效期与时间计数器大小成反比。
因此初始密码有效期越长,时间计数器值越小,重用时间间隔越短。
❯ python verify_exploit.py 123456 59
当前时间:2026-03-17 23:14:48
初始密码(59分钟): 1778707059
找到 1 个可利用的时间段:
有效期 | 时间 | 修改后密码
--------------------------------------------------------------------------------
60分钟 | 2026-08-27 18:00 | 1778707060
安全建议
针对上述问题,可以针对性降低风险:
- 如果下个月天数少于当前月,尽量避免在月底生成临时密码
- 生成临时密码尽量使用较短有效期,然后在两三年内把锁换掉