-
-
[原创]GameGuard NP CS_Auth3协议
-
发表于: 1天前 259
-
GameGuard CSAuth3 心跳协议逆向分析
虽然GameGuard 或者说棒子那边的反外挂已经基本上距离ACE/BE水平还差5个EAC?
但是做为技术分析还是有点用?
毕竟换汤不换药
一、协议是什么
CSAuth3 是 GameMon(客户端反作弊)和对端之间的心跳。问答循环:对端发挑战包,客户端回心跳包,断了就判掉线。
客户端 对端
GameMon CSAuth3 实例
│ ◄──── 挑战包 ──────────────│ Get
│ ────── 心跳包 ───────────►│ Check
└──────── 循环 ──────────────┘
二、完整时序
客户端 GameMon 对端 CSAuth3
│ counter=0, prev_size 初始 │
│ │
│ ◄──── 挑战包 (头16+载荷56+CRC2+CRC1) │
│ │
│ ① 解密头: KDF XOR, K=derive(counter)│
│ ② 校验 magic / seq / CRC1 │
│ ③ descramble 载荷 (Blowfish 解密方向)│
│ ④ 解析 TLV 消息, 更新 state │
│ ⑤ 取消息1的 prev_size (下轮 CRC2 key)│
│ ⑥ 构造心跳包载荷 (8 对齐) │
│ ⑦ scramble 载荷 (空载荷跳过) │
│ ⑧ 算 CRC2 / CRC1 │
│ ⑨ 加密头: KDF XOR, K=derive(counter)│
│ │
│ ──── 心跳包 (头16+载荷+CRC2+CRC1) ──► │
│ │
│ counter++ │
│ │
└──── 循环, counter 递增驱动 KDF 与 CRC2 key
三、包结构
客户端包:
┌────────┬──────────────┬───────┬───────┐
│ 头 16B │ 载荷 (8对齐) │ CRC2 │ CRC1 │
│ KDF加密│ scramble+TLV │ 2B │ 2B │
└────────┴──────────────┴───────┴───────┘
↑size-4 ↑size-2
对端包: 同结构 (头16 + 载荷56 + CRC2 + CRC1)
头部明文(KDF解密后):
struct CsAuth3Header {
uint16_t magic; // [0-1] 0x001E
uint16_t seq; // [2-3] 序号, ≤5
uint16_t msg_count; // [4-5] 期望消息数
uint16_t flags; // [6-7] bit0=错误码映射 bit2=首包
uint8_t protocol; // [8] 协议号 (首包=0)
uint8_t pad[7]; // [9-15]
};
四、完整往返示例(首包, counter=0)
【对端发来】 76B
0C 47 29 91 27 8E 52 22 36 D5 78 B3 48 1C A1 44 <- 头(密文)
C5 83 18 C9 C8 F8 43 40 06 70 09 20 B8 79 4C A9 <- 载荷(scramble后)
28 4D 4D 3B FA CB E7 9D 10 96 F2 AC E1 23 08 CB
CB E4 91 8F 92 37 C6 C1 9B 78 E2 B9 47 F9 98 F9
39 A0 BB 9F 36 4C CE 6F <- 载荷尾
67 5B <- CRC2
8B 51 <- CRC1
解密头 (XOR K=0x91284712):
1E 00 01 00 03 00 02 00 00 00 00 00 00 00 00 00
magic=0x001E seq=1 msg_count=3 flags=0x0002(首包) protocol=0
CRC1 算出来 0x518B,包里存 8B 51,对得上
descramble 载荷 (Blowfish) → 明文 TLV
CRC2 算出来 0x5B67,覆盖「解密头 + descramble 后载荷」
【客户端回发】 28B
0C 47 29 91 27 8E 52 22 36 D5 78 B3 48 1C A1 44 <- 头(同K, 同counter)
00 00 00 00 00 00 00 00 <- 载荷(8字节全0, 空载荷不scramble)
00 00 <- CRC2 = 0
EB 0B <- CRC1
解密头: 同对端包头
CRC1 算出来 0x0BEB,包里存 EB 0B,对得上。
CRC2 = 0x0000(首包空载荷,下面讲)。
两端头相同(同 K=0x91284712, counter=0)。客户端首包载荷 8 字节全 0,空载荷不 scramble。
五、头部 KDF + XOR
uint32_t derive_K(uint32_t seed) {
uint32_t K = (seed == 0) ? 0x91284712 : seed;
if (K >= 100) K %= 100;
if (K % 100 != 0) {
for (int i = 0; i < K % 100; i++) {
K = K * 0x27D2 - ((uint64_t)K * 0x76C4E92B >> 16) * 0x55D49D5A;
if (K == 0) K = 0x7FFFFFFF;
}
}
return K;
}
void encrypt_header(uint8_t plain[16], uint32_t K, uint8_t out[16]) {
uint32_t key[4] = { K, 2*K, 3*K, 4*K };
for (int i = 0; i < 16; i++)
out[i] = plain[i] ^ ((uint8_t*)key)[i];
}
首包 K=0x91284712,密钥流 = 12 47 28 91 24 8E 50 24 36 D5 78 B3 48 1C A1 44。
首包 seed=0(包计数器清零后),后续包 seed=counter。
六、CRC1(固定多项式)
uint16_t crc1(const uint8_t *data, int len) {
uint16_t crc = 0;
for (int i = 0; i < len; i++) {
uint16_t e = data[i] << 8;
for (int b = 0; b < 8; b++) {
if ((e ^ crc) & 0x8000)
crc = (crc ^ 0x0FAE) << 1;
else
crc <<= 1;
e <<= 1;
}
}
return ~crc;
}
// 范围 [0 .. size-3],存 [size-2],小端
76B 首包: crc1(pkt[0:74]) = 0x518B = 存储值 8B 51
七、CRC2(keyed)
uint16_t crc2(const uint8_t *data, int len, uint16_t key16) {
uint16_t crc = 0;
for (int i = 0; i < len; i++) {
uint16_t br = data[i] << 8;
for (int b = 0; b < 8; b++) {
uint16_t t = br ^ crc;
crc <<= 1; // shift 先
if (t & 0x8000) crc ^= key16; // xor 后
br <<= 1;
}
}
return ~crc;
}
// 覆盖 = 解密头(16) + descramble后载荷, 范围 [0 .. size-5],存 [size-4],小端
key 派生:
uint16_t derive_crc2_key(uint16_t counter, uint32_t prev_size) {
uint32_t key;
if (counter == 0)
key = 0x245D96E8;
else
key = ((prev_size * 0x4912 - 0x918) * prev_size) * 0x9184;
return key & 0xFFFF; // 只用低16位 = 0x96E8 (counter=0)
}
76B 首包: crc2(解密头+descramble后载荷, 0x96E8) = 0x5B67 = 存储值。注意 CRC2 算的是 descramble 之后的载荷,所以构造包时必须先 scramble 再算 CRC2。
八、载荷 scramble(Blowfish)
载荷用 Blowfish 变换
8.1 常量
// 模块内置 Blowfish π 常量 (初始化用, 标准 Blowfish P-array/S-box)
// P_init[18] 以 0x243F6A88 开头; S_init[4][256] 标准 Blowfish S-box
// (这两个表是公开的 Blowfish 标准, 任何 Blowfish 实现都有)
// 硬编码密钥 (8 字节, 内存序):
static const uint8_t SCRAMBLE_KEY[8] = {
0x9F, 0x5A, 0x86, 0x03, 0x7E, 0x72, 0xA3, 0x30
};
// 按 big-endian 取两个 32 位: K32[0]=0x9F5A8603, K32[1]=0x7E72A330
8.2 密钥编排(一次性,生成会话 P/S)
uint32_t P[18]; // 会话 P-array
uint32_t S[4][256]; // 会话 S-box
uint32_t F(uint32_t x) {
uint32_t t = (S[1][(x >> 16) & 0xFF] + S[0][(x >> 24) & 0xFF]) & 0xFFFFFFFF;
t ^= S[2][(x >> 8) & 0xFF];
return (t + S[3][x & 0xFF]) & 0xFFFFFFFF;
}
// 加密方向块变换 (密钥编排内部用, P 正序)
void bf_enc(uint32_t *l, uint32_t *r) {
uint32_t L = *l, R = *r;
for (int i = 0; i < 16; i++) { L ^= P[i]; R ^= F(L); uint32_t t=L; L=R; R=t; }
uint32_t t = L; L = R; R = t; // 撤销最后一次交换
R ^= P[16]; L ^= P[17];
*l = L; *r = R;
}
void scramble_key_schedule(void) {
memcpy(P, P_init, sizeof(P));
memcpy(S, S_init, sizeof(S));
// ① 密钥异或: 偶数 P ^= K32[0], 奇数 P ^= K32[1]
uint32_t K32[2] = { 0x9F5A8603, 0x7E72A330 };
for (int i = 0; i < 18; i++) P[i] ^= K32[i & 1];
// ② 用加密方向更新 P 和 S (标准 Blowfish 密钥扩展)
uint32_t L = 0, R = 0;
for (int k = 0; k < 18; k += 2) { bf_enc(&L, &R); P[k] = L; P[k+1] = R; }
for (int t = 0; t < 4; t++)
for (int k = 0; k < 256; k += 2) { bf_enc(&L, &R); S[t][k] = L; S[t][k+1] = R; }
}
8.3 载荷变换(scramble / descramble)
scramble 用解密方向(P 逆序),逐 8 字节块就地变换:
// 解密方向块变换 (载荷 scramble 用, P 逆序)
void bf_dec(uint32_t *l, uint32_t *r) {
uint32_t L = *l, R = *r;
for (int i = 17; i >= 2; i--) { L ^= P[i]; R ^= F(L); uint32_t t=L; L=R; R=t; }
uint32_t t = L; L = R; R = t; // 撤销最后一次交换
R ^= P[1]; L ^= P[0];
*l = L; *r = R;
}
// 对载荷 [data+16 .. size-4) 逐 8 字节块就地 scramble
void scramble_payload(uint8_t *data, int size) {
for (int off = 16; off + 8 <= size - 4; off += 8) {
uint32_t l, r;
memcpy(&l, data + off, 4);
memcpy(&r, data + off + 4, 4);
bf_dec(&l, &r); // scramble = 解密方向
memcpy(data + off, &l, 4);
memcpy(data + off + 4, &r, 4);
}
}
收包 descramble 与发包 scramble 用同一个 bf_dec(解密方向),因为 Blowfish 解密是加密的逆;发送方把明文 TLV 跑一遍 bf_dec 得到线上密文,接收方再跑一遍 bf_dec 还原——所以"scramble"和"descramble"是同一函数,靠 Blowfish 的加解密对称性工作。
8.4 拿首包跑一遍
首包 76B 载荷 (线上 scramble 后, 56B):
C5 83 18 C9 C8 F8 43 40 06 70 09 20 B8 79 4C A9
28 4D 4D 3B FA CB E7 9D 10 96 F2 AC E1 23 08 CB
CB E4 91 8F 92 37 C6 C1 9B 78 E2 B9 47 F9 98 F9
39 A0 BB 9F 36 4C CE 6F
scramble 后 (bf_dec 跑一遍, 56B):
F3 F5 08 00 F3 F5 DE DE DD 40 14 00 19 00 00 00
2A 24 8A CE 6E 24 BD B9 0E F9 99 33 17 E8 16 00
0E 00 00 00 0C 07 0D 09 0E 0F 0F 09 0F 0D 0E 0C
0D 0C 00 00 00 00 00 00
拿这 56 字节加上解密头一起算 CRC2(key=0x96E8),得 0x5B67,跟包里存的 67 5B 一致。
密钥写死、常量是公开的 Blowfish π,P/S 编排结果确定,所以 scramble 能离线复现,不依赖运行时状态。
九、错误码
发包后对端返回的错误码(抓包/自己黑盒测试不一定对,我自己的理解):
| 码 | 含义 |
|---|---|
| 0xBAE | 首包标志(正常) |
| 0xBB8 | CRC2 不匹配(映射模式基值) |
| 0xCE5 | seq > 5 |
| 0xCE8 | CRC2 不匹配(严格) |
| 0xCE9 | CRC1 不匹配 |
| 0xCEA | magic ≠ 0x1E |
| 0xCEC | size < 16 |
| 0xCEE | size > 0x1000 |
| 0xCEF | size < 16(body) |
| 0xD4A | body 未 8 对齐 |
| 0xD52 | 消息数不符 |
| 0xD67 | 消息类型2 失败 |
| 0xD7A | 消息类型11 内容不符 |
| 0xD913 | 消息类型1 哈希失败 |
| ≥0xBB8 | 置错误标志 → 后续 Check 假通过、Get 停发 |
十、TLV 消息(载荷)
struct TlvMsg {
uint16_t type;
uint16_t length; // 含头
uint32_t value;
// 后续字段按类型定
};
| type | 含义 | 关键操作 |
|---|---|---|
| 1 | 同步 | hash = value² × msg.word2 × 0x622B60B;同步下个包大小(CRC2 key源) |
| 2 | 认证 | 设 ID/IP;查表校验 |
| 3 | 日志 | — |
| 10 | 配置 | — |
| 11 | 查表 | 索引 ≤ 50 |
| 12 | 列表 | ≤10 项 × 10 字节 |
| 13 | 扩展 | 按模式分支 |
| 20-24 | 状态 | 写状态字段,含 magic 0x9128/0x8757/0x4813 |
msg_count(头 word2)= 期望消息数。
十一、State 结构(1456 = 0x5B0 字节)
会话状态结构(双端各自维护一份):
struct CsAuth3State { // offset
uint32_t magic; // +0x04 0x11223344
uint32_t flags; // +0x08 bit0/2/3
uint16_t word1; // +0x5A 消息1同步
uint32_t prev_size; // +0x5C CRC2 key源
uint16_t first_pkt; // +0x60
char *user_id; // +0x64
char *ip_addr; // +0x68
uint16_t counter; // +0x6C KDF种子
char *exinfo; // +0x70
uint8_t list[100*10]; // +0x47C 消息12
uint16_t list_count; // +0x4E0
uint32_t ext_info[6]; // +0x4E8 消息13
uint16_t status[4]; // +0x502 消息20-24
uint8_t ready; // +0x510
uint32_t error_flag; // +0x5A8 ≥0xBB8置位
};
十二、首包特征
首包(counter=0):
- K = 0x91284712(固定)
- CRC2 key = 0x245D96E8(低16=0x96E8,固定)
- flags bit2 = 1(首包标志)
- 客户端首包载荷 = 8字节全0,空载荷不 scramble
- 客户端首包 CRC2 = 0x0000
后续包(counter≠0):
- K = derive_K(counter),counter 经 KDF 迭代
- CRC2 key =
((prev_size × 0x4912 − 0x918) × prev_size) × 0x9184的低16位 - prev_size 由消息类型1 同步
- 载荷有 TLV 内容时需 scramble
十三、识别(C 伪代码)
// 用 K 解密前 16 字节,检查 magic 判定是否 CSAuth3 包
int is_csauth3(const uint8_t *pkt, int len, uint16_t counter) {
if (len < 16) return 0;
uint32_t K = derive_K(counter);
uint8_t head[16];
encrypt_header(pkt, K, head); // encrypt = decrypt (XOR 自逆)
return head[0] == 0x1E && head[1] == 0x00;
}
// 完整解析,字段填入 out
int parse_csauth3(const uint8_t *pkt, int len, uint16_t counter, CsAuth3Header *out) {
if (!is_csauth3(pkt, len, counter)) return 0;
uint32_t K = derive_K(counter);
encrypt_header(pkt, K, (uint8_t*)out);
out->size = len;
out->body = (uint8_t*)(pkt + 16);
out->body_len = len - 16 - 4;
out->crc2 = *(uint16_t*)(pkt + len - 4);
out->crc1 = *(uint16_t*)(pkt + len - 2);
out->crc1_ok = (crc1(pkt, len - 2) == *(uint16_t*)(pkt + len - 2));
return 1;
}
拿首包(counter=0)跑一遍识别:
| 包 | size | magic | seq | msg_count | flags | protocol | crc1 | crc1_ok |
|---|---|---|---|---|---|---|---|---|
| 对端 76B | 76 | 0x001E | 1 | 3 | 0x0002 | 0 | 0x518B | 算出来一致 |
| 客户端 28B | 28 | 0x001E | 1 | 3 | 0x0002 | 0 | 0x0BEB | 算出来一致 |
十四、伪造客户端包
void build_client_pkt(uint8_t *out, uint16_t counter, uint32_t prev_size,
uint8_t *tlv_payload, int payload_len) {
// 1. 头
uint32_t K = derive_K(counter);
CsAuth3Header hdr = { 0x001E, 1, msg_count, flags, 0, {0} };
memcpy(out, &hdr, 16);
// 2. 载荷补齐 8 字节
int body_len = payload_len;
memcpy(out + 16, tlv_payload, body_len);
while (body_len % 8) out[16 + body_len++] = 0;
int size = 16 + body_len + 4;
// 3. scramble 载荷 (空载荷跳过)
if (body_len > 0) scramble_payload(out, size);
// 4. CRC2 (覆盖 解密头 + scramble后载荷)
uint16_t k2 = derive_crc2_key(counter, prev_size);
*(uint16_t*)(out + size - 4) = crc2(out, size - 4, k2);
// 5. CRC1 (覆盖整包到 size-2)
*(uint16_t*)(out + size - 2) = crc1(out, size - 2);
// 6. 加密头 (就地)
encrypt_header(out, K, out);
}
载荷需含至少消息类型1(同步包大小),否则后续 CRC2 key 源对不上。
十五、抓包要点
- 包无明文特征,前2字节密文随 K 变化(首包固定
0C 47) - 识别靠:用 K=0x91284712 解密前16字节,检查
1E 00 - 包长 76(首包)或 8 的倍数+20(头+CRC)
- 包尾 4 字节 = CRC2(2) + CRC1(2),客户端/对端包同样
总结
CSAuth3 心跳是一套自包含的对称协议,全部密码学材料都是硬编码常量:
| 环节 | 算法 | 密钥来源 |
|---|---|---|
| 头加密 | XOR 流密码, 密钥流 {K,2K,3K,4K} | K = KDF(counter), counter=0 时 K=0x91284712 |
| 载荷 scramble | Blowfish (解密方向) | 固定密钥 0x9F5A8603/0x7E72A330 + 标准 π 常量 |
| CRC1 | CRC-16, poly 0x0FAE, xor-then-shift | 无密钥 |
| CRC2 | CRC-16, shift-then-xor, keyed | counter=0 时 0x96E8; 否则 prev_size 派生 |
唯一随会话变化的是 counter(驱动 KDF 和 CRC2 key)与 prev_size(由消息1同步)。其余全是固定常量。因此只要拿到首包挑战、复现密钥编排,就能纯脱机心跳
State 1456 会根据版本协议 老版本应该有0x61x系列,auth2 应该是16byte
但是gameguard应该是已经摆烂了,这个分析是基于24年 但是一直更新只是驱动稳定性问题
最后嫌长不看版
客户端有个callback 里面根据参数 然后收到挑战包 然后客户端自己走自己发送发过去
那么你注入进去hook到这个callback handler 就知道发什么了,转发一下也可以
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。