Unity 内存数据极限防护:一个 int 藏 8 份密文,修改器到底还能怎么改?

0 阅读5分钟

游戏里有些数值非常敏感:

金币、钻石、战斗属性、关键计数……

如果直接使用普通 int

public int mGold = 10000;

内存里就是一个非常明确的 10000

一些简单的内存修改器只需要搜索数值、修改数值、再次筛选,就可能定位到目标地址。

MyFramework 中的 SafeInt / SafeLong / SafeFloat 没有试图让客户端数据“绝对不可修改”,而是做了一件更加现实的事情:

不让真正的数据以固定明文、固定位置、固定密钥长期存在,同时主动检测内存是否被异常修改。

项目地址:

github.com/ZHOURUIH/My…

一、一个 int,实际用了 8 个密文位置

SafeInt 内部不是:

private int mValue;

而是准备了 8 个密文槽:

private int sidhgsg;
private int isdhy34;
private int hibsd;
private int sgkn;
private int jgo2;
private int hgwikg;
private int gnwe;
private int gwijhge;

再用:

private int wghwe;

记录当前真正的数据到底藏在哪一个位置。

每次 set()

wghwe = randomInt(0, 7);

因此同一个变量连续写入:

100
100
100
100

真实密文位置都可能不同:

第1次 → Slot 6
第2次 → Slot 1
第3次 → Slot 7
第4次 → Slot 3

修改器就不能长期盯住某个固定字段。

二、真正存进去的也不是原始值

写入时先重新生成两个随机密钥:

gikoweg = randomInt(mValue0, mValue1);
woghjwe = randomInt(mValue2, mValue3);

真正存储的值经过多层运算:

int newValue =
	(value ^ (gikoweg - (gikoweg >> 2))) +
	(woghjwe ^ 0x1238) -
	(woghjwe ^ 0xFF123);

所以业务值:

10000

内存里并不会长期出现:

10000

而会随着每次 set() 的随机密钥发生变化。

也就是说,同样写入 10000 两次,最终看到的密文通常也完全不同。

三、连密钥自己都要做合法性校验

如果攻击者不改密文,直接尝试修改密钥呢?

MyFramework 又给两个密钥增加了一条约束:

gikoweg =
	(gikoweg + woghjwe) /
	mValue5 *
	mValue5 -
	woghjwe;

其中 SafeInt

mValue5 = 437;

最终必须满足:

(gikoweg + woghjwe) % 437 == 0

读取数据前先检查:

if (gikoweg < mValue0 ||
	gikoweg > mValue1 ||
	woghjwe < mValue2 ||
	woghjwe > mValue3 ||
	(woghjwe + gikoweg) % mValue5 != 0)
{
	mGameFrameworkHotFix.onMemoryModified(...);
}

所以密钥不仅要“看起来是个 int”,还必须:

范围正确 + 两个密钥之间的数学关系正确。

四、解密以后,还要再做第二次校验

仅仅检查密钥还不够。

写入真实值时还保存了一份校验数据:

gikowjeg = value ^ woghjwe;

读取时先解密:

int curValue =
	(value -
		(woghjwe ^ 0x1238) +
		(woghjwe ^ 0xFF123))
	^
	(gikoweg - (gikoweg >> 2));

再检查:

if (curValue !=
	(gikowjeg ^ woghjwe))
{
	mGameFrameworkHotFix.onMemoryModified(...);
}

因此一次 get() 实际经历:

检查密钥范围
↓
检查密钥数学关系
↓
找到真正密文槽
↓
解密
↓
用另一份校验数据验证
↓
返回真实值

攻击者只改中间任意一个字段,都可能导致校验失败。

五、每写一次,整套密钥都会换掉

这一点很关键。

set() 不是使用固定 Key:

Value
↓
固定密钥
↓
Cipher

而是:

每次set()
↓
生成新Key0
↓
生成新Key1
↓
重新选择8个槽中的一个
↓
生成新的Cipher

因此即使游戏数值完全没变,只要重新赋值,内存特征也会变化。

这比简单的:

mValue = value ^ 12345678;

要难直接搜索得多。

六、MostSafeInt:觉得一份还不够,再存两套

框架还有一个更加激进的:

public struct MostSafeInt
{
	private SafeInt erhgre;
	private SafeInt gwegihweg;
}

设置时两份一起写:

public void set(int value)
{
	gwegihweg.set(value);
	erhgre.set(value);
}

读取时两边分别解密:

int curValue = gwegihweg.get();
int checkValue = erhgre.get();

if (curValue != checkValue)
{
	mGameFrameworkHotFix.onMemoryModified(...);
}

于是攻击者不仅要让一个 SafeInt 内部所有校验成立,还得保证:

SafeInt A
==
SafeInt B

两套随机密钥、两套随机槽位最终必须解出同一个值。

七、Float 也不是直接把 IEEE 754 放在内存里

SafeFloat 会先把浮点数整数化:

int newValue =
	(round(value * 1000) ^
	(qwfb - (qwfb >> 2))) +
	(qgoqjg ^ 0x1238) -
	(qgoqjg ^ 0xFF123);

也就是保留约 3 位小数后,再进入和 SafeInt 类似的密文体系。

校验数据则使用:

asgihfasg =
	(int)(value * 10000) ^ qgoqjg;

读取以后再通过误差范围比较。

所以框架目前提供:

SafeInt
SafeLong
SafeFloat

MostSafeInt
MostSafeLong
MostSafeFloat

八、安全是有成本的,而且成本并不小

普通:

int = 4 Byte

而一个 SafeInt 内部有 12 个 int

8个密文
1个当前下标
2个密钥
1个校验值

理论数据量就是:

48 Byte

MostSafeInt 又包含两个 SafeInt

约96 Byte

所以这种类型绝对不应该无脑替换项目中所有 int

更合理的用法是只保护真正敏感的数据:

金币
付费货币
重要战斗属性
关键次数
客户端必须暂存的敏感值

而不是拿它存:

数组下标
UI页签
普通动画状态
临时循环变量

另外当前实现依赖随机数,并明确限制:

只能在主线程使用。

九、它不是“客户端绝对安全”

这一点必须说清楚。

加密算法最终仍然存在于客户端代码中,足够有能力的攻击者可以反编译、调试甚至绕过检测。

所以 SafeInt 的定位不是密码学意义上的安全存储,也不能取代服务器权威校验。

它真正解决的是:

明文搜索变困难
↓
固定地址修改变困难
↓
固定密文搜索变困难
↓
随意修改Key容易触发校验
↓
随意修改Cipher容易触发校验
↓
检测到异常后统一上报

最终异常都会进入:

mGameFrameworkHotFix.onMemoryModified(
	flag,
	param0,
	param1,
	param2,
	param3);

再由项目自己决定后续处理。

所以这套设计真正的核心不是:

“我把一个 int 加密了。”

而是:

我让真实值、密文位置和密钥不断变化,再用多层冗余信息检查这些数据有没有被人动过。

对于客户端防简单内存修改来说,这比一个永远躺在固定地址里的普通 int,已经完全是两回事了。

不过说真的,有点用,但不多