猿人学 App 第二十八题「GoNDK」离机复现:从 Go 二进制认出 Tiger 哈希

本文最后更新于:2026年9月3日 下午

[猿人学 App 逆向系列]第二十八题「一池新水碧荷圆【GoNDK】」。签名由 Go 写的 NDK 库(gomobile 打的 libgojni.so)计算。
Go 二进制乍看是个 1.4MB 的黑盒,但它有个”送分”特性——panic 信息里带着源码文件路径,一眼就能看出用了什么。本文记录:靠源码路径认出 Tiger 哈希 → Frida hook 对拍 → 从 so 里抠 S-box 做纯 Python 还原 → 补上 token 这道坑。

⚠️ 猿人学闯关 App 是面向逆向练习的 CTF 式靶场;本文仅用于授权范围内的学习研究,请遵守相关服务条款与法律。

本文目录

  • 请求模型:content 和第一题一样
  • Go 二进制的”送分题”:panic 路径暴露源码
  • Frida hook getSign 对拍
  • 从 libgojni.so 抠出 Tiger 的 S-box
  • 补最后一道坑:token
  • 纯 Python 复现

一、请求模型:content 和第一题一样

ChallengeTwentyEightFragment 里:

1
2
3
4
5
6
7
8
9
10
11
static { System.loadLibrary("gojni"); }       // Go 的 gomobile 产物
// com.yuanrenxue.challenge.twentyeight.sign.Sign
public static native String getSign(byte[] bArr);

// 翻页:
StringBuilder sb = new StringBuilder();
sb.append("page="); // (混淆字符串,解出来是 "page=")
sb.append(page);
sb.append(System.currentTimeMillis());
String sign = Sign.getSign(sb.toString().getBytes()); // content = "page="+page+ms
api.OooOo0o(page, sign, ms); // POST /api/app28 @Field page/sign/ts

**content = "page=" + page + ms**——和第一题一模一样的格式(第一题那次是 dex 层 MD4)。这次 getSign 是 Go native,得进 libgojni.so 里看。

二、Go 二进制的”送分题”:panic 路径暴露源码

Go 的 release 库体积大、符号也常被裁,但有个几乎没法去掉的特征:panic/错误信息里嵌了编译时的源码文件绝对路径(file:line)。strings 一扫:

1
2
3
4
5
$ strings libgojni.so | grep '\.go'
.../challenge-twenty-eight/sign/sign.go
.../challenge-twenty-eight/sign/compress.go
.../challenge-twenty-eight/sign/tiger.go # <-- Tiger!
...runtime/... (标准库)

还有 challenge-twenty-eight/sign.(*digest).Reset / .Sum 这种方法名。**tiger.go + compress.go + digest = Tiger 哈希**——一个 192 位(24 字节 / 48 hex)的老牌哈希。方向瞬间明确:签名就是 Tiger(content)

Go 逆向的第一步永远是 strings | grep .go——它经常直接把作者的意图(用了哪个算法/库)写在脸上。

三、Frida hook getSign 对拍

猜到 Tiger,先用 Frida 抓一条真机 (content -> sign) 坐实。getSign 是标准 JNI 导出 Java_..._Sign_getSign(JNIEnv*, jclass, jbyteArray),用 JNI 函数表读入参数组、读返回串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Frida 17:Process.findModuleByName;JNI 函数表 GetArrayLength=171 / GetByteArrayElements=184 / GetStringUTFChars=169
var addr = Process.findModuleByName("libgojni.so")
.findExportByName("Java_com_yuanrenxue_challenge_twentyeight_sign_Sign_getSign");
Interceptor.attach(addr, {
onEnter(a){ var e=a[0], ft=e.readPointer();
var L=new NativeFunction(ft.add(171*8).readPointer(),'int',['pointer','pointer']);
var G=new NativeFunction(ft.add(184*8).readPointer(),'pointer',['pointer','pointer','pointer']);
this.env=e; this.content=G(e,a[2],ptr(0)).readUtf8String(L(e,a[2]));
},
onLeave(ret){ var e=this.env,ft=e.readPointer();
var S=new NativeFunction(ft.add(169*8).readPointer(),'pointer',['pointer','pointer','pointer']);
send("content="+this.content+" sign="+S(e,ret,ptr(0)).readCString());
}
});

抓到:content = "page=11788414311409",sign = 4f4b4a5335c657e68f1cfae616291d8bebf8b69501e1e763

rhash --tiger 一对:

1
2
$ printf 'page=11788414311409' | rhash --tiger -
4f4b4a5335c657e68f1cfae616291d8bebf8b69501e1e763 # 逐位一致 ✓

确认:sign = 标准 Tiger/192(小端字节序输出)。

四、从 libgojni.so 抠出 Tiger 的 S-box

要做纯 Python 客户端,得有 Tiger。Python 没有内置 Tiger,而它的 4 张 S-box 是 8KB 固定常量,自己按官方口令生成很容易写错(我第一版就错了)。最稳的办法:直接从 libgojni.so 的 .rodata 里把 S-box 抠出来。

Tiger 的 t1[0] 是公开已知的 0x02AAB17CF7E90C5E,拿它当锚点:

1
2
3
4
5
import struct
d = open("libgojni.so","rb").read()
i = d.find(struct.pack("<Q", 0x02AAB17CF7E90C5E)) # 命中 -> S-box 起点
tables = struct.unpack("<1024Q", d[i:i+1024*8]) # 4×256 个 uint64
# 校验:t2[0]=0xE6A6BE5A05A12138, t3[0]=0xF49FCC2FF1DAF39B, t4[0]=0x5B0E608526323C55 ✓

有了正确的 S-box,自己写的 Tiger 压缩函数(3 趟、乘子 5/7/9、key_schedule、0x01 填充)立刻就对了:Tiger("") = 3293ac63…(标准测试向量),样本也逐位一致。把这 8KB base64 内嵌进脚本即可。

五、补最后一道坑:token

算法对了,直接请求却回 {"error": "pass"}(403)。抓一条真机 app28 请求对比才发现:除了 page/sign/ts,请求还带了一个 token(登录态)。补上就 200 了:

1
2
3
POST /api/app28
token: <登录 token> # <-- 缺这个就 {"error":"pass"}
page=1&sign=<tiger>&ts=<ms>

token 存在设备 MMKV 的 com.yuanrenxue.match.utils.KEY_TOKEN,过期换新即可。

六、纯 Python 复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import time, requests
# tiger(): 上面从 so 抠出的 S-box + 标准 Tiger 压缩(见完整脚本)

TOKEN = "<设备 MMKV KEY_TOKEN>"
def sign(page, ms): return tiger(("page=%d%d" % (page, ms)).encode())

sess = requests.Session(); total = 0
for page in range(1, 101):
ms = int(time.time()*1000)
r = sess.post("https://www.python-spider.com/api/app28",
data={"page":page, "sign":sign(page,ms), "ts":ms},
headers={"token":TOKEN, "User-Agent":"okhttp/4.9.2"}, timeout=15).json()
total += sum(int(x["value"]) for x in r["data"]) # 每页 10 个数
print("ANSWER =", total)

自检(Frida 那条)+ 服务器 200 双通过,逐页求和即通关。

小结

第二十八题的破解链很”教科书”:

  1. **Go 二进制先 strings | grep .go**——tiger.go 直接点名算法;
  2. Frida hook getSign 对拍坐实是标准 Tiger(别忘了 Frida 17 用 Process.findModuleByName + JNI 函数表读 jbyteArray/jstring);
  3. 纯 Python 需要 S-box 时,从 so 里抠比自己生成靠谱——用已知首值 0x02AAB17CF7E90C5E 当锚点一抓一个准;
  4. **算法对了还进不去,就去抓真机请求补”环境”**——这题少的是 token

一句话:Go/gomobile 看着唬人,但它把源码路径写在 panic 里;认出经典算法 + 从二进制抠常量,比硬啃汇编省事得多。


猿人学 App 第二十八题「GoNDK」离机复现:从 Go 二进制认出 Tiger 哈希
https://kingjem.github.io/2026/09/03/猿人学App第二十八题离机复现-从Go二进制认出Tiger哈希/
作者
Ruhai
发布于
2026年9月3日
许可协议