内存加密

目标 & 背景

  • 抵御市面上常见的内存模糊搜索工具
  • 没有致命性能问题

在手游的内存搜索工具上,一般来说 Android 需要 root 权限,iOS 需要越狱,但是在 PlayCover[^1]出现后,打破了这个平衡

现在在 iOS 的版本下,对当前 iPA 进行砸壳后,使用 PlayCover[^1] 运行,可以直接用 CE[^2] 等工具,在开启 SIP[^3]的情况下,可以直接对当前游戏进行内存搜索和修改,这样修改内存的门槛就大大降低了,所以针对内存修改的加密方案就变的势在必行

该方案已经开源,可点击查看 仓库地址

本文参考了 CSEncryptType[^4] 这个方案的设计思路,并在此基础上解决了堆内存消耗过大的问题

设计思路

在开始之前,我们要搞清楚这些内存搜索工具的工作原理,一般来说,市面上常见的原理大致上就是多次输入一个值,然后取交集,最终确定这片内存的地址,在此基础上还有些工具会有计算差值等方式

在这个前提下,我们在设计加密时,就需要解决这两个问题

  • 搜不到
  • 无法进行有效差值计算

搜不到这件事情很简单,我们可以通过简单的位运算,解决这个问题,Set 时做一次位运算,Get 时再做一次位运算,这样实际显示/使用的值,和内存的值完全不一致,就可以达成搜不到这件事了

但是,针对第二项的无法进行有效差值计算这条就比较麻烦了,虽然我们设计可以基于位运算来加密这块内存,但是假设你这个值本次发生变化的大小为 10,即使做了位运算,这块加密的内存实际值的偏移量也仍然是 10,所以如果不做处理,针对这种搜索仍然是无效的

针对这个问题,我们可以给加密类型开辟多个存放的内存,每次 Set 时,存放在不同的内存上,这样即使去搜偏移,在大部分情况下搜到的结果都是错误的

改进 CSEncryptType[^4]

下面截取了部分关键代码,从这几行代码中我们可以看到一个致命的问题,如果每次直接使用 EncryptByte byte = 0 这种方式进行赋值,实际上发生的是 new xxxClass,这样就会导致非常严重的堆内存问题,每次赋值都有内存分配,这个并不是我们期望看到的,而且在游戏关键数据逻辑,如:角色属性修改上,是非常非常频繁的,这样会对战斗逻辑造成比较大的额外性能开销

public abstract class EncryptTypeBase<T, KVType, DType> // ...
{
    private KVType[] _values;
    
    // ...
    protected static DType Box(T v)
    {
        DType d = new DType();
        d.SetValue(v);
        return d;
    }
}
public class EncryptByte : EncryptTypeBase<byte, byte, EncryptByte>
{
    // ...
    public static implicit operator EncryptByte(byte v) => Box(v);
    public static implicit operator byte(EncryptByte d) => Unbox(d);
}

针对这个问题,我们的解决思路也非常简单,要将 class 转成 struct 实现,但是在 _values 这个数组的实现上,就比较 tricky 了, 由于 struct 不允许有空的构造函数,int[] _values = new[3] 这种写法就是不支持的

这里我们直接定义 _1_2_3 三个变量,模拟这个数组

 public struct EncryptInt : IComparable<EncryptInt>, IEquatable<EncryptInt>
{
    private int _0;
    private int _1;
    private int _2;
}

然后直接通过操作这片内存,把这个 struct 看做数组,根据 _index 的值获取当前值存放在那个位置上即可

public unsafe int Get()
{
    fixed(EncryptInt* array = &this)
    {
        return((int*) array)[_index] ^ _KEY;
    }
}

Set 也是同理

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe void Set(int value)
{
    if(++_index == 3)
    {
        _index = 0;
    }

    fixed(EncryptInt* array = &this)
    {
        ((int*) array)[_index] = value ^ _KEY;
    }
}

配表内存加密

Luban_Example 仓库中已经实现了对应的 tpl 生成模板,如有需要可以参考具体实现

注意事项

该库已经添加了较为完善的单元测试和性能测试,下图为 long 类型的加密和原生的性能对比,这里我们可以看到,Get 接口与原生访问的性能在同一个数量级内,而 EncryptLong encrypt = 0encrypt.Set(0) 这两种使用方式存在一个数量级的差距

那么在实际使用时就需要注意了,需要频繁修改的场景,请务必使用 Set 接口,不那么频繁的可以直接使用 = 赋值

EncryptLong_Benchmark

最后

我们做这个内存加密的意义是为了提高游戏内存修改的门槛,即使使用了这个加密方案,也并不能认为 100% 没有问题

市面上还有一些加密方案是做了一些偏移,可以做到感知内存被修改了,但是当这片内存被搜索出来的那一刻,事情就不对了,例如 CE[^1] 可以反向找出哪段代码对这片内存进行了修改,这样攻击者就可以通过 hook 等方法对这片代码地址进行注入,从而快速的修改你游戏的逻辑

参考

[^1]: PlayCover: https://github.com/PlayCover/PlayCover

[^2]: CE: https://www.cheatengine.org

[^3]: SIP: https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection

[^4]: CSEncryptType: https://github.com/nichos1983/CSEncryptType