序言
卡牌游戏通常是游戏类破解的困难目标,因为它们是回合制的,并且不需要在客户端和服务器之间交互很多信息。也说明服务器会跟踪整个游戏状态,且只告诉客户端它需要知道的内容。
与快节奏的射击游戏不同,在快节奏射击游戏中,你需要先加载对手团队的模型才能看到它们(允许 wallhacks),或者像玩家移动这样的计算量大的例程可能会被预安装到客户端以节省资源(允许 speedhacks),卡牌游戏在资源消耗方面相对少,信息边界也清晰。玩家所能做的就是打牌和读取棋盘状态,服务器会告诉玩家他们可以做什么以及什么时候可以做什么。
所以客户端本身只负责很少的工作,并且只会在对手出牌或我抽牌时提供有关未公开牌的信息。未公开的信息,如对手的手牌或牌组在本地永远不存在,因此无法读取。我不能按照特定顺序洗牌,也不能改变我抽的牌。服务器只是为我执行这些动作并告诉我结果。这与第一人称射击游戏形成鲜明对比,在第一人称射击游戏中,玩家随时可以使用很多动作和服务。这导致了FPS这类外挂的泛滥:
在上面这个FPS游戏中,所有显示的信息都已存储并缓存在客户端中,安全人员只是将其显示出来。由于游戏的快节奏和动态特性,此类信息必须在玩家获得敌人或物体的信息之前就在客户端可用。否则,所有敌人或物体数据都必须在实时获得和渲染视线时通过线路实时发送(然后在视线中断后立即删除,性能和延迟问题很难控制,所以不现实)
之前测试拳头新产品瓦洛兰特,也是FPS,也发现了最小化显示客户端所有玩家位置数据的利用,有空之后整理出来。
在卡牌类的游戏中,玩家可采取的行动是有限的,且会在设定好的时间发生,因此客户端不用在需要之前存储数据。也让检测非法动作的更容易实现,例如打出不在我手中或顺序不对的牌。这也是卡牌类游戏的大型外挂生态相对较少的部分原因。但这并不意味没有漏洞。
从哪开始测起:
对于卡牌游戏,我认为最优先是查看网络通信数据。对于游戏类漏洞挖掘来说,这是一个很好的经验习惯——我喜欢的 defcon 演讲45 分钟的Manfred的MMO黑客战争故事。推荐一下。他把查找所有这些漏洞而执行的所有分析的根源都归结为逆向网络协议。他编写了专门的工具,用于在包被解密后(如果有加密)HOOK发送/接收消息的任何函数,并在一些十六进制查看器界面里显示流量内容。它似乎也有能力在请求发送到服务器或由客户端解释之前编辑请求,并重播消息。
由于万智牌是用 C# 编写的,优点是我们不需要做他所做的所有花哨的事情来HOOK流量的入口和出口。我们可以使用Reflection在运行时非常轻松地操作游戏中的对象,包括访问私有字段和方法。如果需要的话,去搜索下Reflection如何执行此操作的基本教程。我做的就是找到客户端代码并开始分析。我可以根据需要在运行时测试客户端。
(https://learn.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/reflection)
线索
查看反编译的 C# 也非常友好,因为它会利用元数据标记将已编译代码的元素与可读的名称相关联,这些名称仍存储在已编译的 .NET 程序集中。这意味着我可以看到开发人员在编写代码时编写的所有函数、变量和类名。本质就是将未混淆的 .NET 程序集的反编译变成原本源代码审计。
我开始优先找和加入比赛有关的功能,因为这是针对特定对手进行大量客户端初始化的位置。然后,发现了一个JoinMatch功能。
这个函数很长(超过 200 行),其中很多只是处理玩家元数据,比如排名、外观等。当然,我还是找到了我想要的内容:
private void JoinMatch(NewMatchCreatedConfig matchConfig)
{
...
this._matchManager.ConnectAndJoinMatch(connectionConfig, matchConfig.controllerFabricUri, matchConfig.matchId);
if (matchConfig.matchType == MatchType.NPE)
{
this._npeState.NewNPEOpponent(connectionConfig.MatchDoorHost, connectionConfig.MatchDoorPort, matchConfig.controllerFabricUri, matchConfig.matchId, this._matchManager, this._assetLookupSystem);
return;
}
if (matchConfig.matchType == MatchType.Familiar)
{
this._botTool.NewBotOpponent(connectionConfig.MatchDoorHost, connectionConfig.MatchDoorPort, matchConfig.controllerFabricUri, matchConfig.matchId, !flag3, this._cardDatabase, this._accountInformation, this._matchManager, this._assetLookupSystem);
}
}
一个函数名为ConnectAndJoinMatch用于接收我的匹配配置信息。这很可能是这局游戏第一次连接到比赛相关的位置,这还不是全部。我连接到游戏服务器后还有另外两个函数。
是Sparky
Sparky就是万智牌的内置人机模式,用于引导新玩家完成教程,并随时可以开人机对局练习新卡组。
Sparky 以一种非常有趣的方式实现。如果匹配类型是NPE(代指“新玩家体验”)或Familiar(标准人机匹配),则会调用其他函数,这些函数写了很多以玩家身份参与比赛的功能。任何时候我在万智牌里玩人机模式时,机器人逻辑都会在我的机器上本地运行,只是连接到与我相同的比赛服务器而已,但是它正在对局的所有决策过程都在我本地运行。
我很惊讶,因为人机占用空间小,可以在本地运行。我原以为像万智牌这样复杂的游戏在创建 AI 对手方面需要大量数据。实际上相反,我可查看到 Sparky 的所有逻辑,包括调试 UI 的几个地方
还我最喜欢的描述,让我想起泄露的Valve和Yandex源代码事件的评论:
细节:
让我复盘下一场人机对局中到底发生了什么:
这部分有很多不用去看的代码。看我圈起来显示的内容。人机对局中机器人对手的实际逻辑处理程序位于一个名为HeadlessClient的类中. 人机游戏对局中不需要渲染任何内容,只需连接到游戏服务器就可以。
人机对局连接到服务器信息是本地生成的,而且它使用相同的用户 token 向游戏服务器进行身份验证,用户ID命名是PersonaID,是我成功登录后授权的JSON 网络令牌(jwt),很标准网络认证。(在服务端的逻辑本质就是我和我自己在对局)
这个设定其实很有意思,因为这意味着游戏服务器不会认为我和我在比赛是有问题的。
人机对局游戏服务器是否是单独逻辑?很显然不是,因为对于游戏本身的优点之一是所有玩家的人机对局都可以和人人对局完全相同地运行在一起,避免连接,堵塞,排队和占用资源之类的问题。
所以游戏利用我的ID来直接分配人机对局"座位":
POC
所以,我尝试把人人对局的对手换成人机对手。这需要制作自己客户端,确定我在游戏中的"座位",然后将客户端连接到另一个"座位"。
POC代码内容大部分只是读游戏的内存对象,获取我当前正在匹配连接的游戏的所有信息。然后我使用这些信息将人机连接到这局游戏里。代码会根据我自己的位置计算出对手的"座位",创建并替换为人机,并且作为一些额外的方便性,可以立即让人机的机器人对手认输。
看视频,成功了,将正常对局替换成人机对局,并让人机的机器人对手马上认输。而且拿到了正常对局的奖励和排位等级,让我可以很快拿到高段位和奖励。
POC代码如下(现在已经修复这个漏洞了):
using System;
using UnityEngine;
using System.Reflection;
using Wizards.Mtga;
using Wizards.Mtga.Logging;
using WGS.Logging;
using Wotc.Mtga.Cards.Database;
using Wizards.Mtga.FrontDoorModels;
using AssetLookupTree;
namespace hax
{
public class InstaWin : MonoBehaviour
{
//HeadlessClient cheater;
UnityFamiliar cheatbot;
// Cast a wide net with our BindingFlags to catch most variables we would run into. Scope this down as needed.
// https://learn.microsoft.com/en-us/dotnet/api/system.reflection.bindingflags?redirectedfrom=MSDN&view=net-7.0
BindingFlags flags = BindingFlags.Instance
| BindingFlags.Public
| BindingFlags.NonPublic
| BindingFlags.Static;
// Create logger for printing to debug log
UnityLogger logger = new UnityLogger("Tool", LoggerLevel.Debug);
public void OnStart()
{
// Register our logger
LoggerManager.Register(logger);
}
public void OnGUI()
{
// Create a window at the center top of our game screen that will hold our button
Rect windowRect = new Rect(Screen.width - (Screen.width / 6), Screen.height / 10, 120, 50);
// Register the window. Notice the 3rd parameter is a callback function to make the window, defined below
windowRect = GUI.Window(0, windowRect, DoMyWindow, "HackBox");
// Make the contents of the window
void DoMyWindow(int windowID)
{
// Combo line that creates the button and then also will check if it has been pressed
if (GUI.Button(new Rect(10, 20, 100, 20), "Instawin"))
{
Instawin();
}
}
}
public Matchmaking GetMatchmaking()
{
PAPA papa = GameObject.FindObjectOfType<PAPA>();
return papa.Matchmaking;
}
public void Instawin()
{
Matchmaking mm = GetMatchmaking();
if (mm != null)
{
logger.LogDebugForRelease("Matchmaking Exists");
}
// Get cached config
Type matchmakingType = mm.GetType();
FieldInfo matchConfigField = matchmakingType.GetField("_cachedMatchConfig", flags);
NewMatchCreatedConfig config = (NewMatchCreatedConfig)matchConfigField.GetValue(mm);
logger.LogDebugForRelease(String.Format("config.matchEndpointHost: {0}", config.matchEndpointHost));
logger.LogDebugForRelease(String.Format("config.matchEndpointPort: {0}", config.matchEndpointPort));
logger.LogDebugForRelease(String.Format("config.controllerFabricUri: {0}", config.controllerFabricUri));
logger.LogDebugForRelease(String.Format("config.matchId: {0}", config.matchId));
// Get account information
FieldInfo accountInformationField = matchmakingType.GetField("_accountInformation", flags);
AccountInformation ai = (AccountInformation)accountInformationField.GetValue(mm);
logger.LogDebugForRelease(String.Format("ai.PersonaID: {0}", ai.PersonaID));
logger.LogDebugForRelease(String.Format("ai.Credentials.Jwt: {0}", ai.Credentials.Jwt));
// Get card database
CardDatabase cdb = Pantry.Get<CardDatabase>(Pantry.Scope.Application);
if (cdb == null)
{
logger.LogDebugForRelease(String.Format("cdb is null: ", cdb));
}
logger.LogDebugForRelease(String.Format("cdb.VersionProvider.DataVersion: {0}", cdb.VersionProvider.DataVersion));
// Get Match Manager
FieldInfo matchManagerField = matchmakingType.GetField("_matchManager", flags);
MatchManager man = (MatchManager)matchManagerField.GetValue(mm);
// Get Match Manager
FieldInfo assetLookupSystemField = matchmakingType.GetField("_assetLookupSystem", flags);
AssetLookupSystem ass = (AssetLookupSystem)assetLookupSystemField.GetValue(mm);
logger.LogDebugForRelease(String.Format("ass.Blackboard.ContentVersion: {0}", ass.Blackboard.ContentVersion));
// Get other seat
uint otherSeat = man.LocalPlayerSeatId % 2U + 1U;
UnityFamiliar.SpawnFamiliar_DEBUG(cdb, config.matchEndpointHost, config.matchEndpointPort, ai.PersonaID, ai.Credentials.Jwt, config.controllerFabricUri, config.matchId, otherSeat, null);
cheatbot = UnityEngine.Object.FindObjectOfType<UnityFamiliar>();
cheatbot.Client.Gre.ConcedeGame();
}
}
原文始发于微信公众号(军机故阁):游戏漏洞挖掘-万智牌:竞技场100%胜率
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论