声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!
前言
最近有粉丝投稿,之前写的文章失效了,说新版上了 wasm,而且轨迹也严格,手动滑动都不一定能过去,网上对此的文章也少之又少,本文就对该 demo 站做简单分析。
该demo站为新版,对轨迹的校验肯定是有的,而且不输大厂,能够用算法把轨迹搞到95+以上已经属于很牛的了。
逆向目标
-
目标:行为验证码(TAC)在线体验
-
网址:aHR0cHM6Ly9jYXB0Y2hhLnRpYW5haS5jbG91ZC8=
抓包分析
用浏览器打开进入指定页面,发现有多种验证类型,这里为了方便就直接分析缺口滑块了,点击加载验证码,会发现加载wasm以及请求图片等操作:
验证接口则有 3 个参数需要分析:
响应code为:50000,则轨迹被检测,提示验证码被黑洞吸走了,msg为 check fail
响应code为:4001,如坐标错误则提示验证码被黑洞吸走了,msg为 基础校验失败
响应code为:200,则代表通过,msg里返回token,进行下一步参数校验。
逆向分析:
首先是请求图片接口,接口返回乱序数组以及相关图片地址,可以参考往期文章,【验证码识别专栏】通杀滑动还原拼图验证码,这里不再重复提及,完整还原代码如下:
from PIL import Image
from io import BytesIO
def restore_image(restore_array, image_byte_data):
scrambled_image = Image.open(BytesIO(image_byte_data))
width, height = scrambled_image.size
image_pieces = []
piece_width = width // 5
piece_height = height // 2
for i in range(2):
for j in range(5):
x = j * piece_width
y = i * piece_height
piece = scrambled_image.crop((x, y, x+piece_width, y+piece_height))
image_pieces.append(piece)
restored_image = Image.new(‘RGB’, (width, height))
for index, position in enumerate(restore_array):
row = index // 5
col = index % 5
x = col * piece_width
y = row * piece_height
restored_image.paste(image_pieces[position], (x, y))
restored_byte_data = BytesIO()
restored_image.save(restored_byte_data, format=‘PNG’)
restored_byte_data.seek(0)
return restored_byte_data.read()
然后是提交接口,依旧从栈底进入,依次调试后在 validCaptcha 这个栈的位置断下,发现在 _0x5b316f['data'] = _0xfa9f47
中 data 为原始明文,里面包含了轨迹验证码大小等各个信息,随后经过 _0x5b316f = this[_0x27565a(0xe12, 'j)0U')][_0x27565a(0xdb2, 'axKn')](_0x27565a(0x155, 'pnM#'), _0x5b316f, _0x12d4a3, _0x30ee94) 发现明文做了加密,data变成了下面的格式。
跟栈后发现,最终进入了 preRequest 方法:
继续跟进,发现最终构造了一个数组数组,对原始数据进行依次编码。最终调用join拼接后返回:
将 _0x4ad4a6 这个大方法以及ob相关扣下即可,用node复现即可:
注意time时间戳需要传入,对成功率也是有影响的。
继续分析看到对加密后的数据再次进行了一层加密,_0x12d4a3[_0x27565a(0xd76, 'G6%x')](_0x5b316f[_0x27565a(0x81b, '$@gA')][_0x27565a(0x6f7, 'sPl4')]),跟进后发现他最终调用 window.__encrypt__完成加密,跟进后发现进入 wasm交互相关的js,调用 _makeFuncWrapper 完成相关的加密,接下来只需处理 wasm 就可以了。处理 wasm 可以用补环境或者反编译.c后算法分析,再或者用gcc将.c编译成.o后用 ida 进行分析。
接着继续分析,发现 drives 和 ki 生成位置如下:
经过分析发现,wasm初始化完成的时候,就已经生成了 drives 与 ki ,并且赋值给了window。
补环境分析
针对wasm补环境往期文章也说过,找到与 wasm 交互的相关文件,将相关文件导出到本地,用 WebAssembly 来加载我们导出的 JS,
缺少对应的环境加载报错,环境将dom部分方法补齐即可,都是常见的几个环境,全部补齐即可在初始化的时候生成所需的 drives 与 ki,以及window 下的 encrypt 方法,进行data参数的最后一轮加密。
纯算分析
对于该站,3 个参数是完完全全都校验的,ki 的生成需要与 drives 与 data 生成对应,ki 错误则返回验证码被黑洞吸走了,该站应该为 demo 站,所以正因为对应关系,ki 即可写死,那么反之drives 也可以写死,data则对应drives 写活,相信正式的站点估计会上动态匹配,ki 短期只可用一次,不可重复使用,那么风控将又上一个台阶,本文为了演示,3个参数都会相应提及,根据往期经验 data 与 drives 相同加密内容密文不变初步猜测他是 对称加密,ki 则大可能为非对称 RSA 加密。
wasm反编译的话,大多还是需要用到 wabt 工具,用 ./wasm2c main.wasm -o main.c 将 wasm 先反编译成 c 代码,这步已经代码相对可观了,更接近高级语言,经验丰富的可以直接分析,或者继续使用 gcc 编译c代码为.o 然后用 ida 进行分析。
加密算法通用代码特征无非是位移操作,查表操作,循环操作,通过观察反编译后的代码,搜索右移+掩码+查表的组合模式:
grep -B5 -A5 “>>= (var_i2 & 31)” main.c | grep -A5 “255u” | head -30
我们可以查到如下:
var_i1 = var_l7;
var_i2 = 8u;
var_i1 >>= (var_i2 & 31);
var_i2 = 255u;
var_i1 &= var_i2;
var_i2 = 96724u;
var_i1 += var_i2;
var_i1 = i32_load8_u_default32(&instance->w2c_memory, (u64)(var_i1));
进而定位附近代码到如下:
// 字节0(无位移)
var_i0 = var_l7;
var_i1 = 255u;
var_i0 &= var_i1;
var_i1 = 96724u; // S-box基址,较为重要
var_i0 += var_i1;
var_i0 = i32_load8_u_default32(…); // 查S-box
var_i1 = 2u;
var_i0 <<= (var_i1 & 31); // 左移2位
var_i1 = 104148u; // T0表
var_i0 += var_i1;
var_i0 = i32_load_default32(…); // 查T-table
// 字节1(右移8位)
var_i1 >>= 8;
var_i2 = 255u;
var_i1 &= var_i2;
var_i2 = 96724u; // 同样S-box
…
var_i2 = 103124u; // T1表
// 字节2(右移24位)
var_i2 >>= 24;
…
var_i3 = 96724u; // S-box
var_i3 = 101076u; // T2表
// 字节3(右移16位)
var_i3 >>= 16;
…
var_i4 = 96724u; // S-box
var_i4 = 102100u; // T3表
// 最后:XOR所有结果
var_i2 ^= var_i3;
var_i1 ^= var_i2;
var_i0 ^= var_i1;
回溯找到当前所在的加密函数,sed -n '298000,298822p' main.c | grep -n "^static void"如下:
static void w2c_main_f635(
w2c_main* instance, // WASM实例指针
u32 p0, // 参数0:输出缓冲区
u32 p1, // 参数1:输入数据指针
u32 p2, // 参数2:输入数据长度
u32 p3 // 参数3:IV相关
)
所以最终定位到 f635 为最终加密入口,基于刚刚我们的分析我们认为96724重复4次,大概率是S-box表的起始地址,刷新网页,我们在 635函数处断下,运行:
const memory = instance.exports.memory;
const view = new Uint8Array(memory.buffer);
const add = 96724;
const sbox = view.slice(add, add + 256);
console.debug(‘box’, Array.from(sbox.slice(0, 16)).map(b => ‘0x’ + b.toString(16).padStart(2, ‘0’)).join(’, '));
验证是AES加密后,我们知道加密过程中,密钥扩展将Key (16字节) → 扩展为 11轮密钥 (176字节),使用 Rcon 轮常数表,在 635 函数开始向下开始跟,
// func635内部:加载Key指针
var_i1 = 138792u; // ← 138792地址
var_i1 = i32_load_default32(&instance->w2c_memory, (u64)(var_i1));
var_l24 = var_i1; // Key存入var_l24
// 传递给密钥扩展函数
var_i0 = var_l6; // 输出缓冲区
var_i1 = var_l24; // ← Key指针作为参数
var_i2 = var_l13;
call $func634 // ← 调用密钥扩展函数
然后验证func634是密钥扩展函数:
void w2c_main_f634(w2c_main* instance, u32 var_p0, u32 var_p1, u32 var_p2) {
// var_p0: 输出缓冲区 (176字节,存放11轮密钥)
// var_p1: Key指针,就是138792加载的值
// var_p2: Key长度
// 读取Key的前4个字节
var_i0 = var_p1;
var_i0 = i32_load_default32(&instance->w2c_memory, (u64)(var_i0));
var_l5 = var_i0; // 存储到var_l5
// 读取Key的第5-8字节
var_i0 = var_p1;
var_i0 = i32_load_default32(&instance->w2c_memory, (u64)(var_i0) + 4);
var_l6 = var_i0;
// 读取Key的第9-12字节
var_i0 = var_p1;
var_i0 = i32_load_default32(&instance->w2c_memory, (u64)(var_i0) + 8);
var_l7 = var_i0;
// 读取Key的第13-16字节
var_i0 = var_p1;
var_i0 = i32_load_default32(&instance->w2c_memory, (u64)(var_i0) + 12);
var_l8 = var_i0;
// 写入第一轮密钥
var_i0 = var_p0;
var_i1 = var_l5;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0), var_i1);
var_i0 = var_p0;
var_i1 = var_l6;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 4, var_i1);
// 密钥扩展循环
var_i1 = var_l8;
var_i2 = 8u;
var_i1 >>= (var_i2 & 31); // 右移8位
var_i2 = 255u;
var_i1 &= var_i2; // 取字节
var_i2 = 96724u; // ← S-box基址
var_i1 += var_i2;
var_i1 = i32_load8_u_default32(&instance->w2c_memory, (u64)(var_i1)); // 查S-box
// XOR Rcon轮常数
var_i0 = var_l5;
var_i1 ^= var_i0; // Key[0] XOR Sbox XOR Rcon
var_l5 = var_i1; // 生成下一轮密钥的第一个字
}
然后走到这里已经解决大部分了,继续跟进加密流程,分析iv来源:
在CBC加密主循环之前的初始化阶段
block $label15 (result i32)
global.get $global1
i32.eqz
if
local.get $var24
i32.eqz
if
i32.const 0
local.set $var5
i32.const 0
local.set $var9
i32.const 0
br $label15
end
local.get $var6
i32.const 16
i32.add
local.set $var1
end
; 关键调用:将key作为IV
global.get $global1
i32.eqz
local.get $var15
i32.const 8
i32.eq
i32.or
if
local.get $var1 输出缓冲区
i32.const 139832
local.get $var24 key指针
i32.const 0 已有数据长度=0
i32.const 0 偏移
i32.const 16 复制长度=16字节
i32.const 1
call $func45 复制key作为IV
fun45函数:
local.get $var3
i32.eqz
br_if $label1
local.get $var7
local.get $var1
local.get $var3
local.get $var6
i32.mul
memory.copy
追加新数据
local.get $var7
local.get $var3
local.get $var6
i32.mul
i32.add 目标 + 偏移
local.get $var2
local.get $var5 新数据长度
local.get $var6 元素大小
i32.mul
memory.copy 复制新数据
至此AES全部流程分析完毕,data 与 drives 也就解决了,对比demo站ki写死即可,当然也可以溯源,只不过略复杂。经过分析可知w2c_main_f204 初步确认为 ki 流程函数:
var_i0 = 138792u;
var_i1 = var_l1;
var_i1 = i32_load_default32(&instance->w2c_memory, (u64)(var_i1) + 80u);
var_l0 = var_i1;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0), var_i1);
var_i0 = var_l1;
var_i1 = var_l0;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 116, var_i1);
var_i0 = var_l1;
var_i1 = 72u;
var_i0 += var_i1;
var_l0 = var_i0; // 准备传给func632的参数
w2c_main_f632(instance, var_i0);
var_i0 = 2u;
var_i1 = instance->w2c_g1;
var_i2 = 1u;
var_i1 = var_i1 == var_i2;
if (var_i1) {goto var_B1;}
var_j0 = 9221120241336057861ull;
var_i1 = 0u;
var_i2 = 127860u; // 字符串__ki__
var_i3 = 10u; // "ki"的长度
var_i4 = 129196u;
var_i5 = var_l0; // 密文长度
w2c_main_f178(instance, var_j0, var_i1, var_i2, var_i3, var_i4, var_i5);
var_i0 = 5u;
var_i1 = instance->w2c_g1;
var_i2 = 1u;
var_i1 = var_i1 == var_i2;
if (var_i1) {goto var_B1;}
在wat 或 .o 文件可以找到公钥相关信息:
但是作者岂会让你如此如愿
0d\f3\01\00\ac\12\01\00js.ref\00\00\d5\00\00\00D\f3\01\00\cb\00\00\00l\f3\01\00\d5\00\00\00d\f3\01\00__drives__\00\00Q\00\00\00\88\f3\01\00\d5\00\00\00\80\f3\01\00__ki__\0d\00Value\00\00\10id\00\00\ca\00\00\00\ac\f3\01\00\d5\00\00\00\a4\f3\01\00__encrypt__MIGfMA0GCSqGSIb3DQEBAQUAA4GNAtbkxk+9ZzgxbYe5rrOXAPj+PZz+2bDCBiQKBgQDArgKannXgSG/3J1L009FZ0W32bR3wuY6TDoyzKmmLcWTmHP5ZdCsIhvSxZQxZ2sQteJMcHDTK7g0RBcPvdUtWfQIDAQAB9wXBm9SJyCN0nc3h6TL6fwaJJwELWwkJiVd/Fp2qtZPVsCk09opKQiXpostmanPostmanRuntimePostman
这组秘钥是经过打乱排序的,经过验证这个秘钥是完成不了加密的,目的就是为了防止静态提取,增加逆向难度。
我们初步可以判断秘钥是错误的,或者说是正确的但是需要经过多轮复原。经过验证秘钥的打乱重组逻辑藏在wasm中,在func632在执行RSA加密前,将8个片段的地址和长度存储到栈上,相关代码分析如下:
//长度29
var_i0 = var_l1;
var_i1 = 127935u;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 488, var_i1);
var_i0 = var_l1;
var_i1 = 29u;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 492, var_i1);
//长度22 (存储到 var_l1+496)
var_i0 = var_l1;
var_i1 = 127993u;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 496, var_i1);
var_i0 = var_l1;
var_i1 = 22u;
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 500, var_i1);
…
…
//长度28 (存储到 var_l1+520)
var_i0 = var_l1;
…
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 520, var_i1);
…
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 524, var_i1);
//127964, 长度29 (存储到 var_l1+528)
var_i0 = var_l1;
…
i32_store_default32(&instance->w2c_memory, (u64)(var_i0) + 532, var_i1);
// line 289780-289820: 循环拼接8个片段
var_L6:
var_i0 = var_l3;
var_i1 = 8u;
var_i0 += var_i1;
var_l4 = var_i0;
var_i1 = 72u;
var_i0 = var_i0 == var_i1;
var_l6 = var_i0;
if (var_i0) {goto var_B4;}
感兴趣的可以自己去静态分析:
存储顺序
栈偏移
内存地址
长度
片段编号
打乱位置
正确位置
1
+488
127935
29
#1
0-*
0-29
2
+496
127993
22
#3
*-80
29-*
3
+504
128045
23
#5
110-*
51-*
4
…
…
…
…
…
…
最终经过完整流程将正确的公钥复原进行加密即可完成ki的复现,最终全部参数全部算法解决,在demo介绍里面他是有提及轨迹校验的,使用逆向完成的参数我们进行验证测试几轮(均用快代理私密进行测试):
一轮算法轨迹验证:
二轮算法轨迹验证:
整体来说,检测力度还是比较大的,此外在同轨迹生成同一ip多次请求与同轨迹不同ip多次请求下成功率还是不同的,再用时序网络来测试下成功率。
一轮AI轨迹验证:
二轮AI轨迹验证:
可以观察到时序网络的状态还是很稳定的,可以查阅相关资料学习(后续有机会写篇相关内容文章),基本只要识别对基本100%都可以过去。
算法轨迹如下:
import random
from typing import List, Dict
class TrackGenerator:
def generate(self, distance: int, start_x: int = 540, start_y: int = 480) -> List[Dict]:
track = []
track.append({
“x”: start_x,
“y”: start_y,
“type”: “down”,
“t”: 0
})
t = random.randint(8, 12)
track.append({
“x”: start_x + random.choice([-1, 0, 1]),
“y”: start_y + random.choice([0, 1]),
“type”: “move”,
“t”: t
})
t += random.randint(38, 50)
t = self._generate_three_phase_track(track, start_x, start_y, distance, t)
t += random.randint(8, 15)
track.append({
“x”: track[-1][“x”],
“y”: track[-1][“y”],
“type”: “up”,
“t”: t
})
return track
def _generate_three_phase_track(self, track: List[Dict], start_x: int, start_y: int,
distance: int, start_t: int) -> int:
current_x = start_x
current_y = start_y
current_t = start_t
y_total = random.randint(10, 15)
phase1_distance = distance * random.uniform(0.28, 0.35)
phase1_points = random.randint(3, 5)
p0_x, p0_y = current_x, current_y
p1_x = current_x + phase1_distance * 0.4
p1_y = current_y + y_total * 0.2
p2_x = current_x + phase1_distance * 0.7
p2_y = current_y + y_total * 0.4
p3_x = current_x + phase1_distance
p3_y = current_y + y_total * 0.5
for i in range(1, phase1_points + 1):
t_val = i / phase1_points
t_val = 1 - (1 - t_val) ** 2
x, y = self._cubic_bezier(t_val, p0_x, p0_y, p1_x, p1_y, p2_x, p2_y, p3_x, p3_y)
y += random.uniform(-0.5, 0.5)
current_t += random.randint(8, 10)
track.append({
“x”: int(round(x)),
“y”: int(round(y)),
“type”: “move”,
“t”: current_t
})
current_x = track[-1][“x”]
current_y = track[-1][“y”]
remaining_distance = start_x + distance - current_x
phase2_points = random.randint(15, 17)
p0_x, p0_y = current_x, current_y
p1_x = current_x + remaining_distance * 0.3
p1_y = current_y + y_total * 0.25
p2_x = current_x + remaining_distance * 0.7
p2_y = start_y + y_total * 0.95
p3_x = start_x + distance
p3_y = start_y + y_total
for i in range(1, phase2_points + 1):
t_val = i / phase2_points
t_val = t_val ** 3
x, y = self._cubic_bezier(t_val, p0_x, p0_y, p1_x, p1_y, p2_x, p2_y, p3_x, p3_y)
y += random.uniform(-0.3, 0.3)
progress = i / phase2_points
if progress < 0.5:
interval = random.randint(8, 10)
elif progress < 0.85:
interval = random.randint(8, 12)
else:
if i < phase2_points:
interval = random.randint(8, 15)
else:
最后一个点:大停顿
interval = random.randint(65, 75)
current_t += interval
track.append({
“x”: int(round(x)),
“y”: int(round(y)),
“type”: “move”,
“t”: current_t
})
return current_t
def _cubic_bezier(self, t: float, p0_x: float, p0_y: float,
p1_x: float, p1_y: float, p2_x: float, p2_y: float,
p3_x: float, p3_y: float) -> tuple:
mt = 1 - t
mt2 = mt * mt
mt3 = mt2 * mt
t2 = t * t
t3 = t2 * t
x = mt3 * p0_x + 3 * mt2 * t * p1_x + 3 * mt * t2 * p2_x + t3 * p3_x
y = mt3 * p0_y + 3 * mt2 * t * p1_y + 3 * mt * t2 * p2_y + t3 * p3_y
return x, y
共同学习,写下你的评论
评论加载中...
作者其他优质文章


















