nProtect BlackCipher 反作弊分析
本文是对跑跑卡丁车(PopKart)内置反作弊组件 nProtect BlackCipher 的分析。
内容综合自对运行进程的 IPC 探测、动态调试对抗、内存 dump 与内核组件提取的实测结果。
目标信息
INCA Internet(韩国)是 nProtect 系列反作弊厂商。测试游戏跑跑卡丁车,程序路径X:\TCGameApps\kart\BlackCipher\:
| 属性 | 值 |
|---|---|
| 组件 | BlackCipher.aes / BlackCall.aes / BlackXchg.aes / NGClient.aes |
| 构建标识 | NGS-REL568-NB(nProtect Game Security 568) |
| 编译时间 | 2026-02-23 |
| 架构 | x86(32 位子进程,跑在 x64 系统) |
| 签名 | NEXON Korea Corporation(签名有效) |
| 父进程 | KartRider.exe |
关于 ".aes 伪装":四个组件实际均为 x86 PE(MZ 头),扩展名只是伪装。直接读文件内容为高加密文——组件整体被加密壳包裹。config.bc为专有配置(魔数DCCB)。
进程运行架构(实测):
KartRider.exe (x64)
└── BlackCipher.aes (x86, 子进程)
├── 组件全为加密壳 PE(静态不可读)
├── 无 SCM 服务级内核驱动(驱动运行时内存加载)
└── 命名管道 IPC(内核命名空间)
注意:系统驱动 npsvctrig.sys 是微软自带 "Named pipe service triggers",并非 nProtect 组件(容易看错)。IPC管道
反作弊进程正在运行,第一件事是看它有没有暴露用户态通信接口。procmon 抓取 20 秒活动,内核视角下出现一个命名管道:
\Device\NamedPipe\BlackCipher\1869882415
管道名格式为 BlackCipher\<数字>,数字后缀为运行时生成。用用户态路径打开它:
[+] opened: \\.\pipe\BlackCipher\1869882415 handle=00000000000000BC
flags=0x4 (type=MESSAGE, mode=BYTE-RM) outbuf=0 inbuf=0 maxinst=255
WriteFile 4 bytes: OK (4 written, err=0)
ReadFile: OK (44 bytes)
server pid: 87376 ← 服务端 = BlackCipher.aes 本体
关键:管道无 ACL,任意用户态进程可打开、可写、可读。
#include <windows.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
printf("=== pipe enumeration (BlackCipher*) ===\n");
char buf[4096];
DWORD len = sizeof(buf);
if (!GetNamedPipeHandleStateA(INVALID_HANDLE_VALUE, NULL, NULL, NULL, NULL, NULL, 0))
;
const char *name = "\\\\.\\pipe\\BlackCipher\\1869882415";
HANDLE h = CreateFileA(name, GENERIC_READ | GENERIC_WRITE,
0, NULL, OPEN_EXISTING, 0, NULL);
if (h == INVALID_HANDLE_VALUE) {
printf("open failed: %lu\n", GetLastError());
return 1;
}
printf("[+] opened: %s handle=%p\n", name, h);
DWORD flags = 0, outbuf = 0, inbuf = 0, maxinst = 0;
if (GetNamedPipeInfo(h, &flags, &outbuf, &inbuf, &maxinst)) {
printf(" flags=0x%X (type=%s, mode=%s) outbuf=%lu inbuf=%lu maxinst=%lu\n",
flags,
(flags & PIPE_TYPE_MESSAGE) ? "MESSAGE" : "BYTE",
(flags & PIPE_READMODE_MESSAGE) ? "MESSAGE-RM" : "BYTE-RM",
outbuf, inbuf, maxinst);
}
char wbuf[8] = {0x41, 0x42, 0x43, 0x44, 0, 0, 0, 0};
DWORD wr = 0;
BOOL ok = WriteFile(h, wbuf, 4, &wr, NULL);
printf(" WriteFile 4 bytes: %s (%lu written, err=%lu)\n",
ok ? "OK" : "FAIL", wr, GetLastError());
char rbuf[512];
DWORD rd = 0;
ok = ReadFile(h, rbuf, sizeof(rbuf), &rd, NULL);
printf(" ReadFile: %s (%lu bytes)\n", ok ? "OK" : "FAIL", rd);
if (ok && rd > 0) {
printf(" data hex: ");
for (DWORD i = 0; i < rd && i < 48; i++) printf("%02X ", (unsigned char)rbuf[i]);
printf("\n data ascii: ");
for (DWORD i = 0; i < rd && i < 48; i++) {
unsigned char c = (unsigned char)rbuf[i];
printf("%c", (c >= 0x20 && c < 0x7f) ? c : '.');
}
printf("\n");
}
DWORD serverPid = 0;
if (GetNamedPipeServerProcessId(h, &serverPid))
printf(" server pid: %lu\n", serverPid);
CloseHandle(h);
return 0;
}
协议请求-响应接口
管道能通,下一步确认它是什么协议。分别做只读、写入不同长度数据,观察响应:
| 操作 | 响应长度 | 说明 |
|---|---|---|
| 只读(不写) | 53 字节 | 管道预置数据/心跳 |
写 1 字节 0x00 | 41~403 字节 | 请求-响应,长度随输入变化 |
写 4 字节 AAAA | 303 字节 | 同上 |
写 16 字节 0x42*16 | 57 字节 | 同上 |
写入任意数据都触发变长响应——管道背后存在活跃的处理逻辑。但所有响应均为二进制(加密/混淆),协议明文不可直接读取。
存在开放的请求-响应接口,但协议层加密。
#include <windows.h>
#include <stdio.h>
#include <string.h>
static void hexdump(const char *tag, const unsigned char *d, DWORD n)
{
printf("%s (%lu bytes): ", tag, n);
for (DWORD i = 0; i < n && i < 64; i++) printf("%02X ", d[i]);
printf("\n");
}
static void test_read_only(HANDLE h)
{
char rbuf[512];
DWORD rd = 0;
BOOL ok = ReadFile(h, rbuf, sizeof(rbuf), &rd, NULL);
printf("== read-only: %s (%lu bytes, err=%lu)\n", ok ? "OK" : "FAIL", rd, GetLastError());
if (ok && rd > 0) hexdump(" data", (unsigned char*)rbuf, rd);
}
static void test_write(HANDLE h, const void *data, DWORD len, const char *tag)
{
DWORD wr = 0;
BOOL ok = WriteFile(h, data, len, &wr, NULL);
printf("== write [%s] %lu bytes: %s (written=%lu err=%lu)\n",
tag, len, ok ? "OK" : "FAIL", wr, GetLastError());
char rbuf[512];
DWORD rd = 0;
ok = ReadFile(h, rbuf, sizeof(rbuf), &rd, NULL);
printf(" after-write read: %s (%lu bytes, err=%lu)\n", ok ? "OK" : "FAIL", rd, GetLastError());
if (ok && rd > 0) hexdump(" resp", (unsigned char*)rbuf, rd);
}
int main(void)
{
const char *name = "\\\\.\\pipe\\BlackCipher\\1869882415";
HANDLE h = CreateFileA(name, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
if (h == INVALID_HANDLE_VALUE) { printf("open failed: %lu\n", GetLastError()); return 1; }
printf("[+] opened %s handle=%p\n", name, h);
test_read_only(h);
char b1 = 0x00;
test_write(h, &b1, 1, "1byte");
char b4[4] = {0x41, 0x41, 0x41, 0x41};
test_write(h, &b4, 4, "AAAA");
char b16[16];
memset(b16, 0x42, 16);
test_write(h, b16, 16, "16xB");
CloseHandle(h);
return 0;
}
抓取行为检测
在连续探测过程中,procmon 两次抓取的管道名出现了变化:
抓取 #2: \Device\NamedPipe\1888ae32e00d56aa-86196-pipe-nt-0x1
抓取 #3: \Device\NamedPipe\1888ae32e00d56aa-78452-pipe-nt-0x2
- 前缀
1888ae32e00d56aa固定,中间数字与后缀0xN每次递增轮换; - 新管道名在用户全部访问失败(
WaitNamedPipe返回 err 161 BAD_PATHNAME)。
结论:BlackCipher 具备客户端行为检测——识别到非预期访问模式后,销毁旧管道并换随机名重建。探测行为本身会被发现,这给"继续深挖协议"增加了难度,但也侧面证实管道承载着重要逻辑。
import re
with open(r'X:\Users\Desktop\x3test\bc_raw3.pml', 'rb') as f:
data = f.read()
print('=== NamedPipe paths in new capture ===')
pipes_a = set()
for m in re.finditer(rb'\\Device\\NamedPipe\\[^\x00\x80-\xff]{2,120}', data):
pipes_a.add(m.group(0).decode('ascii', errors='replace'))
for p in sorted(pipes_a)[:40]:
print(' ', p)
print('\n=== BlackCipher related strings ===')
idxs = [m.start() for m in re.finditer(rb'BlackCipher', data)]
print('BlackCipher hits:', len(idxs))
for i in idxs[:20]:
chunk = data[max(0, i-96): i+256]
strs = re.findall(rb'[\x20-\x7e]{4,}', chunk)
out = [s.decode('ascii', errors='replace') for s in strs]
print('@%08x: %s' % (i, ' | '.join(out)[:160]))
复核IPC 实为内核命名空间对象
为排除"轮换是探测触发"的干扰,在游戏稳定运行(未探测)状态下重新抓取。结果出乎意料:
\Device\NamedPipe\BlackCipher\1869882415 ← 内核视角仍然存在
但尝试所有路径写法:
\\.\pipe\BlackCipher\1869882415 -> err 161 (BAD_PATHNAME)
\\.\Device\NamedPipe\BlackCipher\... -> err 161
\Device\NamedPipe\BlackCipher\... -> err 161
\\.\BlackCipher\... 等变体 -> err 161
全部失败。管道对象存在于内核命名空间 \Device\NamedPipe\,用户 \\.\pipe\ 映射不到,不是权限问题。
import ctypes
import ctypes.wintypes as wt
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
names = [
r'\\.\pipe\BlackCipher\1869882415',
r'\\.\pipe\1888ae32e00d56aa-86196-pipe-nt-0x1',
]
wnp = kernel32.WaitNamedPipeW
wnp.argtypes = [wt.LPCWSTR, wt.DWORD]
wnp.restype = wt.BOOL
for name in names:
r = wnp(name, 500)
err = ctypes.get_last_error()
print('%-60s -> %s (err=%d)' % (name, 'EXISTS' if r else 'NO', err))
if r:
cf = kernel32.CreateFileW
cf.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p, wt.DWORD, wt.DWORD, wt.HANDLE]
cf.restype = wt.HANDLE
h = cf(name, 0xC0000000, 0, None, 3, 0, None)
e2 = ctypes.get_last_error()
print(' CreateFileW -> handle=%s err=%d' % (hex(h) if h and h != -1 else 'None', e2))
if h and h != -1:
kernel32.CloseHandle(h)
print('\n=== 列举 \\.\pipe\\BlackCipher* 相关管道 ===')
for suffix in ['1869882415', '87376', 'BlackCipher']:
n = r'\\.\pipe\BlackCipher\%s' % suffix
r = wnp(n, 200)
print('%-50s -> %s (err=%d)' % (n, 'EXISTS' if r else 'NO', ctypes.get_last_error()))
动态调试
IPC 攻不进去,换动态调试——附加 BlackCipher.aes,对 CreateNamedPipeW/ConnectNamedPipe 下断点,直接从管道名构造逻辑入手。
用 x32dbg 附加(x32dbg -p <PID>)后,BlackCipher 存活数秒,随后游戏弹出提示:
"发现正在使用异常程序,与服务器断开连接"
反调试检测生效:BlackCipher 自毁、游戏断线。从后续 dump 中确认了反调试相关 API:
NtGetContextThread / NtSetContextThread // 线程上下文检测
KeQueryTimeIncrement // 时间差检测
关键:动态调试被系统性阻断。脱壳可能成为唯一可行的深入路径。
尝试脱壳:四层嵌套 PE 与 WinLicense 壳
脱机内存 dump
BlackCipher.aes 是 32 位进程,这里采用 32 位工具 + SeDebug 提权,从运行进程完整读取内存:
[+] IMG regions: 371, total 171495424 bytes
[+] contiguous cluster: 26 regions, size 110612480 bytes (0x00400000 - 0x06D7D000)
[+] read 110612480 / 110612480 bytes ← 100% 读出
[+] MZ header found at ImageBase
[+] PE signature at +0x148 - VALID PE IMAGE
machine: 0x014C (x86)
成功 dump 出 110MB 完整映像(bc_dump.bin)。
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
#include <stdlib.h>
typedef NTSTATUS (NTAPI *pNtQVM)(HANDLE, PVOID, int, PVOID, ULONG, PULONG);
#define MAX_REGIONS 4096
typedef struct {
BYTE *base;
SIZE_T size;
DWORD protect;
} REGION;
int main(int argc, char **argv)
{
if (argc < 3) { printf("usage: bc_dump.exe <PID> <outfile>\n"); return 1; }
DWORD pid = (DWORD)strtoul(argv[1], NULL, 10);
HANDLE hTok;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hTok)) {
LUID luid;
if (LookupPrivilegeValue(NULL, "SeDebugPrivilege", &luid)) {
TOKEN_PRIVILEGES tp;
tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hTok, FALSE, &tp, 0, NULL, NULL);
}
CloseHandle(hTok);
}
HANDLE h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid);
if (!h) { printf("OpenProcess FAILED err=%lu\n", GetLastError()); return 1; }
printf("[+] OpenProcess OK\n");
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
pNtQVM NtQVM = (pNtQVM)GetProcAddress(ntdll, "NtQueryVirtualMemory");
REGION regs[MAX_REGIONS];
int n = 0;
BYTE *addr = NULL;
MEMORY_BASIC_INFORMATION mbi;
ULONG outLen = 0;
SIZE_T total = 0;
while (n < MAX_REGIONS) {
outLen = 0;
NTSTATUS st = NtQVM(h, addr, 0, &mbi, sizeof(mbi), &outLen);
if (st != 0) break;
if (mbi.State == MEM_COMMIT && mbi.Type == MEM_IMAGE) {
regs[n].base = (BYTE *)mbi.BaseAddress;
regs[n].size = mbi.RegionSize;
regs[n].protect = mbi.Protect;
total += mbi.RegionSize;
n++;
}
addr = (BYTE *)mbi.BaseAddress + mbi.RegionSize;
}
printf("[+] IMG regions: %d, total %lu bytes\n", n, (ULONG)total);
if (n == 0) { printf("no IMG regions\n"); return 1; }
for (int i = 0; i < n - 1; i++)
for (int j = i + 1; j < n; j++)
if (regs[j].base < regs[i].base) {
REGION t = regs[i]; regs[i] = regs[j]; regs[j] = t;
}
BYTE *imgBase = regs[0].base;
SIZE_T imgSize = regs[0].size;
printf("[+] ImageBase candidate: %p (size %lu)\n", imgBase, (ULONG)imgSize);
printf("[+] ImageBase: %p, total IMG %lu bytes\n", imgBase, (ULONG)total);
SIZE_T dumpSize = regs[0].size;
int clusterEnd = 0;
for (int i = 1; i < n; i++) {
SIZE_T gap = (SIZE_T)(regs[i].base - (regs[i-1].base + regs[i-1].size));
if (gap < 0x100000) {
dumpSize = (SIZE_T)(regs[i].base + regs[i].size - imgBase);
clusterEnd = i;
} else {
break;
}
}
printf("[+] contiguous cluster: %d regions, size %lu bytes (0x%p - 0x%p)\n",
clusterEnd + 1, (ULONG)dumpSize, imgBase, imgBase + dumpSize);
if (dumpSize > 0x20000000) {
printf("[!] dump too large (%lu), aborting\n", (ULONG)dumpSize);
return 1;
}
BYTE *buf = (BYTE *)malloc(dumpSize);
if (!buf) { printf("malloc fail (%lu)\n", (ULONG)dumpSize); return 1; }
memset(buf, 0, dumpSize);
SIZE_T readTotal = 0;
for (int i = 0; i <= clusterEnd; i++) {
SIZE_T off = (SIZE_T)(regs[i].base - imgBase);
SIZE_T rd = 0;
if (ReadProcessMemory(h, regs[i].base, buf + off, regs[i].size, &rd)) {
readTotal += rd;
} else {
printf(" [warn] RPM fail at %p (err=%lu) - skipping\n",
regs[i].base, GetLastError());
}
}
printf("[+] read %lu / %lu bytes\n", (ULONG)readTotal, (ULONG)dumpSize);
if (buf[0] == 'M' && buf[1] == 'Z') {
printf("[+] MZ header found at ImageBase\n");
DWORD e_lfanew = *(DWORD *)(buf + 0x3C);
if (buf[e_lfanew] == 'P' && buf[e_lfanew+1] == 'E') {
printf("[+] PE signature at +0x%X - VALID PE IMAGE\n", e_lfanew);
WORD machine = *(WORD *)(buf + e_lfanew + 4);
printf(" machine: 0x%04X (%s)\n", machine,
machine == 0x14C ? "x86" : machine == 0x8664 ? "x64" : "other");
}
} else {
printf("[!] no MZ at ImageBase - first bytes: %02X %02X %02X %02X\n",
buf[0], buf[1], buf[2], buf[3]);
}
FILE *f = fopen(argv[2], "wb");
if (!f) { printf("cannot open output %s\n", argv[2]); return 1; }
fwrite(buf, 1, dumpSize, f);
fclose(f);
printf("[+] saved %lu bytes to %s\n", (ULONG)dumpSize, argv[2]);
free(buf);
CloseHandle(h);
return 0;
}
四层PE
分析 dump,发现嵌入 4 个有效 PE:
| # | 架构 | 特征 | 身份 |
|---|---|---|---|
| PE#1 | x86 | 16 节,.vm_sec + .winlice(54MB) + .boot(39MB) | 主壳(WinLicense 加载器) |
| PE#2 | x86 | 8 节,入口 0x8E00 | 核心逻辑模块(bc_core.bin,2.98MB) |
| PE#3 | x64 | NATIVE subsystem,ImageBase 0x140000000,4MB | 内置内核驱动 |
| PE#4 | x86 | 3 节,极小 | 引导 stub |
import struct
import re
path = r'X:\Users\Desktop\x3test\bc_dump.bin'
with open(path, 'rb') as f:
data = f.read()
imagebase = 0x400000
pe_positions = [0x00000000, 0x00654700, 0x009315C8, 0x00D5AD5F]
for pos in pe_positions:
try:
e_lfanew = struct.unpack_from('<I', data, pos + 0x3C)[0]
pe = pos + e_lfanew
if data[pe:pe+2] != b'PE':
print('0x%08X: not PE' % pos)
continue
machine = struct.unpack_from('<H', data, pe+4)[0]
nsec = struct.unpack_from('<H', data, pe+6)[0]
opt = pe + 24
magic = struct.unpack_from('<H', data, opt)[0]
ep = struct.unpack_from('<I', data, opt+16)[0]
imgbase = struct.unpack_from('<I', data, opt+28)[0] if magic == 0x10B else 0
sec = opt + (224 if magic == 0x10B else 240)
print('\n=== PE @ file 0x%08X (VA 0x%08X) ===' % (pos, imagebase + pos))
print(' machine: 0x%04X (%s)' % (machine, 'x86' if machine == 0x14C else 'x64' if machine == 0x8664 else '?'))
print(' sections: %d entrypoint: 0x%X imagebase: 0x%X' % (nsec, ep, imgbase))
print(' sections:')
for i in range(min(nsec, 20)):
s = sec + i * 40
name = data[s:s+8].rstrip(b'\x00').decode('latin1', errors='replace')
vsz, vaddr, rawsz, rawptr = struct.unpack_from('<IIII', data, s+8)
print(' %-8s VA=0x%08X VSize=0x%X Raw=0x%X RawSize=0x%X' % (name, vaddr, vsz, rawptr, rawsz))
import re as _re
dlls = set()
region = data[pos:pos + min(len(data)-pos, 0x2000000)]
for m in _re.finditer(rb'[A-Za-z0-9_]{3,24}\.dll', region):
dlls.add(m.group(0).decode('latin1').lower())
print(' DLLs:', sorted(dlls)[:25])
except Exception as ex:
print('0x%08X: error %s' % (pos, ex))
加密壳类型确认
节名 .winlice + .vm_sec + .boot 是典型特征,配合字符串 WinLicenseVersion / Software\WinLicense,确认是 WinLicense 2.x(Themida 商业版)。
.winlice 节(54MB,Raw=0)为运行时解密的虚拟化代码区——静态 dump 中该区域实际包含已解密的代码与明文内核 API 名。而管道名、设备名等关键字符串在 dump 中全部缺失(运行时拼接),静态提取到此为止。
这脚本就不提供了!!!
PE#3确认
PE#3 的 subsystem 为 NATIVE(内核驱动特征),且 import 表不依赖任何用户态 DLL——这是驱动最硬的判定依据。从 dump 中提取出 79 个内核 API(extract_kernel_apis.py),按能力分类:
import struct
import re
path = r'X:\Users\Desktop\x3test\bc_dump.bin'
with open(path, 'rb') as f:
data = f.read()
imagebase = 0x400000
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
nsec = struct.unpack_from('<H', data, e_lfanew + 6)[0]
optsz = struct.unpack_from('<H', data, e_lfanew + 20)[0]
opt = e_lfanew + 24
sec = opt + optsz
sections = []
for i in range(nsec):
s = sec + i * 40
name = data[s:s+8].rstrip(b'\x00').decode('latin1')
vsz, vaddr, rawsz, rawptr = struct.unpack_from('<IIII', data, s+8)
sections.append((name, vaddr, vsz, rawptr, rawsz))
wl_va = 0xD54000
wl_size = 0x361A000
wl_off = wl_va - imagebase
region = data[wl_off:wl_off + wl_size]
print('=== .winlice 中内核 API 名称 ===')
kernel_pat = re.compile(rb'\b(?:Zw|Nt|Ke|Ps|Ob|Mm|Ex|Io|Rtl|Se|Cc|FsRtl|Kd|Hal|Wdf|Cm|Po|Pi|Pp|Verifier|Etw|Wmi)\w{3,40}\b')
apis = set()
for m in kernel_pat.finditer(region):
name = m.group(0).decode('latin1')
if name.isidentifier() and len(name) >= 4:
apis.add(name)
print('找到 %d 个内核 API 名' % len(apis))
prefixes = {}
for a in apis:
p = a[:2]
prefixes.setdefault(p, []).append(a)
for p in sorted(prefixes):
names = sorted(prefixes[p])
print('\n--- %s* (%d) ---' % (p, len(names)))
print(' ' + ', '.join(names))
print('\n=== 反作弊相关 API ===')
user_pat = re.compile(rb'\b(?:OpenProcess|ReadProcessMemory|WriteProcessMemory|VirtualAllocEx|CreateRemoteThread|NtQuerySystemInformation|DeviceIoControl|CreateFile|RegOpenKey|NtQueryInformationProcess|GetThreadContext|SetThreadContext|NtSetInformationThread|DebugActiveProcess|IsDebuggerPresent|CheckRemoteDebuggerPresent|OutputDebugString)\w*\b')
uapis = set()
for m in user_pat.finditer(region):
uapis.add(m.group(0).decode('latin1'))
for u in sorted(uapis):
print(' ' + u)
| 能力域 | 关键 API | 说明 |
|---|---|---|
| 设备创建 | IoCreateDevice / IoCreateSymbolicLink / IoDeleteDevice | 会创建设备对象,潜在 IOCTL 攻击面(代码加密) |
| 进程监控 | PsSetCreateProcessNotifyRoutine / PsRemoveLoadImageNotifyRoutine | 注册进程创建/映像加载回调 |
| 跨进程读写 | KeStackAttachProcess / MmCopyMemory / MmIsAddressValid | 内核级跨进程内存访问 |
| 对象操作 | ObOpenObjectByPointer / ObReferenceObjectByHandle | 句柄铸造/引用 |
| 内核线程 | PsCreateSystemThread / IoAllocateWorkItem / IoQueueWorkItemEx | 内核异步任务 |
| 反调试 | KeQueryTimeIncrement / ZwQuerySystemInformation | 时间差/系统状态检测 |
| 注册表/文件 | ZwOpenKey / ZwSetValueKey / ZwCreateFile | 配置持久化 |
| 驱动管理 | ZwUnloadDriver | 驱动加载/卸载控制 |
加载方式
系统驱动服务列表中没有该驱动,且 NtLoadDriver/ZwLoadDriver/CreateService 相关操作一次未出现——它是运行时从用户态内存直接加载的(非标准驱动加载路径)。设备名运行时构造 + 加密,静态无法提取。
关键:反作弊(进程监控、跨进程读写、反调试)全部下沉到用户进程不可达的内核层,并让驱动绕过标准加载路径。
实验脚本记录
实测结果均通过对应脚本取得
| 实测结果 | 脚本/工具 | 关键输出 |
|---|---|---|
| 管道可打开、可读写 | bc_pipe_probe2.exe(bc_pipe_probe2.c) | [+] opened ... server pid: 87376 |
| 协议行为矩阵 | bc_pipe_probe3.exe(bc_pipe_probe3.c) | 53 / 41~403 / 303 / 57 字节变长响应 |
| 管道轮换对比 | procmon + parse_pml5.py / parse_pml6.py | 86196-pipe-nt-0x1 → 78452-pipe-nt-0x2 |
| 用户6 种路径全失败 | probe_py.py | 全部 err 161 (BAD_PATHNAME) |
| OpenProcess 拦截 / 非 PPL | bc_memtest.exe + bc_ppl_check.exe | 普通权限全拒;Protection Level=0x00 |
| 句柄表隐藏 | bc_handles.exe(bc_handles.c) | 系统 24 万句柄中目标 0 匹配 |
| 反调试触发 | x32dbg -p <PID> | 游戏提示"发现正在使用异常程序"并断线 |
| 内存 dump | bc_dump.exe(bc_dump.c,vcvars32 编译) | 110MB,100% 读出,MZ+PE 有效 |
| 四层嵌套 PE | analyze_dump.py / extract_pe.py / extract_core.py | PE#1~#4 结构与架构 |
| 壳类型确认 | shell_info.py | WinLicense 2.x(.winlice/.vm_sec/.boot) |
| 内核 API 提取 | extract_kernel_apis.py | 79 个内核 API 分类清单 |
说明:bc_dump.c必须用 32 位编译器(vcvars32.bat)编译——64 位工具查 32 位进程只能看到零星共享区域,拿不到完整地址空间。所有.exe需以管理员权限运行以启用 SeDebugPrivilege。
测试环境工具
- 虚拟机实测:KartRider(
X:\TCGameApps\kart\BlackCipher\) - 进程内存 dump:
bc_dump.bin(110MB,含 4 层嵌套 PE) - 动态调试:x32dbg 附加 → 反调试触发 → 游戏断线
- Sysinternals Process Monitor:管道活动与命名空间分析
- 工具链:x32dbg 套件、capstone 反汇编框架
本报告仅用于安全研究参考。
步骤完整收藏,慢慢看