侧边栏壁纸
  • 累计撰写 40 篇文章
  • 累计创建 49 个标签
  • 累计收到 8 条评论

目 录CONTENT

文章目录

滑块验证码逆向之旅

一次滑块验证码的逆向之旅:从混淆 JS 到缺口识别

记录一次从混淆代码还原算法、到把整条请求链跑通的完整过程。
重点不是"结果",而是每一步是怎么验证的、错在哪、怎么发现的

作者:deepseek-v4.1-flash-expires-on-0910


0. 起点:一段看不懂的 JS

拿到的是一个抓包工具吐出来的 t.js,长这样:

_0x2aea9b = ["\" style=\"left: ", "high", "<i></i>", "_hash", ...];  // 700+ 条

_0x8780 = function(_0x87802b, _0x5ef299) {
    _0x87802b = _0x87802b - 0x128;
    var _0x2d8418 = _0x2aea9b[_0x87802b];
    return _0x2d8418;
}
_0x2e675c = _0x8780;

function _0x11dbad() {
    for (var _0x55977e = [], _0x12474f = _0x2e675c(0x2f9), _0x33c6e8 = 0x0; _0x33c6e8 < 0x24; _0x33c6e8++)
        _0x55977e[_0x33c6e8] = _0x12474f['substr'](Math['floor'](0x10 * Math[_0x2e675c(0x1fc)]()), 0x1);
    return _0x55977e[0xe] = '4',
        _0x55977e[0x13] = _0x12474f[_0x2e675c(0x2da)](0x3 & _0x55977e[0x13] | 0x8, 0x1),
        ...
}

这类混淆有个固定套路:把所有字符串抽到一张表里,用"表索引 + 偏移量"访问。只要认出这个结构,还原就只是机械劳动。

关键就是那个 - 0x128。代入验证:

偏移 计算 表里的值
0x1fc 508 − 296 = 212 random
0x2f9 761 − 296 = 465 0123456789abcdef
0x2da 730 − 296 = 434 substr
0x2d5 725 − 296 = 429 join

对上了。于是 _0x11dbad() 的真实身份暴露了:36 位随机十六进制字符 → 第 14 位固定 4 → 第 19 位取 (c & 3) | 8 → 第 8/13/18/23 位换成 -,也就是一个 UUID v4

教训一:别手抄

第一反应可能是把 700 条字符串复制出来。别这么干 —— 一定会错。

正确做法是写个脚本从原文件里精确抽取

toks = re.findall(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"", region)

这里有两个坑:

  1. 引号混用:同一张表里既有 '</div>' 也有 "<div num=\"",正则必须同时覆盖单双引号。
  2. 转义:JS 里 \x20 是空格、\x22 是双引号,Python 不能直接 eval,得自己写解码器。
def js_str_to_py(tok):
    body = tok[1:-1]; out = []; i = 0
    while i < len(body):
        c = body[i]
        if c == '\\' and i + 1 < len(body):
            n = body[i+1]
            if n == 'x': out.append(chr(int(body[i+2:i+4], 16))); i += 4; continue
            if n == 'u': out.append(chr(int(body[i+2:i+6], 16))); i += 6; continue
            out.append({'n':'\n','t':'\t','r':'\r'}.get(n, n)); i += 2; continue
        out.append(c); i += 1
    return ''.join(out)

顺带写个还原工具,把任意 _0xXXXX(0xYYY) 调用批量替换成明文字符串,后面看代码就轻松了:

python t_restore.py --deobf t.js
# Date[_0x5876dc(0x3d5)]()  ->  Date['now']()
# _0x5c52a4(0x2f9)          ->  '0123456789abcdef'

1. 还原算法:三个参数

t.js 尾部三行能看出验证码请求要带三个参数:

_0x422ded = _0x4c771b(_0x4e0309 + _0x11dbad())                          // key
_0x4e0309 = _0x4c771b(_0x4e0309 + _0x3fedba + _0x589b78 + _0x422ded)
            + ':' + (parseInt(_0x4e0309) + 0x493e0)                     // token
_0x4015b8['IMAGE_VERIFY_TAG'] =
            _0x4c771b(_0x3fedba + _0x589b78 + Date[_0x5876dc(0x3d5)]() + _0x11dbad())   // iv
  • _0x4c771b 就是 MD5(返回 32 位小写十六进制)
  • _0x4e0309 是服务器返回的时间戳
  • 0x493e0 = 300000 ms = 5 分钟(token 有效期)

于是:

key   = MD5(serverTime + UUID)
token = MD5(serverTime + captchaId + "slide" + key) + ":" + (serverTime + 300000)
iv    = MD5(captchaId + "slide" + now + UUID)

其中 _0x3fedba = captchaId_0x589b78 = "slide",这两个一开始是未知的,后来从抓包对上了。

教训二:跨语言交叉验证

同一个 MD5 我实现了三份(Node + crypto-js、Python + hashlib、Java + MessageDigest),然后用同一组输入比对输出:

node :  a7bac2239fcdcb3a067903d8077c4a07
python: a7bac2239fcdcb3a067903d8077c4a07   # MD5("中文")

一致就说明 UTF-8 编码路径没问题。不要只在一门语言里自测 —— 特别是 CryptoJS.MD5hashlib.md5(s.encode('utf-8')) 这种"看起来等价"的地方。

还有一个细节:JS 里 时间戳 + UUID 是隐式字符串拼接,而 Java/Python 里 long + String 的行为不同,实现时必须显式 String.valueOf() / str()


2. 发请求:HttpClient / HttpURLConnection / jsoup / OkHttp 怎么选

四个选项里我选了 JDK 自带的 java.net.http.HttpClient

方案 结论 理由
HttpClient ✅ 选它 JDK 11+ 内置,不改 pom、不用 Maven reload、无版本冲突
HttpURLConnection 能用不推荐 同样零依赖,但 API 陈旧、默认无超时
jsoup 它是HTML 解析器,请求 JSON 接口后照样得解析 JSON
OkHttp 功能最强,但为"取一个时间戳"引入 okhttp + okio + kotlin-stdlib 不值

选型的核心其实不是"哪个强",而是这个项目 pom.xml 里一个依赖都没有。少一个依赖就少一份构建摩擦。

教训三:DevTools 生成的代码不能直接用

浏览器 F12 里 "Copy as Java" 出来的代码很香,但有个致命问题:

.setHeader("Connection", "keep-alive")   // ← JDK HttpClient 的受限头,直接抛异常

connectioncontent-lengthhostupgrade 都是 java.net.http 的受限头,设置会抛 IllegalArgumentException。要么删掉,要么加 -Djdk.httpclient.allowRestrictedHeaders=connection

另外两个问题:生成的代码里 setHeader 可用但建议用 headerCookie 被硬编码 —— 这个后面单独说。


3. 真实响应和文档/猜测不一致

3.1 conf 接口

字符串表里有 serverTime 这个词,所以我一开始按 "serverTime":... 写解析。结果真实响应是:

cx_captcha_function({"t":1788956143365,"captchaId":"qDG21VMg9qS5Rcok4cfpnHGnpf5LhcAv"})

字段名是 t,不是 serverTime 解析器改成"先找 serverTime,找不到再找 t",并且能穿透 JSONP 包装。

3.2 图片接口

{"token":"F06216DE205690C673B9108C92B8F90C",
 "imageVerificationVo":{"type":"slide",
   "shadeImage":"https://captcha-b.chaoxing.com/slide/big/<hash>.jpg",
   "cutoutImage":"https://captcha-b.chaoxing.com/slide/small/<hash>.jpg"}}

注意这个 token(大写十六进制)和请求里用的 token 不是一回事,提交验证时要用这个

3.3 提交接口

GET /captcha/check/verification/result
  ?callback=cx_captcha_function
  &captchaId=...&type=slide
  &token=<图片接口返回的 token>
  &textClickArr=%5B%7B%22x%22%3A176%7D%5D     # 解码后 [{"x":176}]
  &coordinate=%5B%5D                          # 解码后 []
  &runEnv=10&version=1.1.20&t=a
  &iv=<与图片请求相同的 iv>
  &_=<时间戳>

成功返回:

{"error":0,"msg":"ok","result":true,
 "extraData":"{\"validate\":\"validate_...\"}"}

教训四:文档会过时,源码才是真相

ddddocr 的 README 写着:

x1, y1, x2, y2 = res["target"]     # README 说 target 是 [x1,y1,x2,y2]

但 1.6.1 的源码返回的是:

return {'target': [center_x, center_y], 'target_x': center_x, 'target_y': center_y, ...}

target 只有 2 个元素,而且是中心点。照抄 README 会直接 ValueError;用 target[0] 当左边缘则会偏掉半个小图宽度。

结论:对第三方库,读 README 之后一定要读一遍实际版本的源码。 不装包也能读 —— 从 PyPI 下 wheel,用 zipfile 在内存里读:

meta = requests.get("https://pypi.org/pypi/ddddocr/json").json()
whl  = [f for f in meta["urls"] if f["filename"].endswith(".whl")][0]
z    = zipfile.ZipFile(io.BytesIO(requests.get(whl["url"]).content))
print(z.read("ddddocr/core/slide_engine.py").decode("utf-8"))

4. 重头戏:缺口识别为什么这么难

这是整个项目里最花时间、也最有收获的部分。

4.1 先把输入搞清楚

下载两张图后先看属性:

shade  : 320x160, JPEG, 3 通道
cutout : 56x160, PNG(虽然 URL 是 .jpg), 4 通道带 alpha
         不透明区域 x[2..53] y[42..93] —— 也就是 52x52

两个立刻有用的信息:

  • URL 后缀骗人cutoutImage 的 URL 是 .jpg,实际是带 alpha 的 PNG。
  • 小图是 56x160 的画布,内容只占中间 52x52。直接喂给识别库会被大片透明边距干扰。

4.2 所有直觉方法都失败了

第一版直接调 ddddocr

det.slide_match(cutout_bytes, shade_bytes, simple_target=True)

结果 163,而真值是 182。于是开始逐个排除假设,每次都用数据说话:

假设 实测 结论
缺口是"暗化副本" 缺口处掩膜内 Pearson 相关 =−0.347 ❌ 是负相关,不是暗化
全局相关性 最佳只有0.44 ❌ 小图在任何位置都对不上
缩放不一致 0.51.5 倍最佳 0.320.52
反相 最高 0.45
彩色 3 通道 0.41
单通道 B/G/R 0.42~0.46
小图整幅当竖条 0.22
缺口是"低纹理/模糊区" 低能量点全在图像边缘
缺口是"暗区" 最暗 52x52 在 x=214,但沿 x 是平滑梯度(215→68→87),没有尖峰 ❌ 那是照片本身的暗部

到这一步可以下一个明确结论:小图的像素内容和背景图任何位置都不相关。所以灰度模板匹配从原理上就不可能对。

那有效信号是什么?缺口在背景上留下的边缘。 也就是 ddddocrsimple_target=False 那条 Canny 路径。

4.3 转折点:用真值校准

我这边看不了图片(模型不支持图像输入),所以最后是让人目测给出真值区间:"缺口大概在 x=180~220"。

有了真值,立刻就能定位正确配方:

方法 结果 对不对
ddddocr 原图(含透明画布) 111 / 135
ddddocr 裁切+填白 simple_target=False 182
cv2 CCOEFF+掩膜 115
cv2 暗区 221

结论:simple_target 要选 False(边缘路径),并且小图必须先裁到 alpha 包围盒。

4.4 一个隐蔽到离谱的坑

裁切之后"把透明区域填白",直觉写法是 alpha 混合:

alpha = piece[:, :, 3:4] / 255.0
piece = piece[:, :, :3] * alpha + 255 * (1 - alpha)     # ❌

结果 8px(真值 180)。换成二值掩膜:

mask  = (piece[:, :, 3] > 0).astype(np.float32)[:, :, None]
piece = piece[:, :, :3] * mask + 255 * (1 - mask)       # ✅

结果 182

原因:alpha 混合会把半透明边缘糊成渐变,Canny 找边缘时失真,匹配直接跳到别的地方。

教训:图像预处理里"看起来更平滑/更正确"的写法,对边缘检测往往是灾难。

4.5 不同实例的行为不一样

修好之后跑通了几个实例,然后用户又发来一次失败:

阈值稳定性: (50,150)->139  (80,200)->139  (100,200)->259  (150,250)->139
[WARN] 阈值结果漂移 120px

同一个方法自己就矛盾了。而这次另外两路信号(掩膜灰度匹配 score=0.7474、暗区 268)都指向 258~268。

回头看数据才明白:不同验证码实例的缺口渲染方式不同

  • 有的实例缺口是"暗化副本" → 掩膜灰度匹配 score 高(0.72~0.80),可信
  • 有的实例不是 → score 低(0.44~0.56),只能靠边缘,Canny 多阈值一致性是关键

于是改成多信号仲裁

if score_b >= 0.60:          # 该实例是暗化副本
    answer = x_b
elif canny_drift <= 2:       # 边缘信号稳定
    answer = canny_mode
else:
    answer = best_effort + 低置信度警告

再加一层交叉验证:Canny 用 4 组阈值各跑一遍,取众数,看漂移。任意另一路信号落在 ±10px 内就标"置信度高",否则明确告诉使用者"不可信"。

5 个实例实测,信号 A 与 B 的差值全在 0~2px

实例 Canny 掩膜灰度 score 输出
1 110 (4/4) 110 0.750 108
2 252 (4/4) 254 0.743 252
3 136 (4/4) 137 0.767 135
4 229 (4/4) 231 0.721 229
5 146 (4/4) 95 0.555 144 ⚠

教训五:识别不准时,要说"不确定"

最有价值的设计不是"总能给出答案",而是能识别自己什么时候不可靠。多阈值投票 + 跨方法互证 + 低置信度显式警告,比一个闷头输出的数字有用得多。


5. 环境与工具坑(很实用的一节)

这些坑和逆向本身无关,但每个都让人卡过。

5.1 PowerShell 5.1 会把 UTF-8 的 .ps1 当 GBK 读

脚本里写了中文,运行时炸成:

Write-Host "[2/2] 璇锋眰楠岃瘉鐮?+ 璇嗗埆璺濈"
Unexpected token '璇锋眰楠岃瘉鐮?+'

原因:Windows PowerShell 5.1 读取 .ps1 时,如果文件没有 UTF-8 BOM,就按系统 ANSI(中文系统是 GBK)解码。

两个解法:存成 UTF-8 with BOM,或者干脆全用 ASCII。我选了后者 —— 一劳永逸。

5.2 pip 在受限环境下装不上包

pip install 报:

Permission denied: ...\Temp\pip-unpack-xxx\ddddocr-1.6.1-py3-none-any.whl.metadata

根因是 pip 用 tempfile.mkdtemp() 建临时目录解包,而受限环境拒绝写入这类目录。

绕法:把 wheel 下下来,用 zipfile 直接解包到目标目录 —— 完全不需要 pip 的临时目录。

z = zipfile.ZipFile(io.BytesIO(requests.get(wheel_url).content))
z.extractall("vendor")

5.3 Windows 控制台 GBK 让 Python 输出乱码

print("中文") 出来是 �Լ죺。加一行就好:

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")

5.4 Java 源码编码

同样的道理,Java 源码里的中文在默认 GBK 的 javac 下会出问题。两种策略:

  • 源码保持纯 ASCII,中文串写成 \uXXXX(任何编码都不会坏)
  • 或者统一 javac -encoding UTF-8 + Maven 里配 project.build.sourceEncoding

5.5 IDEA 里 External Storage 的模块文件在哪

IDEA 2021+ 默认开启 External Storage.idea/没有 .iml,模块配置在:

%LOCALAPPDATA%\JetBrains\IntelliJIdea<版本>\projects\<项目名>.<hash>\external_build_system\modules\<模块>.xml

给模块加 Python facet / orderEntry 就得改这个文件(改之前先关 IDEA,否则退出时会覆盖)。


6. 工程化:从一次性脚本到可用的类

6.1 单一入口,隐藏细节

最初把 md5()uuidV4() 都暴露在外面,用起来得自己拼字符串。后来收成:

public record CaptchaParams(String key, String token, String iv) { }

public static CaptchaParams buildCaptchaParams(long serverTimeMs, String captchaId) {
    return buildCaptchaParams(serverTimeMs, captchaId, DEFAULT_CAPTCHA_TYPE);
}

md5 / uuidV4 全部改 private,类改 final + 私有构造。对外只剩一个方法。

6.2 大数据挪出主类

702 条字符串表把主类淹了。拆成独立的 TRestoreTable,并且用 IDEA 认的折叠标记默认收起来:

//<editor-fold defaultstate="collapsed" desc="_0x2aea9b : obfuscated strings">
static final String[] TABLE = { ... };
//</editor-fold>

主类从 900+ 行降到 202 行。

代价:拆成两个文件后,java TRestore.java 单文件启动失效了(JEP 330 只编译指定的那个文件)。有得有失。

6.3 跨进程传递状态

流程天然分成三步(取图 → 识别 → 提交),但识别是 Python、其余是 Java,两次 Java 运行之间需要共享 captchaId / token / iv / Cookie。

解法:把会话存成 properties 文件。

public record Session(String captchaId, String captchaType, String version, String token,
                      String iv, long imageCacheBuster, String cookie, String referer) { }

6.4 一键脚本

最后收成一个:

.\run_slider.ps1            # 编译 → 取图 → 识别 → 提交
.\run_slider.ps1 -Debug     # 额外输出标注图
.\run_slider.ps1 -Save dir  # 存图,便于离线复跑

离线复跑这个能力非常重要:每次联网请求都是新实例,没法调试。把失败的样本存下来,才能反复分析。


7. 安全与边界

这部分必须写。

7.1 凭据不要进仓库

抓包里复制出来的 Cookie 里有 p_auth_token(JWT)。而这个项目有 GitHub 远端,且文件已经在 git 暂存区 —— 一旦提交推送,等于把登录态公开。

处理:

cx.local.properties       # 配置(captchaId / cookie / referer)
cx.session.properties     # 运行时会话(也含 Cookie)
*.session.properties
*.bak-*                   # 顺手把备份文件也挡掉

提交前检查暂存区:

git rm --cached src/main/java/javaversion/*.bak-*

7.2 在哪里停下

技术上一路走通之后,再加一个账号密码的 POST 就能完成自动登录。

我在这里停下了。

理由是:还原算法、识别图片、调 HTTP 客户端,这些是逆向学习和工程练习;再往前一步就是"绕过验证码做账号自动化",常见的用途是自动签到、刷课、批量登录 —— 违反平台协议,也有账号安全风险。

实际做法上,浏览器手动登录一次、把 Cookie 交给程序已经足够支撑自动化调试,而且这个"人工环节"刚好是个合适的边界。


8. 完整链路回顾

conf 接口 ──► serverTime + captchaId
                 │
                 ├─ key   = MD5(serverTime + UUID)
                 ├─ token = MD5(serverTime + captchaId + "slide" + key) + ":" + (serverTime + 300000)
                 └─ iv    = MD5(captchaId + "slide" + now + UUID)
                 │
image 接口 ──► imageToken + shadeImage + cutoutImage
                 │
多信号识别 ────► 距离
                 │
result 接口 ───► {"result":true}

9. 这次学到的东西

  1. 混淆只是壳:字符串表 + 偏移量是固定套路,写个脚本就还原了,别手抄。
  2. 文档会过时,源码不会ddddocr 的 README 和 1.6.1 的实际返回值不一致。
  3. 用数据否定假设,而不是靠感觉:每一步都算相关性、看剖面、跑多尺度,才能把"所有直觉方法都失败"变成一个明确的结论。
  4. 真值是调试的锚点:自己看不了图时,一句"大概在 180~220"直接把问题从"猜"变成"校准"。
  5. 预处理顺序会影响算法原理:alpha 混合 vs 二值掩膜,一个把边缘糊掉,结果从 182 跳到 8。
  6. 同一问题在不同样本上可能有不同成因:多信号 + 交叉验证 + 显式的低置信度警告,比单方法硬输出可靠。
  7. 工程细节会吃掉大量时间:PowerShell 编码、pip 临时目录、控制台 GBK、IDEA External Storage —— 都不难,但每个都能卡住半小时。
  8. 知道边界在哪:技术上能做完 ≠ 应该做完。

附:文件清单

javase/
├── src/main/java/javaversion/
│   ├── TRestore.java          封装后的 API(时间戳 + captchaId → key/token/iv)
│   ├── TRestoreTable.java     702 条混淆字符串表(IDEA 默认折叠)
│   └── CaptchaServer.java     HttpClient 请求:conf / image / result + 会话持久化
├── src/main/nodejs/
│   ├── t.js                   原始混淆文件
│   ├── t_restore.py           字符串表还原 + --dump / --deobf 工具
│   ├── slider_detect.py       多信号缺口识别 + 仲裁 + 标注图
│   ├── ddddocr_distance.py    独立的最小可用版本(兼容 1.5.x / 1.6.x)
│   ├── run_slider.ps1         一键全流程
│   └── slider/inst1..5/       失败/成功样本,用于离线回归
├── cx.local.properties        凭据(git-ignored)
└── cx.session.properties      运行时会话(git-ignored)

常用命令

# 还原混淆
python t_restore.py --dump 0x1fc
python t_restore.py --deobf t.js

# 一键全流程
cd src\main\nodejs
.\run_slider.ps1

# 离线复跑某个样本
python slider_detect.py --shade slider\inst1\shade.jpg --cutout slider\inst1\cutout.png --debug
1

评论区