本文是对跑跑卡丁车(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 字节 0x0041~403 字节请求-响应,长度随输入变化
写 4 字节 AAAA303 字节同上
写 16 字节 0x42*1657 字节同上

写入任意数据都触发变长响应——管道背后存在活跃的处理逻辑。但所有响应均为二进制(加密/混淆),协议明文不可直接读取。

存在开放的请求-响应接口,但协议层加密


#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#1x8616 节,.vm_sec + .winlice(54MB) + .boot(39MB)主壳(WinLicense 加载器)
PE#2x868 节,入口 0x8E00核心逻辑模块(bc_core.bin,2.98MB)
PE#3x64NATIVE subsystem,ImageBase 0x140000000,4MB内置内核驱动
PE#4x863 节,极小引导 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 个内核 APIextract_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.exebc_pipe_probe2.c[+] opened ... server pid: 87376
协议行为矩阵bc_pipe_probe3.exebc_pipe_probe3.c53 / 41~403 / 303 / 57 字节变长响应
管道轮换对比procmon + parse_pml5.py / parse_pml6.py86196-pipe-nt-0x178452-pipe-nt-0x2
用户6 种路径全失败probe_py.py全部 err 161 (BAD_PATHNAME)
OpenProcess 拦截 / 非 PPLbc_memtest.exe + bc_ppl_check.exe普通权限全拒;Protection Level=0x00
句柄表隐藏bc_handles.exebc_handles.c系统 24 万句柄中目标 0 匹配
反调试触发x32dbg -p <PID>游戏提示"发现正在使用异常程序"并断线
内存 dumpbc_dump.exebc_dump.c,vcvars32 编译)110MB,100% 读出,MZ+PE 有效
四层嵌套 PEanalyze_dump.py / extract_pe.py / extract_core.pyPE#1~#4 结构与架构
壳类型确认shell_info.pyWinLicense 2.x(.winlice/.vm_sec/.boot
内核 API 提取extract_kernel_apis.py79 个内核 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 反汇编框架
本报告仅用于安全研究参考。