经历了多年的DMA热潮终于落下帷幕,如今DMA作弊已经形成了相当大的产业链,时过境迁,曾经的最强现在到底是不是入国军呢?
正好最近真正在做一些相关探索,于是乎水一篇大粪在这,本人水平低下还望大佬指正。
【1】 出世
本来大家都对抗的好好的你突然用硬件酱味打击这不胡闹吗?galgame里不是这样的,你应该先和我在R3上一决高下最后再在R0打的难舍难分... 相信大家对DMA的工作原理早就有过充分了解所以也不再赘述,非要说就是:
配置空间(Configuration Space)
CPU 发起 → Root Complex → PCIe TLP(Config) → 设备
用途:枚举设备、分配 BAR、读写能力寄存器
特点:请求者是 CPU,不经过 IOMMU
内存访问(DMA)
设备发起 → PCIe TLP(MemRd/MemWr) → [IOMMU] → 内存控制器 → 物理内存
用途:设备读写系统内存
特点:请求者是设备,IOMMU 启用时经其翻译 (后面要考)
是的就这样,使用DMA可以很轻松的做到任意物理内存读取,写入,更重要的是它不会留下痕迹。和很多的技术手段一样,本来DMA是老老实实作研究用的,可是君子无罪怀璧其罪。
总而言之,DMA刚出来的时候确实是完完全全的碾压了市面上所有的反作弊,没有人使用DMA去对抗反作弊,反作弊也就没有对应的需求,自然而然更加的海阔天空,第一批DMA使用者也就切切实实享受到了新世界。
2016年左右,PCILeech发布 (DMA读写从未如此简单):) DMA读写从此工程化
fpga固件 收发 PCIe TLP
|
leechcore 传输层
|
vmm/MemProcs 物理内存 → 进程视图、符号解析
|
上位机程序 读目标数据
19-21年这个时间段就依旧开始出现咋混共作弊的DMA作弊硬件流入市场,配套服务也非常好,固件配套服务也开始出现,逐渐成熟化了,对抗也进入了下一阶段。
【2】大众
总所周知,追求小众是一件非常大众的事情,而在反作弊对抗这块可以说大众了,就再也没有翻身的余地了,技术的泛滥与不断迭代,让旧技术狠狠的被碾压过去,不幸的,很显然DMA也没有逃过时代车轮的碾压,就在某个时间节点,DMA的用户迎来了大爆发,有人赚的盆满钵满,固件,教学等等雨后春笋一般冒出,设备成本的降低和名色大噪又反向助推用户数量的增长,于是肉眼可见的,DMA被盯上了。
另一边,反作弊厂商可以说是亚历山大了,于是正式打响对抗战争。
反作弊厂商首先想到的对抗方法是什么呢?既然DMA是设备那显然他们可以先遍历检查一下:
以下代码为伪代码还原示例
ANSI_STRING a; UNICODE_STRING u;
char path[] = "\\Driver\\pci";
RtlInitAnsiString(&a, path);
RtlAnsiStringToUnicodeString(&u, &a, TRUE);
g_pci = ObReferenceObjectByName(&u, ...);
//遍历 PCI 总线驱动自己的设备链表
//g_pci + 0x200 是 LIST_ENTRY 头(node+0 = Flink,node+8 = Blink)
//记录基址 elem = node - 0x280
//配置空间头就嵌在 elem + 0x04
for (node = *(PVOID *)(g_pci + 0x200); ; node = *(PVOID *)node) {
Word vendor = *(Word *)(elem + 0x04);
Word device = *(Word *)(elem + 0x06);
if (vendor != 0x8086) continue; // 判断是不是Intel
if (device <= 0x7D0B) {
if (device == 0x7D0B || device == 0x201D || device == 0x28C0 ||
device == 0x467F || device == 0x4C3D) goto matched;
} else {
if (device == 0x9A0B || device == 0xA77F || device == 0xAD0B) goto matched;
}
continue;
if (!(flags & 8)) continue;
先拿这块设备内核 PDO,再找到分配的 MMIO 资源
PDEVICE_OBJECT pdo = *(PDEVICE_OBJECT *)(elem + 0x108);
ULONG len = 0;
status = IoGetDeviceProperty(pdo,
0x15 /* DevicePropertyAllocatedResources */,
0, NULL, &len);
if (status != 0xC0000023 /* STATUS_BUFFER_TOO_SMALL */) bail;
PVOID buf = ExAllocatePoolWithTag(NonPagedPool, 0x200, 'pciR');
IoGetDeviceProperty(pdo, 0x15, 0x200, buf, &len);
PCM_RESOURCE_LIST rl = buf;
if (rl->List[0].PartialResourceList.PartialDescriptors[0].Type
!= 3 /* CmResourceTypeMemory */) bail;
bar = ...[0].u.Memory.Start;
barLen = ...[0].u.Memory.Length;
//越过 VMD 桥本身,直读它下游总线的配置空间窗口
if (barLen >= 0x100000) {
p = MmMapIoSpace(bar + 0x100000, 4, MmNonCached);
if (*(ULONG *)p == 0xFFFFFFFF) {
//总线 1 上没东西
MmUnmapIoSpace(p, 4);
ExFreePoolWithTag(buf, 0);
} else {
//上有设备 → 把它的完整 256 字节配置空间读下来
MmUnmapIoSpace(p, 4);
if (barLen >= 0x100000 + 0x1100) {
out = 0;
sub_14001BAF0(bar + 0x100000, &out, elem);
}
}
if (barLen >= 0x200000) {
q = MmMapIoSpace(bar + 0x200000, 4, MmNonCached);
if (*(ULONG *)q == 0xFFFFFFFF) { MmUnmapIoSpace(q, 4); }
else { //同上 }
}
ExFreePoolWithTag(buf, 0);
}
sub_14001BAF0(bar + 0x100000, &out, elem);
}
void sub_14001BAF0(PHYSICAL_ADDRESS addr, ULONG *out, UCHAR *elem)
{
void *p = MmMapIoSpace(addr, 0x1100, MmNonCached); // 0x1400d7338
scratch = ExAllocatePoolWithTag(0x200, 0x1100, 'pcfg'); // 0x1400d743d
i = 0x1000;
while (((UCHAR *)p)[i] == 0xFF) i++; // 全 0xFF 无设备
sub_140026280((UCHAR *)p + 1, (UCHAR *)p + 0x1001, 0xFF); // 0x1400d768a
*out = 1;
for (i = 0; i < 0x100; i++) // 展开 256 条 store8
elem[0x158 + i] = ((UCHAR *)p)[0x1000 + i]; // → elem[0x158..0x257]
MmUnmapIoSpace(p, 0x1100);
}
那么这几个case 的十六进制常量是什么呢? 通过查看PCI ID Repository我们可以看到
0x201D 0x467F 0x4C3D 0x9A0B = "Volume Management Device NVMe RAID Controller";
0x28C0 = "Volume Management Device (VMD)";
0x7D0B = "Core Ultra 200H/200V Series Processors VMD";
0xA77F = "RST Volume Management Device Controller";
0xAD0B` = "Core Ultra 200 Series Processors VMD";
那就会有人问了,反作弊拿这个elem 0x158-257又是干什么呢?难道里面有什么可以判断是不是DMA的信息吗。
于是我们就查一查
status = sub_14001C9D0(...); // 填充记录(含直读配置空间)
if (status < 0) goto done;
node = *(PVOID *)(P + 0x200);
if (node == (PVOID *)(P + 0x200)) goto empty; // 空链
out = user_buffer;
out[0x00..0x02] = elem[0x00..0x02];
*(WORD *)(out + 0x03) = *(WORD *)(elem + 0x04); // VendorID
*(WORD *)(out + 0x05) = *(WORD *)(elem + 0x06); // DeviceID
*(U64 *)(out + 0x07) = ((U64)*(ULONG*)(elem+0x120) << 32)
| (U64)*(ULONG*)(elem+0x11C);
*(ULONG*)(out + 0x0F) = *(ULONG *)(elem + 0x130);
*(U64 *)(out + 0x13) = *(U64 *)(elem + 0x128);
*(ULONG*)(out + 0x33) = *(ULONG *)(elem + 0x14C);
*(ULONG*)(out + 0x37) = *(ULONG *)(elem + 0x150);
*(ULONG*)(out + 0x3B) = *(ULONG *)(elem + 0x154);
*(U64 *)(out + 0x3F) = *(U64 *)(elem + 0x158); // ← 直读配置空间七点
/* 一直到 out+0x27E */
*(ULONG *)r14 = 2;
sub_14001BD00(P + 0x210, out + 0x27E, ...);
直接查资料看看他都收集了哪些东西吧,忘了说了这么多还没贴出来资料:
typedef struct _PCI_COMMON_HEADER
0x00 Vendor ID USHORT VendorID / PCI_VENDOR_ID 0x00
0x02 Device ID USHORT DeviceID / PCI_DEVICE_ID 0x02
0x04 Command(16 位) PCI_ENABLE_IO_SPACE 0x0001 / PCI_ENABLE_MEMORY_SPACE 0x0002 / PCI_ENABLE_BUS_MASTER 0x0004
0x06 Status PCI_STATUS_CAPABILITIES_LIST 0x0010 等
0x08 Revision ID UCHAR RevisionID
0x09 ProgIf(编程接口) UCHAR ProgIf / PCI_CLASS_PROG 0x09
0x0A SubClass UCHAR SubClass
0x0B BaseClass UCHAR BaseClass / PCI_CLASS_DEVICE 0x0a(16 位,含 SubClass)
0x0E HeaderType PCI_HEADER_TYPE 0x0e;0=普通设备,1=PCI 桥,2=CardBus
0x10–0x27 BAR0–BAR5(6×32 位) ULONG BaseAddresses[PCI_TYPE0_ADDRESSES],PCI_TYPE0_ADDRESSES 为 6
0x28 CIS / CardBus 指针 ULONG CIS
0x2C Subsystem Vendor ID USHORT SubVendorID
0x2E Subsystem ID USHORT SubSystemID
0x30 Expansion ROM 基址 ULONG ROMBaseAddress
0x34 Capabilities Pointer UCHAR CapabilitiesPtr / PCI_CAPABILITY_LIST 0x34
0x3C Interrupt Line UCHAR InterruptLine
0x3D Interrupt Pin UCHAR InterruptPin(只读)


0x00 Vendor / Device ID 身份;DMA 卡常报成网卡/声卡
0x04 Command:bit1 Command寄存器
0x08–0x0B Revision / ProgIF / SubClass / BaseClass 你是什么设备
0x10–0x27 BAR0–BAR5 MMIO
0x2C Subsystem Vendor / Device
0x34 Capabilities Pointer 能力链头
0x3C/0x3D Interrupt Line / PIN 中断
什么是MMIO?
MMIO就是 Memory-Mapped I/O,0x10–0x27 那 6 个 32 位 BAR 是设备声明自己需要一段地址空间的寄存器
#define PCI_BASE_ADDRESS_0 0x10
#define PCI_BASE_ADDRESS_5 0x24
#define PCI_BASE_ADDRESS_SPACE 0x01 /* 0 = 内存空间, 1 = I/O 空间 */
#define PCI_BASE_ADDRESS_MEM_TYPE_64 0x04 /* 64 位地址 */
#define PCI_BASE_ADDRESS_MEM_PREFETCH 0x08 /* 可预取 */
系统给这段空间分配物理地址后把基址写回 BAR;之后 CPU 访问这段物理地址,就是访问设备的板载寄存器。
为什么收集MMIO?
设备内部的寄存器里,包含 DMA 引擎的描述符地址、长度、启动位、完成状态。这些寄存器通常映射在 BAR 里,CPU 只能通过这段 MMIO 去操作 DMA 。
为什么收集 Command 寄存器?
#define PCI_ENABLE_IO_SPACE 0x0001
#define PCI_ENABLE_MEMORY_SPACE 0x0002 // bit1
#define PCI_ENABLE_BUS_MASTER 0x0004 // bit2
#define PCI_ENABLE_SPECIAL_CYCLES 0x0008
#define PCI_ENABLE_WRITE_AND_INVALIDATE 0x0010
#define PCI_ENABLE_VGA_COMPATIBLE_PALETTE 0x0020
#define PCI_ENABLE_PARITY 0x0040 // (ro+)
#define PCI_ENABLE_WAIT_CYCLE 0x0080 // (ro+)
#define PCI_ENABLE_SERR 0x0100 // (ro+)
#define PCI_ENABLE_FAST_BACK_TO_BACK 0x0200 // (ro)
#define PCI_DISABLE_LEVEL_INTERRUPT 0x0400
当 PCI_ENABLE_BUS_MASTER = 1 时,该 Function 可以主动向总线发起 Memory Read / Write Request(即 DMA 操作)。
当 PCI_ENABLE_BUS_MASTER = 0 时,该 Function 不允许发起任何 DMA 请求,只能被动响应 CPU 的访问。
还可以在用户态继续一下
HDEVINFO h = SetupDiGetClassDevs(&guid, NULL, NULL, DIGCF_ALLCLASSES | DIGCF_PRESENT);
for (DWORD i = 0; SetupDiEnumDeviceInfo(h, i, &di); i++) {
SetupDiGetDeviceRegistryProperty(h, &di, SPDRP_HARDWAREID,
NULL, buf, sizeof buf, NULL);
//读取Vendor ID对比一下黑名单id
}这里也有一些被拉黑的数据,收集数据,建立数据库狠狠的拉黑这些板子使用者。
109E Brooktree Corporation Bt848 / Bt878 / Bt879 视频采集 pci-ids 109E
1CD7 Nanjing Magewell Electronics(美乐威) Pro Capture / Eco Capture 采集卡 pci-ids 1CD7
BDBD Blackmagic Design DeckLink / Intensity / UltraStudio 采集卡 pci-ids BDBD (注意都是采集卡下面也要考)
校验方案:
//没服务
for (int i = 0; i < total; i++, elem += 0x848) {
if (*(U32 *)(elem + 0x808) == 0) //设备没有绑定任何驱动服务
//上报
}
//有服务
for (int i = 0; i < total; i++, elem += 0x848) {
if (*(U32 *)(elem + 0x808) == 0) continue; // 无服务 → 归 dma_nosrv 管
if (*(U32 *)(elem + 0x812) != 786) continue; // ← 第二个判据
if (ven_match(o, hwid(elem), o->vendor_list))
report_event(7990, 102, 9015);
}
//假服务:
bool vm = ven_match(o, hwid(elem), o->vendor_list); // 子串查找
bool vs = svc_match(o, svc(elem), o->service_list); // 字符串全等
if (vm && vs) report_event(7990, 102, 9019);
bool ven_match(Type134 *o, char *hwid, vector<string> *list) {
if (list->empty() || !hwid) return false;
for (each v in *list) {
string pat = "VEN_" + v;
if (sub_181737594(hwid, pat.c_str())) // ← 在 hwid 里找这个子串
return true;
}
return false;
}
if (*(U32 *)(elem + 0x00) == 0 &&
*(U32 *)(elem + 0x04) == 0 &&
*(U32 *)(elem + 0x08) != 0 && // ← 这一项要求"非零"
*(U32 *)(elem + 0x10) == 0 &&
*(U32 *)(elem + 0x14) != 0 &&
all_zero(elem + 0x18, 256) && // ← 连续 256 字节全零
total == 1) // ← 记录总数恰好为 1
这里给出参考表也不再赘述了,核心对抗就是你伪造的我就校验,这也是为什么某些垃圾固件确实会拉闸。
elem+0x808 U32 nosrv / wrongsrv == 0 无驱动服务
elem+0x812 U32 wrongsrv == 786 服务起不来
硬件 ID 字符串 string fakesrv VEN_<厂商> 子串 厂商身份
服务名字符串 string fakesrv 全等 3 个虚拟机名 伪造服务名
elem+0x00/04/08/10/14 5×U32 hide 零/非零交替 空壳形态
elem+0x18..0x117 256 B hide 全零 该填未填
记录总数 int hide ==1 / 变化 藏得太干净
两来源 ID U32×2 cfg 互相相等 读取链被改
【3】现在
难道说DMA不小众了我就不玩了吗,但那是弱者的思维。哪怕是在VTD大清洗后,还是有相当数量的八旗子弟仍旧抱着DMA四死死不放手,这也能理解,毕竟双机硬件搭配依旧能解决很多问题,比如签名,软件特征等等。
DMA为了应对反作弊厂商的严肃进攻,也是做出了努力,比如他们尝试伪装固件,把收集到的公开的固件刷到自己这里,或者伪装自己的ID用于对抗反作弊的黑固件数据库,甚至是自己实现一个协议去对抗反作弊的行为检测。
不过反作弊也迎来了史上最强VTD vs 现代最强DMA
0xe87f test al, al
0xe881 je 0x14000e890
0xe883 cmp byte ptr [0x14003027A], 0 ; g_securePciEnabled
0xe88a jne 0x14000e912 ; Secure PCI 已启用 → 跳过整段
0xe890 cmp dword ptr [rbp - 0x7c], 0x10ec ; 未启用 → 检查 vendor == Realtek
0xe897 jne 0x14000e912
0xe8a5 lea rdx, [r13 + 0x10] ; 遍历设备条目,步长 0x14
0xe8c5 cmp byte ptr [rcx], 3 ; 子类型 == 3
/* g_securePciEnabled 由 VslGetSecurePciEnabled() 写入(0x14000D586 → 0x140016F5C) */
if (secure_pci_enabled)
return; //直接过
if (vendor != 0x10EC)
return;
/* 遍历设备条目,按子类型继续查 */
系统已经启用了 Secure PCI就不管,至于为什么只检查Realtak就懂的都懂了。
不过反作弊也利用起了大杀器,VTD,还记得前面说的IOMMU吗?这里就考到了,DMA要访问是必须经过IOMMU的。
在没有VTD时候,DMA是这样访问的:
设备 ──DMA──▶ 物理地址 0x0000_0000 / 内核代码 / 其他进程 / 其他 VM
而在打开了VTD之后就是如此访问的:
PCIe 设备
│ DMA 请求(带 IOVA + Requester ID / BDF)
▼
VT-d DMA Remapping Engine(Root Complex 里)
│ 1. 用 BDF 查 Root Entry → Context Entry
│ 2. 找到这个设备的 I/O 页表(Domain)
│ 3. IOVA → HPA 翻译
│ 4. 检查 R/W/EXEC 权限
▼
物理内存
0x00 Signature "DMAR"
0x04 Length
0x08 Revision
0x09 Checksum
0x0A OEMID 6 字节
0x10 OEM Table ID 8 字节
0x18 OEM Revision
0x1C Creator ID 4 字节
0x20 Creator Revision
0x24 HAW
0x25 Flags
0x26 Reserved
0x28 Remapping Structures (DRHD/RMRR/ATSR/...)
PCIE设备只能看到IOVA(I/O VirtualAddress),而不是直接拿到真实的物理地址了,此时此刻真实的物理地址由VTD硬件查表页决定。
而如果他要读取就需要系统在IOMMU页表里建映射表,如果没有映射的话就会计入DMAR fault,看到这里你应该也能感觉到VTD的姜维打击了,还是那句话,如果身份互换,我会让你知道我的痛苦,可以说,打开VTD几乎DMA就没有还手之力了。
0x140016F5C mov rax, [0x140032550] ; g_VslGetSecurePciEnabled
0x140016F63 test rax, rax
0x140016F66 je 0x140016F6B
0x140016F68 jmp rax
0x140016F6B ret ; 指针为 NULL → al = 0 → 返回 FALSE
当然DMA玩家也并非全无办法,比如:想办法解析DMAR表篡改数据,或者伪造设备已经打开了VTD。
比如我可以构造一张假的DMAR表:
定位 RSDP → 找到 XSDT 物理地址
解析 XSDT → 收集所有 ACPI 表项地址
扫描物理内存,找 4KB 对齐且空闲的区域(与现有表至少隔 4KB)
在该区域构造一份合法的 DMAR 表(含 DRHD/RMRR 等子结构)
DRHD 寄存器基址选 >4GB 的空闲 PCIe MMIO 页
把新 DMAR 的物理地址写入 XSDT 条目(替换或追加)
修正校验和,回读验证
观察系统 IOMMU 行为变化
关键代码: 取自kEv1nZ0/VTD-Bypass: 全自动过VTD检测
定位 RSDP → XSDT
# 扫描物理内存低 1MB 找 RSDP
for pa in range(0x000E0000, 0x00100000, 16):
buf = lc.read(pa, 36)
if buf[0:8] == b'RSD PTR ':
rsdp = buf
break
xsdt_pa = struct.unpack_from('<Q', rsdp, 24)[0] # XsdtAddress at offset 24
找空闲 4KB 对齐区域
def find_free_4k(start, end, used_list):
"""找和 used_list 中每个地址至少隔 4KB 的空洞"""
for pa in range(start, end, 0x1000):
conflict = False
for used in used_list:
if abs(pa - used) < 0x1000:
conflict = True
break
if not conflict:
return pa
return 0
构造 DMAR 表
def build_dmar(dmar_pa):
dmar = bytearray(48) # 表头 48 字节
struct.pack_into('4s', dmar, 0, b'DMAR')
struct.pack_into('<I', dmar, 4, 48)
dmar[8] = 1 # Revision
# OEMID / OEM Table ID / Creator ID 模仿原固件
# Checksum 后面算
return dmar
def calc_checksum(buf):
return (-sum(buf)) & 0xFF
dmar = build_dmar(dmar_pa)
dmar[9] = calc_checksum(dmar)
DRHD 基址选 >4GB MMIO
def pick_drhd_base():
"""选 >4GB 的空闲 PCIe MMIO 页"""
for pa in range(0x100000000, 0x10000000000, 0x1000):
if not is_mmio_mapped(pa) and not is_used(pa):
return pa
return 0
写入新 DMAR 并挂进 XSDT
# 写入 DMAR 表体
lc.write(dmar_pa, bytes(dmar))
# 替换 XSDT 中的 DMAR 条目
for i, entry in enumerate(entries):
if lc.read(entry, 4) == b'DMAR':
lc.write(xsdt_pa + 36 + i*8, struct.pack('<Q', dmar_pa))
break
# 修正 XSDT 校验和
xsdt_data = lc.read(xsdt_pa, length)
xsdt_data[9] = calc_checksum(xsdt_data)
lc.write(xsdt_pa, xsdt_data)
最后再回读验证一下
verify = lc.read(dmar_pa, 4)
assert verify == b'DMAR', "DMAR 写入失败"
verify_xsdt = lc.read(xsdt_pa + 36 + i*8, 8)
assert struct.unpack('<Q', verify_xsdt)[0] == dmar_pa, "XSDT 条目更新失败"当然,大部分反作弊也会在用API判断完后检查DMAR表,也可以利用固件元数据不一致来判断环境是否可信。
*(U32*)(dmar+0x0B) 0x0B OEMID(0x0A,6) 之内
*(U16*)(dmar+0x0F) 0x0F OEMID 末 + OEMTableID 首
*(U32*)(dmar+0x1D) 0x1D CreatorID(0x1C) 之内
RtlCompareMemory(dmar+0x0B, sdt+0x0B, 6) 0x0B 起 6 B
伪代码:
PVOID dmar = FindAcpiTable('DMAR');
PVOID root = FindAcpiTable('XSDT');
bool vt_d_seen = (dmar != NULL);
bool same_oem = vt_d_seen && RtlCompareMemory(dmar + 0x0B,
root + 0x0B, 6) == 0;
【最后】
感谢@Peyria 的支持,感谢各位能读到这,也衷心希望大佬们指出错误(排版真的好麻烦)
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 1天前
被n013ody编辑
,原因: