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

小程序截图
小程序截图

和标准 TOTP 不同的是,这个小程序还支持自定义 OTP 的有效时长,这点设计还算实用。

我随手搜了下这个小程序的名称,结果发现好几个不同品牌的智能锁说明书里都提到了它:

好巧啊,大家都用的一个算法🐶
好巧啊,大家都用的一个算法🐶

看了下这个小程序的主体是个人,猜测这些锁大概率用的是同一套公版方案,小程序也是方案商统一做的。考虑到哪天小程序不续费下架了就没法用临时密码了,我决定把它的算法逆向出来自己实现。在 Claude Code 的帮助下很快就搞定了,纯 Python 实现的代码已经开源在 GitHub:mrchi/reverse-smart-doorlock-otp

逆向步骤

整个过程非常简单:

  1. 使用wedecode工具解包微信小程序,支持 Mac 端微信 4.0+ 版本
  2. 解包后把代码丢给 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"

步骤拆解:

  1. 把 32 位整数格式化为 8 位字符串,长度不够的话前面补 0
  2. 反转整个字符串
  3. 只保留前 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

安全建议

针对上述问题,可以针对性降低风险:

  1. 如果下个月天数少于当前月,尽量避免在月底生成临时密码
  2. 生成临时密码尽量使用较短有效期,然后在两三年内把锁换掉