处理2k离线/年龄限制等神奇问题:(
CivilizationVI(文明6)在几年前的某次更新后,添加了年龄限制,所有联机功能必须通过这个联网年龄验证,否则无法进入联机界面。局域网联机则是无法建立房间(会卡在确认设置没反应)。
即,在那次更新之后,无论是否是学习版,都没法在离线情况下进行局域网联机
查遍全网,似乎并没有找到现成的解决方案。故,自己动手,丰衣足食吧qwq
与此绑定2K帐号关联的年龄未达到法定年龄要求,因此无法访问游戏的某些内容。请联系客户支持以获取帮助。在语言文件\Base\Assets\Text\Vanilla_zh_Hans_CN.xml中找到文本对应的TAG为LOC_MULTIPLAYER_INTERNET_GAME_OFFLINE_AGE_TT。
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。
同时,重复第一大章,也注意到,离线局域网模式下,创建房间时,按下没反应的确认设置按钮,所调用的lHostGame函数的注册也在这里。
这里注册的每一个函数都通过lua名称得知了其作用,这很关键!
观察注册到lua的函数,注意到不少函数都使用了sub_14050E420,并使用其返回值增加固定偏移来获取数据。且该函数固定返回qword_1422EDFA8。推测该函数会返回这个关键结构体的基址。
同时,沿着IsAgeRestricted的逻辑向上找,发现其使用这个结构体的+468偏移,推测该地址就是关键的验证字段存储位置。同时也注意到其使用unsigned int。
使用od或者frida动态调试,当检测到sub_14050E420返回有效地址时,读取+468偏移,并写1。
观察到右下角显示离线,但并非年龄提示。进入多人游戏菜单,互联网游戏解锁,局域网游戏可以正常创建房间。

ida64,默认基址是0x140000000,所以在动态调试的时候,找到模块基址后偏移应该是50E420。咱做的是完全离线的学习版,所以替换了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版本