Exploring DMA-Based Game Cheats

引子:所谓的“插板子”究竟是什么

上一篇外挂博客的研究已经不能满足我的好奇心了,于是我终于走上了这最后一步! image.png 通过外置的FPGA设备直接读取主机内存的作弊方式被称之为DMA外挂,也就是俗称的“插板子”。 板子是通过可以开发的固件实现功能的,但是想拥有一个好的固件,难的不是内存的读取,而是如何绕过游戏的反作弊。 一般的DMA板子都是通过PCIE接口读取主机内存的,然而一个未知的可读取内存的设备存在于计算机是很可疑的,所以我们要将其仿真为其他拥有PICE接口的正常设备来合理化其存在意义。如网卡,打印机,显卡,声卡等。 用于采集信息的设备中网卡最为便宜,且对于网卡仿真的FPGA项目早已经开源(也因此稳定性不高,最容易被检测),所以我们将以网卡固件为例进行讲解。

准备工作

硬件

一块CaptainDma75t板子(某鱼大约300) 8111网卡(大约10,多买几张防封备用) 副机(某鱼上有专卖的,大约500,用个人计算机也可以,最好有usb3.0接口)

官方软件:

TeleScan(用于采集网卡信息) Vivado(用于开发FPGA)

小工具

Telescan2coeGui(前往https://github.com/un4ckn0wl3z/TeleScan2coeGui 仓库获取) DMA-Flash-Tools(前往https://github.com/kilmu1337/DMA-Flash-Tools 仓库获取)

采集与配置

设备信息采集

插上8111网卡,注意一定不要热插拔!打开TeleScan软件进行扫描 选中扫描出的网卡,看到有6个Base Address寄存器  2025-05-16 014526.png 选中每一个不全是0的寄存器,将其的值修改为最大值(右侧填满1)  2025-05-16 014801.png 然后右键-> write-> DWORD at offset  2025-05-16 015244.png 完成这一步后将其保存为.tlscan文件

配置流程

获取https://github.com/ekknod/pcileech-wifi/ 项目,我们总共有4个文件需要修改 1.pcileech_cfgspace 将你采集到的.tlscan文件拖入到Telescan2coeGui程序中 你将会得到一个output.coe,重命名为pcileech_cfgspace.coe 复制到项目下ip文件夹内,替换原有的文件即完成了这一步骤 2.pcileech_fifo 打开vivado,注意到底下的Tcl Console, cd到你项目的目录并运行source vivado_generate_project_captain_75T.tcl 打开项目后在vivado中找到pcileech_fifo.sv文件 查找rw[203]关键字,确保其值为0,并确保rw[206]值为1 3.pcileech_pice_cfg_a7 打开你之前保存的.tlscan文件,找到Device Serial Number Capability这一项 image.png 找到PCI Express Device Serial Number ,这个分为1st DW与 2nd DW,将2nd的值放在前1st的值放在后,获取到一个16位的编码 在vivado中找到pcileech_pice_cfg_a7.sv文件 查找关键字rw[127:64],将其值修改为上面得到的16位编码,这其实就是设备的DSN 同时还要确保rw[20]为1以及rw[21]为0 4.pcie_7x_0_core_top 在vivado中找到pcie_7x_0_core_top.v文件 这个文件有很多的parameter,要修改的地方非常多,总体而言就是仔细对照.tlscan,凡是其中涉及到的每一项都要修改

比如说最基础的CFG_VEND_ID,CFG_SUBSYS_ID,CLASS_CODE等参数一看便与teleScan中一一对应 image.png

比如说AER_BASE_PTR参数,主要观察AER,其实就对应了teleScan中Advanced Error Reporting这一项的缩写,BASE_PTR也就是括号内的offset的值 而AER_CAP_NEXTPTR参数,指的就是teleScan中Advanced Error Reporting下一项所对应的offset了 如果该项点开发现本身有Next_Item_Ptr属性,则NEXTPTR填入该值而非下一项所对应的offset 如果该项为最后一个,那么NEXTPTR应该填入000 image.png

又比如说BAR0到BAR5其实又对应了之前看到的Base Address Register的值,这里一一填入。注意如果某个BAR以04或0C结尾,下一个BAR应该全部填入F. 像我这样的初级教程,我们不做IO,那么要另外注意BAR0应该以00结尾

烧录与测速

固件生成与烧录

1.完成以上采集与配置后点击vivado中的Generate Bitstream,直接点OK耐心等待生成 2.前往项目中pcileech_enigma_x1\pcileech_enigma_x1.runs\impl_1目录,找到pcileech_enigma_x1_top.bin,这就是我们获取的固件了 3.将CaptainDma75t板子通过PCIE接口插入主机,并通过usb连接板子的UPDATE_PORT口(靠近金手指的一段) 4.将固件粘贴入DMA-Flash-Tools目录下的Flash Tools子目录,运行#####flash_ch347_75t#####.bat即可完成烧录。如果每个sector花费0-1ms这是不正常的,请重新运行.bat文件直到ms数在几百左右;如果卡在sector 33之后不动,这是正常的,请耐心等待直到出现success 5.如果烧录失败可能需要安装驱动CH341PAR,也在DMA-Flash-Tools目录下

副机与测速

DMA外挂存在的主要目的就是悄咪咪地读取内存而不被发现,所以如果我们通过其读取内存的程序运行在主机上,那前面都白忙活了 这就是副机——另一台用于运行代码读取主机内存的计算机存在的意义。 用副机测速是实战前非常重要的部分,用于检测板子是否可以正常、高效地使用

简单叙述一下驱动安装与测速过程:DMA板子通过DATA_PORT口连接副机,安装FTDI SuperSpeed-FIFO Bridge驱动,安装测速的系统运行库,运行下载的DMATest.exe完成测速

具体实现请看这一大佬的文章 https://flowus.cn/ddma/share/9d0bae1a-373b-4e43-94a7-9043d3519b72 ,其中还包括了下载链接与常见问题,非常方便

简易的实战

注意:代码运行在副机上 我使用了memprocfs的python api用于读取内存,这里介绍一下用例

vmm = memprocfs.Vmm(['-device', 'fpga']) # 通过板子连接主机
process = vmm.process('cs2.exe') # 获取进程
base_addr = next((m.base for m in process.module_list() if 'client.dll' in m.name.lower()), 0) # 获取dll地址
# 某个我封装好的用于读取指针数据的函数
def read_ptr(process, address):
    pre = process.memory.read(address, 8) # 最重要的内存读取函数
    return struct.unpack('<Q', pre)[0] if pre else 0
# memprocfs.FLAG_NOCACHE决定了你获取的是实时数据还是缓存数据,假如你感觉一段时间内多次读取的数据一模一样,可能就是没有这个参数
process.memory.read(entity["gsn"] + offsets["m_angAbsRotation"] + 4, 4, memprocfs.FLAG_NOCACHE)

下面实现了一个简易的cs2雷达外挂,可以实时获取人物在地图中的各种信息,并搭建了一个websocket服务器用于传输数据到渲染的机子上 有关offsets这里就不直接提供了,否则就是传播外挂了,有心者可以前往 https://github.com/IMXNOOBX/cs2-external-esp 找到相关脚本,不过要稍作修改

import memprocfs, struct, os, json, threading, asyncio, websockets

points = []
points_lock = threading.Lock()

websocket_clients = set()

def read_offsets(filepath="/offsets/offsets.json"):
    filepath = os.path.dirname(__file__) + filepath
    if not os.path.exists(filepath):
        raise FileNotFoundError(f"file not found: {filepath}")
    with open(filepath, "r", encoding="utf-8") as file:
        return json.load(file)

def read_ptr(process, address):
    pre = process.memory.read(address, 8)
    return struct.unpack('<Q', pre)[0] if pre else 0

def read_str(process, address):
    raw = process.memory.read(address, 256)
    return raw.split(b'\x00', 1)[0].decode('utf-8', errors='ignore')

def get_entity_arr(process, el_addr, lc_addr, offsets):
    my_team = process.memory.read(lc_addr + offsets["m_iTeamNum"], 4)
    my_name_addr = read_ptr(process, lc_addr + offsets["m_sSanitizedPlayerName"])
    my_name = read_str(process, my_name_addr)

    entity_array = []
    for index in range(1, 64):
        entry_addr = read_ptr(process, el_addr + (8 * (index & 0x7FFF) >> 9) + 16)
        entity_addr = read_ptr(process, entry_addr + 120 * (index & 0x1FF))
        if not entity_addr: continue

        team = process.memory.read(entity_addr + offsets["m_iTeamNum"], 4)
        if team != b'' and team != b'\x00\x00\x00\x00':
            pawn_addr = struct.unpack('<I', process.memory.read(entity_addr + offsets["m_hPlayerPawn"], 4))[0]
            entry2_addr = read_ptr(process, el_addr + 0x8 * ((pawn_addr & 0x7FFF) >> 9) + 16)
            entity2_addr = read_ptr(process, entry2_addr + 120 * (pawn_addr & 0x1FF))
            gsn_addr = read_ptr(process, entity2_addr + offsets["m_pGameSceneNode"])

            weapon_addr = read_ptr(process, entity2_addr + offsets["m_pClippingWeapon"])

            name_addr = read_ptr(process, entity_addr + offsets["m_sSanitizedPlayerName"])
            name = read_str(process, name_addr)

            who = 0
            if team != my_team: who = 1

            entity_array.append({
                "entity": entity2_addr,
                "gsn": gsn_addr,
                "who": who,
                "name": name,
                "weapon": weapon_addr
            })
    return entity_array


def cheat_init(offsets):
    vmm = memprocfs.Vmm(['-device', 'fpga'])
    process = vmm.process('cs2.exe')
    base_addr = next((m.base for m in process.module_list() if 'client.dll' in m.name.lower()), 0)
    if not base_addr:
        raise RuntimeError("client.dll base address not found!")

    print(f"--base address {base_addr} get successfully!")
    lc_addr = read_ptr(process, base_addr + offsets["dwLocalPlayerController"])
    print(f"--local controller address {lc_addr} get successfully!")
    el_addr = read_ptr(process, base_addr + offsets["dwEntityList"])
    print(f"--global entity list address {el_addr} get successfully!")

    entity_array = get_entity_arr(process, el_addr, lc_addr, offsets)
    print("--entity address array get successfully!")

    return process, entity_array


def cheat_updater(process, entity_array, offsets):
    global points
    while True:
        temp_points = []
        for entity in entity_array:
            try:
                health = struct.unpack('<i', process.memory.read(entity["entity"] + offsets["m_iHealth"], 4))[0]
                x, y, _ = struct.unpack('<fff', process.memory.read(entity["entity"] + offsets["m_vOldOrigin"], 12, memprocfs.FLAG_NOCACHE))
                yaw = struct.unpack('<f', process.memory.read(entity["gsn"] + offsets["m_angAbsRotation"] + 4, 4, memprocfs.FLAG_NOCACHE))[0]
                weapon = "unknown"
                weapon_addr = read_ptr(process, entity["entity"] + offsets["m_pClippingWeapon"])
                if weapon_addr!=0:
                    first = read_ptr(process,weapon_addr + 0x10)
                    if first != 0:
                        second = read_ptr(process,first + 0x20)
                        if second!=0:
                            weapon = read_str(process, second)
                            if weapon.startswith("weapon_"):weapon = weapon[7:]
                if health != 0:temp_points.append(( x, y , entity["who"], yaw ,entity["name"], health, weapon))
            except:continue
        with points_lock:
            points = temp_points

async def websocket_handler(websocket, path):
    websocket_clients.add(websocket)
    try:
        await websocket.wait_closed()
    finally:
        websocket_clients.remove(websocket)

async def websocket_broadcaster():
    while True:
        await asyncio.sleep(0.1)
        with points_lock:
            data = json.dumps(points)
        for ws in websocket_clients.copy():
            try:
                await ws.send(data)
            except:
                websocket_clients.remove(ws)

def start_websocket_server():
    asyncio.run(websocket_main())

async def websocket_main():
    print("-- WebSocket server started at ws://0.0.0.0:8765")
    async with websockets.serve(websocket_handler, "0.0.0.0", 8765):
        await websocket_broadcaster()

if __name__ == "__main__":
    offsets = read_offsets()
    process, entity_array = cheat_init(offsets)

    update_thread = threading.Thread(
        target=cheat_updater, args=(process, entity_array, offsets), daemon=True
    )
    update_thread.start()

    print("--cheat server running... press Ctrl+C to stop")

    try:
        asyncio.run(websocket_main())
    except KeyboardInterrupt:
        print("--server stopped")

后话

这是目前最简单的DMA外挂开发流程,并且我亲自测试过,对于国内某cs平台,毫不掩饰地使用透视,经历一周时间才封禁了我的账号。 但是这也是为了学习使用,开挂并不能给人带来真正的进步,本人非常不建议仅仅为了在游戏中追求快感而使用外挂,这对其他玩家是非常不公平的。 DMA也就玩到这了,之后再怎么升级也无非是固件开发与仿真技术的升级了,等以后有机会再研究吧。