为什么 HESOYAM 能生效:GTA:SA 作弊码背后的识别机制

拆解 GTA: San Andreas 识别作弊码的底层机制,解释 HESOYAM 这类看似无意义的字母组合为何真的有效

发布于
作者是 GEMILUXVII · 收录在 纸上谈兵 · 标签 techgta-sahashcrc32sha256cryptography · 约 18 分钟阅读 · 这里是

在《Grand Theft Auto: San Andreas》的众多作弊码中,HESOYAM 无疑是最具代表性的一个。

在 PC 版游戏中输入这七个字母,CJ 会恢复生命值与护甲,并获得 25 万美元。效果直接而实用,这也让它成为许多玩家最早记住的作弊码之一。然而,与 INEEDSOMEHELP 这类语义明确的短句相比,HESOYAM 更像一组无规则的字母:它不是英文单词,也无法从字面上和生命、护甲或金钱建立任何联系。

这种古怪并非翻译问题,也不是开发者刻意设计的暗号。HESOYAM 之所以有效,是因为它和另一条字符串经过同样的计算后,得到了完全相同的 32 位数值:

JAMCRC(reverse("HESOYAM"))
= 0xEECCEA2B

JAMCRC(reverse("INEEDSOMEHELP"))
= 0xEECCEA2B

游戏最终比较的并非作弊码原文,而是计算后得到的数值——不同字符串只要产生相同结果,就能触发同一种效果。这正是许多 GTA:SA 作弊码缺乏直观语义的根本原因。

一、HESOYAM 在程序中的识别方式#

GTA:SA 没有专门的作弊码输入框,也不要求玩家输入后按下 Enter。游戏在运行过程中持续监听键盘输入,按下相应字母序列即可触发识别。

根据社区对经典 PC 版的逆向分析,游戏会持续记录最近输入的字符。每当玩家按下一个新按键,它就会取出最近 6~29 个字符,计算出一个 32 位的 Key,再与内置的作弊码表进行比较:

玩家按下按键
   ↓
更新作弊码输入缓冲区
   ↓
计算最近 6~29 个字符的 Key
   ↓
与游戏内置的数值表比较
   ↓
命中后执行相应效果

逆向项目 gta-reversed 中可以看到两个相关常量:

static constexpr auto CHEAT_STRING_SIZE = 30;
static constexpr auto CHEAT_MIN_HASH_SIZE = 6;

与上述流程对应的逆向函数是 CCheat::AddToCheatString

// 0x438480
void CCheat::AddToCheatString(char lastPressedKey) {
    // 旧字符整体右移一位,为最新按键腾出 buffer[0]
    for (int i = CHEAT_STRING_SIZE - 2; i >= 1; --i) {
        m_CheatString[i] = m_CheatString[i - 1];
    }

    m_CheatString[0] = lastPressedKey;       // 最新字符放在最前面
    m_CheatString[CHEAT_STRING_SIZE - 1] = 0; // 保证以 \0 结尾

    if (strlen(m_CheatString) < CHEAT_MIN_HASH_SIZE) {
        return; // 少于 6 个字符时不进行匹配
    }

    // 依次检查最近 6、7、8……29 个字符
    for (int length = CHEAT_MIN_HASH_SIZE;
         length < CHEAT_STRING_SIZE;
         ++length) {
        const uint32 hash = CKeyGen::GetKey(m_CheatString, length);

        // 原源码在 m_aCheatHashKeys 中逐项查找相同数值
        for (int id = 0; id < TOTAL_CHEATS; ++id) {
            if (m_aCheatHashKeys[id] != hash) {
                continue;
            }

            ApplyCheat(static_cast<eCheats>(id)); // 命中后执行效果
            m_CheatString[0] = '\0';              // 清空输入缓冲区
            return;
        }
    }
}

原函数可在 Cheat.cppAddToCheatString 中查看。DoCheats 则遍历键盘按键,并在检测到新按键时调用该函数,因此整个过程不需要输入框或确认键。

输入缓冲区最多容纳 29 个有效字符,游戏从 6 个字符开始逐一尝试匹配。因此作弊码不需要任何结束符:每产生一次新的键盘输入,游戏都会重新判断最近输入的内容是否命中某项效果。

在程序逻辑中,HESOYAM 并不直接代表“生命、护甲和金钱”,真正参与匹配的是数值 0xEECCEA2B。只要另一字符串能产生相同结果,程序便不会对二者作出任何区分。

二、反向输入缓冲区的设计#

GTA:SA 的输入缓冲区采用了一种不太直观的存储顺序:新字符写入最前端,已有字符则依次向后移动。

假设玩家依次输入 ABCD,缓冲区内容会这样变化:

输入 A → A
输入 B → BA
输入 C → CBA
输入 D → DCBA

因此,当玩家输入 HESOYAM 时,实际参与计算的其实是反转后的 MAYOSEH

这种保存方式并非为了进一步隐藏作弊码,而是出于实现上的便利:游戏始终需要检查“最近输入的 N 个字符”,把最新字符放在数组开头后,无论要检查最近 6 个、7 个还是更多字符,都能从同一位置读取,只需改变读取长度即可。

三、HESOYAM 与 INEEDSOMEHELP#

严格来说,HESOYAM 本身并没有可以翻译的语义。

后续研究者从移动版本中恢复了更接近开发者原始意图的作弊字符串,其中对应生命、护甲和金钱效果的是:

INEEDSOMEHELP

这句话意为“我需要一些帮助”,与作弊效果之间有着明确的语义联系。玩家熟悉的 HESOYAM,只是一条更短、但计算结果恰好与之相同的字符串。

二者的关系可以概括为:

  • INEEDSOMEHELP 是开发阶段便于理解和管理的意图字符串;
  • HESOYAM 是能命中同一个 32 位数值的另一组字符;
  • PC 版只需要保存计算结果,不必保留两条字符串的明文。

逆向项目中的作弊码表也直接记录了这一对应关系:

// 函数地址       执行函数                         目标 Key      效果枚举
{ 0x438e40, CCheat::MoneyArmourHealthCheat, ..., 0xeeccea2b,
  CHEAT_HEALTH_ARMOR_250K },

表中没有 HESOYAMINEEDSOMEHELP 的明文,只有目标值 0xEECCEA2B、执行函数与效果枚举。输入字符串只要计算出这一数值,最终都会进入 MoneyArmourHealthCheat

这种现象并不限于 HESOYAM。GTA:SA 的 PC 版作弊码中,有的近似缩写,有的接近可读单词,也有的几乎不具备任何语义。对这些字符串而言,首要条件从来不是构成一句可读的话,而是计算后能否命中游戏内置的目标值。

四、CRC32 与字符串碰撞#

GTA:SA 使用的计算方式属于 CRC32,具体形式通常称为 CRC-32/JAMCRC:采用常见的反射多项式 0xEDB88320 和初始值 0xFFFFFFFF,但省略了标准 CRC-32 最后的异或步骤。

CRC32 本质上就是一种计算很快、但安全强度较弱的哈希函数:对任意输入数据进行运算,输出一个固定为 32 位的结果。它常被用于数据完整性校验,例如文件传输、存储介质检错等场景。

gta-reversed 中的 CKeyGen::GetKey 与上述参数完全对应:

// 0x53CED0
[[nodiscard]] static constexpr uint32 GetKey(
    const char* str,
    int32 size
) noexcept {
    // GTA:SA 使用 0xFFFFFFFF 作为初始余数
    uint32 key = INITIAL_REMAINDER;

    for (int32 i = 0; i < size; ++i) {
        // 取当前字符与 key 的低 8 位异或,作为查表索引
        const uint8 index = static_cast<uint8>(str[i] ^ key);

        // 查表结果与右移后的旧状态异或,得到下一状态
        key = KeyTable[index] ^ (key >> 8);
    }

    // 此处没有再与 0xFFFFFFFF 异或,因此结果属于 JAMCRC 形式
    return key;
}

完整实现位于 Core/KeyGen.h。其中 KeyTable 是由反射多项式 0xEDB88320 生成的 256 项查找表。代码依次读取反向缓冲区中的字符并更新 key,最终得到一个 32 位整数;作弊码表比较的正是该结果,原始字符的语义完全不参与判断。

32 位输出一共只有:

2^32 = 4,294,967,296

种可能,而可能的输入字符串数量是无限的,比输出空间大得多。输入数量既然超过输出取值范围,肯定会有不同输入对应同一输出,这就是“碰撞”。对 CRC32 这样的非密码学哈希而言,碰撞不仅存在,而且可以被主动构造。

只考虑 26 个大写英文字母,字符串数量会随长度迅速增加:

长度可选字符串数量相对于全部 32 位结果的平均数量
626^6 = 308,915,7760.072
726^7 = 8,031,810,1761.87
826^8 = 208,827,064,57648.6

长度达到 7 个字母时,可能的字符串数量就已经超过 CRC32 的全部输出;长度达到 8 个字母时,平均每个 32 位结果能对应近 49 个大写字符串。

这里的“平均”并不意味着每个目标值都必然存在 49 条易于输入的作弊码,但这一数量关系足以说明:在如此庞大的候选集合中找到另一条结果相同的字符串,并非什么极端偶然的事件。

五、碰撞字符串的搜索#

最直接的方法是穷举:将 8 位大写字母的所有组合逐一尝试,直到找到命中目标数值的那一个。但这样要检查超过 2088 亿种可能,普通电脑硬算的成本相当高。

更高效的做法是 Meet-in-the-Middle(中间相遇),利用了 CRC32 运算可以双向进行的特点:已知一个字符时,既可以正向计算下一个状态,也可以从结果反推上一个状态。具体步骤如下:

  1. 将 8 个字符拆成两半:ABCD | EFGH
  2. 前半段 ABCD 从初始值正向计算,枚举并保存所有可能的中间状态;
  3. 后半段 EFGH 从目标值反向计算,同样枚举所有可能的中间状态;
  4. 若两边在某个中间状态上相等,拼接得到的 ABCDEFGH 就是一条能命中目标值的字符串。

这里的 ABCDEFGH 指传给 CKeyGen::GetKey 的字节顺序。由于 GTA:SA 会把键盘输入反向保存,若程序找到的 CRC 输入为 PPWAAGGF,玩家实际需要输入的是反转后的 FGGAAWPP

中间相遇并不是游戏本身的逻辑,而是建立在 CKeyGen::GetKey 状态转移之上的搜索方法。以下 C++ 代码给出其核心实现,其中 kKeyTable 对应 CKeyGen::KeyTable 的 256 项内容:

#include <algorithm>
#include <array>
#include <cstdint>
#include <optional>
#include <unordered_map>

using State = std::uint32_t;
using Half  = std::array<char, 4>;
using Input = std::array<char, 8>;

constexpr State kPolynomial = 0xEDB88320u;
constexpr State kInitial    = 0xFFFFFFFFu;
constexpr std::uint32_t kHalfSpace = 26u * 26u * 26u * 26u;

// 生成与 CKeyGen::KeyTable 相同的 256 项查找表
consteval std::array<State, 256> BuildKeyTable() {
    std::array<State, 256> table{};
    for (State i = 0; i < table.size(); ++i) {
        State value = i;
        for (int bit = 0; bit < 8; ++bit) {
            value = (value >> 1) ^ ((value & 1) ? kPolynomial : 0);
        }
        table[i] = value;
    }
    return table;
}

constexpr auto kKeyTable = BuildKeyTable();

// 由查找表最高字节反查索引,用于撤销一次状态转移
consteval std::array<std::uint8_t, 256> BuildReverseIndex() {
    std::array<std::uint8_t, 256> reverseIndex{};
    for (std::uint32_t i = 0; i < kKeyTable.size(); ++i) {
        reverseIndex[kKeyTable[i] >> 24] = static_cast<std::uint8_t>(i);
    }
    return reverseIndex;
}

constexpr auto kReverseIndex = BuildReverseIndex();

State ForwardByte(State state, std::uint8_t byte) {
    const auto index = static_cast<std::uint8_t>(state ^ byte);
    return kKeyTable[index] ^ (state >> 8);
}

State ReverseByte(State nextState, std::uint8_t byte) {
    // nextState 的最高字节只可能来自某一个查表项
    const auto index = kReverseIndex[nextState >> 24];

    // 撤销查表异或与右移,并由 index ^ byte 恢复旧状态低字节
    const State upper = (nextState ^ kKeyTable[index]) << 8;
    return upper | static_cast<std::uint8_t>(index ^ byte);
}

Half DecodeBase26(std::uint32_t value) {
    Half result{};
    for (int i = 3; i >= 0; --i) {
        result[i] = static_cast<char>('A' + value % 26);
        value /= 26;
    }
    return result;
}

State ForwardHalf(const Half& half) {
    State state = kInitial;
    for (const auto ch : half) {
        state = ForwardByte(state, static_cast<std::uint8_t>(ch));
    }
    return state;
}

State ReverseHalf(State target, const Half& half) {
    State state = target;
    for (auto it = half.rbegin(); it != half.rend(); ++it) {
        state = ReverseByte(state, static_cast<std::uint8_t>(*it));
    }
    return state;
}

std::optional<Input> FindEightLetterInput(State target) {
    std::unordered_map<State, Half> leftStates;
    leftStates.reserve(kHalfSpace);

    // 枚举 ABCD,保存 initial --ABCD--> middle
    for (std::uint32_t i = 0; i < kHalfSpace; ++i) {
        const auto left = DecodeBase26(i);
        leftStates.try_emplace(ForwardHalf(left), left);
    }

    // 枚举 EFGH,计算 middle --EFGH--> target 的逆过程
    for (std::uint32_t i = 0; i < kHalfSpace; ++i) {
        const auto right = DecodeBase26(i);
        const State middle = ReverseHalf(target, right);

        const auto found = leftStates.find(middle);
        if (found == leftStates.end()) {
            continue;
        }

        Input result{};
        std::copy(found->second.begin(), found->second.end(), result.begin());
        std::copy(right.begin(), right.end(), result.begin() + 4);
        return result;
    }

    return std::nullopt;
}

0xEECCEA2B 为目标值,可以得到如下一组结果:

参与 CRC 计算的顺序: PPWAAGGF
玩家在游戏中的输入: FGGAAWPP
结果校验: 0xeeccea2b

ReverseByte 是这段代码的核心。正向更新时,旧状态先右移 8 位,因此新状态的最高字节完全来自某个查表项;反向计算先用这一最高字节确定查表索引,再恢复旧状态被移走的最低字节。由此,每个已知字符对应的状态转移都可以精确撤销。

这样一来,原本需要逐个尝试的 26^8 = 208,827,064,576 种组合,拆成两半后只需分别准备 26^4 = 456,976 种可能,合计 26^4 + 26^4 = 913,952 次运算。搜索规模由两千多亿降到了百万级,代价是要先将一边的所有中间结果保存下来,供另一边查找比对,但这已经是普通电脑可以胜任的规模。

但找到数学上成立的碰撞字符串,并不代表它能在 GTA:SA 中直接使用,还存在两个限制:

  • 字符范围:CRC32 也可以在已知目标结果的情况下,反推出几个特定字节使整段数据刚好得到该值,但这样得到的字节未必落在英文字母范围内,需要额外筛选出可输入、便于记忆的组合;
  • 游戏的匹配规则:GTA:SA 从第 6 个字符起就会开始判断是否命中作弊码。如果一条较长候选码的前 6 位或前 7 位提前命中了别的效果,游戏会立即执行该效果并清空输入缓冲区,后续字符就无法再组成原本设想的那条作弊码了。

六、CRC32 与 SHA-256 的用途差异#

CRC32 容易产生相同结果,这也引出另一个问题:Rockstar 为什么不使用 SHA-256 这类密码学哈希?

原因在于两种算法的设计目标本就不同。

CRC32 的目标是快速发现数据中的意外错误,而不是防止攻击者主动构造相同结果。它的输出只有 32 位,内部结构也便于分析,所以可以寻找碰撞,甚至定向调整数据以得到需要的结果。

SHA-256 则面向密码学用途:输出是 256 位,计算过程经过多轮混合,特意避免了 CRC32 那种容易被利用的线性关系。理论上 SHA-256 也必然存在碰撞——毕竟固定长度的输出无法容纳无限多的输入——但在现实计算能力下,人们无法用对付 CRC32 的方法轻易找到它们。

讨论哈希时,还需要区分三个相近的概念:

  • 碰撞:自由寻找两条不同内容,使它们的结果相同;
  • 原像:给定一个结果,寻找一条能得到该结果的内容;
  • 第二原像:给定一条现有内容,再寻找另一条结果相同的内容。

寻找一条能触发指定 GTA:SA 作弊效果的字符串,更接近带有长度与字符范围限制的原像搜索;从 INEEDSOMEHELP 出发寻找另一条结果相同的字符串,则可以视为第二原像问题。日常表述常把这些情况统称为“哈希碰撞”,但它们在密码学中的含义并不完全相同。

SHA-256 属于密码学哈希,但并不适合直接保存密码。现代密码存储仍应使用带盐、计算缓慢并具有一定内存成本的专用方案,例如 Argon2、scrypt、bcrypt 或 PBKDF2。文件的 SHA-256 摘要,也只有在来源可信,或配合 HMAC、数字签名等认证机制时,才能证明文件未被恶意替换。

七、CRC32 是否适合这一场景#

若用 CRC32 保存密码、验证数字签名或防范主动篡改,显然并不合适。但 GTA:SA 的作弊码系统并不承担这些安全职责。

这套系统的主要需求其实是:

  • 快速处理玩家的每次按键;
  • 避免在程序中直接保存明显的作弊码明文;
  • 以较低成本完成匹配;
  • 把正常游戏中的误触概率控制在可接受范围内。

即便玩家找到了另一串字符,最终也只会触发一项既有的单机游戏功能,不会绕过账户认证或获取服务器权限。对这样的使用场景而言,轻量的 32 位 Key 已经足够。换成 SHA-256 固然会让碰撞更难寻找,却不会为这款单机游戏带来什么实际价值。

所以,GTA:SA 的作弊码看似古怪,并不是设计者随意排列字母的结果,而更像是游戏实现方式留下的痕迹:程序保存的是数值,玩家输入的是能命中这些数值的字符组合,可读性从来都不是首要条件。

参考资料#


上一篇:

评论