处理2k离线/年龄限制等神奇问题:(


背景

CivilizationVI(文明6)在几年前的某次更新后,添加了年龄限制,所有联机功能必须通过这个联网年龄验证,否则无法进入联机界面。局域网联机则是无法建立房间(会卡在确认设置没反应)。

即,在那次更新之后,无论是否是学习版,都没法在离线情况下进行局域网联机

查遍全网,似乎并没有找到现成的解决方案。故,自己动手,丰衣足食吧qwq

从界面文本开始…

  1. 在所有资源文件中,查找与此绑定2K帐号关联的年龄未达到法定年龄要求,因此无法访问游戏的某些内容。请联系客户支持以获取帮助。

在语言文件\Base\Assets\Text\Vanilla_zh_Hans_CN.xml中找到文本对应的TAG为LOC_MULTIPLAYER_INTERNET_GAME_OFFLINE_AGE_TT

  1. 在lua脚本中,查找使用该tag的逻辑
    注意到相关逻辑
function GetInternetGameOfflineTT()
	if (Network.IsAgeRestricted()) then
		return Locale.Lookup("LOC_MULTIPLAYER_INTERNET_GAME_OFFLINE_AGE_TT");
	end

	if( Network.GetNetworkPlatform() == NetworkPlatform.NETWORK_PLATFORM_EOS ) then
		return Locale.Lookup("LOC_EPIC_MULTIPLAYER_INTERNET_GAME_OFFLINE_TT");
	end

	return Locale.Lookup("LOC_MULTIPLAYER_INTERNET_GAME_OFFLINE_TT");
end

注意到Network.IsAgeRestricted()函数由引擎注册,这里开始反汇编

追踪到二进制文件…

这里咱用ida64,逆向游戏主程序CivilizationVI.exe
首先需要使用steamless给壳脱了,文明6用了steam标准的drm包装,这里没有难度。
追踪IsAgeRestricted字符,找到函数sub_140267FE0,其注册了部分函数,其中就有IsAgeRestricted
ida64截图
同时,重复第一大章,也注意到,离线局域网模式下,创建房间时,按下没反应的确认设置按钮,所调用的lHostGame函数的注册也在这里。
ida64截图
这里注册的每一个函数都通过lua名称得知了其作用,这很关键!

找到目标结构体…

观察注册到lua的函数,注意到不少函数都使用了sub_14050E420,并使用其返回值增加固定偏移来获取数据。且该函数固定返回qword_1422EDFA8。推测该函数会返回这个关键结构体的基址。
同时,沿着IsAgeRestricted的逻辑向上找,发现其使用这个结构体的+468偏移,推测该地址就是关键的验证字段存储位置。同时也注意到其使用unsigned int
ida64截图

验证…

使用od或者frida动态调试,当检测到sub_14050E420返回有效地址时,读取+468偏移,并写1。
观察到右下角显示离线,但并非年龄提示。进入多人游戏菜单,互联网游戏解锁,局域网游戏可以正常创建房间。
civ6截图civ6截图

注意

集成

咱做的是完全离线的学习版,所以替换了steam_api,并且这个模拟器支持加载额外的自定义dll,所以直接注入了gadget,完美结束。
这里给出最后咱用的脚本

const IDA_IMAGE_BASE = ptr("0x140000000");
const IDA_TARGET_VA = ptr("0x14050E420");
const TARGET_OFFSET = IDA_TARGET_VA.sub(IDA_IMAGE_BASE);
const FIELD_OFFSET = 468;
const DESIRED_VALUE = 1;
const ACQUIRE_INTERVAL_MS = 1000;
const GUARD_INTERVAL_MS = 1000;

const mainModule = Process.mainModule;
const target = mainModule.base.add(TARGET_OFFSET);

console.log("[*] 主模块:", mainModule.name, "基址=", mainModule.base);
console.log("[*] 目标偏移:", TARGET_OFFSET, "运行时=", target);

const sub_14050E420 = new NativeFunction(target, "pointer", []);

let structPtr = null;
let fieldPtr = null;

function acquireStructIfReady() {
    try {
        const retPtr = sub_14050E420();
        if (retPtr.isNull()) {
            return;
        }

        const candidateField = retPtr.add(FIELD_OFFSET);
        const current = candidateField.readU32(); // 测试可读性
        structPtr = retPtr;
        fieldPtr = candidateField;
        console.log("[+] 结构已就绪:", structPtr, "字段(+0x468) 当前值=", current);
    } catch (e) {
        // 结构尚未就绪或不可访问;继续探测
    }
}

function forceDesiredValue() {
    if (fieldPtr === null) {
        return;
    }

    try {
        const current = fieldPtr.readU32();
        if (current !== DESIRED_VALUE) {
            fieldPtr.writeU32(DESIRED_VALUE);
            console.log("[修复] 结构体=", structPtr, "字段(+0x468):", current, "->", DESIRED_VALUE);
        }
    } catch (e) {
        console.log("[!] 结构无效,正在重新获取...");
        structPtr = null;
        fieldPtr = null;
    }
}

setInterval(acquireStructIfReady, ACQUIRE_INTERVAL_MS);
setInterval(forceDesiredValue, GUARD_INTERVAL_MS);

setImmediate(function () {
    console.log("[*] 等待结构,然后守护字段 +468 为 1");
    acquireStructIfReady();
    forceDesiredValue();
});

其他

通过抓包等途径观察,注意到:
游戏启动时,会先从steam_api获取某些信息,然后访问2k.com的一个子域,这应该是在线获取年龄验证信息的步骤。若获取信息失败(使用steam_api模拟器)则压根不会发送向2k.com的请求。


同时注意到,游戏启动时,会向epic发送采集信息,即使是steam版本