作弊(主要是弱联网休闲游戏的作弊)的影响,比如改变排行榜(包括全局排名、地区排名和好友排名),不仅会影响用户留存,还会影响广告投放,带来经济损失。弱联网游戏可能存在的安全问题:存档被篡改和复制;本地配置被篡改;帐号被盗和恶意修改;内存被修改;网络协议被破解和重放攻击;工程被逆向破译。现在有很多技术高超的人,可以反汇编你的代码,然后把你的工程逆向;二进制程序可能被修改等。
1. 内存修改
主流游戏内存修改器有三大内存搜索功能:
- 基础数值搜索
- 模糊搜索
- 反加密搜索
基础数值搜索是这么一个工作流程:首先搜一个特定的数值,比如金钱100就搜100,待金钱变化后,比如110就再搜110,如此重复3到4步操作,内存修改器就能定位到金钱所在的内存地址,接着就可以进行修改了。
模糊搜索是针对看不到具体数值的数值的修改(比如柱状血量、柱状气槽以及某些加密了的数值等)。模糊搜索是这么一个工作流程:首先启动模糊搜索,然后在游戏中等待要修改的数值发生变化,搜索“变大”、“变小”、“变化了”、“没变化”等操作,进行内存筛选,如此重复7到10步操作,就能定位到要修改的数值的内存地址,接着就可以进行修改了。
反加密搜索是更为强悍的内存搜索功能,和基础数值搜索是同样一个使用流程,但根据反加密强度,可以轻松破解多种常见的数值加密算法:
- 倍值加密算法,
y = n * x + m。其中x为加密前的数值,y为内存中的数值,n为任意正负整数。y = (x << n) + m也属于这种算法,m为任意正负整数。 - 异或加密算法,
y = n ^ x + m。其中x为加密前的数值,y为内存中的数值,n为任意正负整数,m为任意正负整数。
前述三大内存搜索功能,都基于一个前提:要修改的数值,在内存中的地址是不变的或者在定位过程中是不变的。
所以,防御内存修改器的方法是:不断变化敏感数值的内存存储地址,敏感数值不留明文并随时更换密钥(比如将读写变量的方法封装成set和get两方法, 调用set方式时自动切换变量存储地址、变化加密密钥等)。
此外,游戏修改器在找不到直接修改游戏中的金钱、血量、经验值等敏感数据的时候,通常还采用“曲线救国”的方法:比如修改商店中物品的价格,改为负数,买商品,越买钱越多;比如修改每一局游戏中打到的怪物数量,结算的时候就可以获得更多的奖励;比如修改某一物品的数量,然后定位到该物品所在地址,修改该物品代码,把垃圾装备变成黄金装备等。这些数据,都是要格外注意保护的。
下面是一种简单的内存加密的实现方式(Java 示例):
/**
* 加密Int
*/
public class EncryptInt {
private int value;
private byte[] values = new byte[4];
/**
* 根据加密的值返回原始值
*/
public int getValue() {
return (int) ((values[0] & 0xFF)
| ((values[2] & 0xFF) << 8)
| ((values[1] & 0xFF) << 16)
| ((values[3] & 0xFF) << 24));
}
/**
* 加密存储的值
*/
public void setValue(int value) {
this.value = value;
values = new byte[4]; // 改变变量存储地址
values[0] = (byte) (value & 0xFF);
values[2] = (byte) ((value >> 8) & 0xFF);
values[1] = (byte) ((value >> 16) & 0xFF);
values[3] = (byte) ((value >> 24) & 0xFF);
}
/**
* 校验值是否被篡改
*/
public boolean isValid() {
return value == getValue();
}
}
2. 存档篡改
防止存档篡改需要考虑加密算法和存档协议设计:
加密算法:存档加密建议强加密,必须使用安全的对称加密算法,建议用 AES 256 CBC PKCS#7。
首先,密钥不要直接保存在代码里面;密钥是生成的;第二,密钥不要放在存档里面,这是很危险的。第三,密钥每次加密当场现算;第四,密钥的生成需要有随机度。
存档协议设计:存档内容需要包含存档类型、存档版本号、源数据哈希、初始向量和加密数据。源数据哈希可以用于校验解密后的数据是否被篡改,初始向量可以参与加密算法中 IV 的生成,防止存档被转移。
AES 是当今世界上使用最广泛的对称密钥加密算法,用于加密和解密数据。AES 密钥生成是 AES 算法的关键步骤之一,密钥的长度可以是 128 位、192 位或 256 位,根据密钥的长度的不同,加密强度也不同。可以使用 UDID(唯一设备识别符)参与密钥和 IV 的生成,这样不仅可以防止存档被篡改,也可以防止存档被转移。
下面是一种存档读/写的实现方式(Java 示例):
/**
* 存档管理
*/
public class ArchiveManager {
private static final String DIR_ARCHIVE = "Archive";
/**
* 写入存档
*
* @param context 上下文
* @param data 存档数据
* @param fileName 文件名
*/
public static boolean writeArchive(Context context, String data, String fileName) {
String aesKey = getAesKey(context);
String rawIV = generateRawIV(context);
String aesIv = getAesIv(context, rawIV);
String hash = md5(data);
String encryptData = AES.encrypt(aesKey, aesIv, data);
StringBuilder builder = new StringBuilder();
builder.append("1"); // 存档类型
builder.append("|");
builder.append("10"); // 版本号
builder.append("|");
builder.append(hash); // 源数据哈希
builder.append("|");
builder.append(rawIV); // 初始向量
builder.append(System.lineSeparator());
builder.append(encryptData); // 加密数据
String content = builder.toString();
try {
File file = getFile(context, fileName);
BufferedWriter writer = new BufferedWriter(new FileWriter(file));
writer.write(content);
writer.flush();
writer.close();
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/**
* 读取存档
*
* @param context 上下文
* @param fileName 文件名
*/
public static String readArchive(Context context, String fileName) {
try {
String line;
String header = null;
String data = null;
File file = getFile(context, fileName);
BufferedReader reader = new BufferedReader(new FileReader(file));
while ((line = reader.readLine()) != null) {
if (header == null) {
header = line;
} else if (data == null) {
data = line;
}
}
reader.close();
if (header != null && data != null) {
String[] split = header.split("\\|");
String hash = split.length > 2 ? split[2] : null; // 源数据哈希
String rawIV = split.length > 3 ? split[3] : null; // 初始向量
String aesKey = getAesKey(context);
String aesIv = getAesIv(context, rawIV);
String decryptData = AES.decrypt(aesKey, aesIv, data);
if (Objects.equals(hash, md5(decryptData))) { // 校验存档内容哈希值
return decryptData;
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
private static File getFile(Context context, String fileName) {
File filesDir = context.getExternalFilesDir(DIR_ARCHIVE);
if (!filesDir.exists() && !filesDir.mkdirs()) {
filesDir = new File(context.getFilesDir(), DIR_ARCHIVE);
}
return new File(filesDir, fileName);
}
private static String getAesKey(Context context) {
String key = Build.FINGERPRINT + '/' + context.getPackageName() + '/' + getAndroidId(context);
return md5(key);
}
private static String getAesIv(Context context, String rawIv) {
String iv = rawIv + '/' + getAndroidId(context);
return md5(iv).substring(0, 16);
}
private static String generateRawIV(Context context) {
return md5(context.getPackageName() + '/' + System.currentTimeMillis());
}
private static String getAndroidId(Context context) {
try {
return Settings.System.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
private static String md5(String data) {
return digest("MD5", data);
}
private static String sha256(String data) {
return digest("SHA-256", data);
}
private static String digest(String algorithm, String data) {
try {
MessageDigest digest = MessageDigest.getInstance(algorithm);
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
return byte2Hex(bytes);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
private static String byte2Hex(byte[] bytes) {
String hex;
StringBuilder builder = new StringBuilder();
for (byte b : bytes) {
hex = Integer.toHexString(b & 0XFF);
if (hex.length() == 1) {
builder.append("0");
}
builder.append(hex);
}
return builder.toString();
}
}