游戏安全初探: 从Pokemon火红开始

一直都很想接触一下逆向相关的东西,但是技术太有限了,只能先从简单的方面入手

你问我为什么从Pokemon FireRed开始?这可是爷的青春!

0x00 提取GBA的BINARY

在github上可以找到火红的整个项目代码:https://github.com/pret/pokefirered

这无疑是减少了巨量的逆向工作量,but更多的时候我们都需要从0开始,所以我还是打算从提取binary这一步做起。

这里需要用到一个工具:no$gba ( Download:https://problemkaputt.github.io/gba.htm )

1679061233844.png

载入火红ROM的gba文件之后,转到0x08000000地址处,

使用no$gba菜单栏的 Utility -> Binarydump to .bin FILE, 输入0x01000000 (16MB, 火红ROM的大小)代表要提取这么多数据。

接下来用ida打开,选择Processor type为ARM Little-endian [ARM]

1679061466938.png

然后从0x08000000处开始载入0x1000000个字节

1679061521278.png

不过这个时候函数表还是空的,在IDA View-A视图中地址0x08000000的地方按p,就能分析出来很多函数了,并且也能正常反编译。

1679063200200.png

不过,直接这样看很难看出来这些函数究竟是在干什么,结合动调可能会好一些,但是效率任然会很低。

结合该项目现有的资源,我又找到了他的符号表,

(Download: https://raw.githubusercontent.com/pret/pokefirered/symbols/pokefirered.sym)

类似于如下的格式:

08000000 g 00000000 Start
08000000 l 00000000 .text
08000004 g 00000000 RomHeaderNintendoLogo
080000a0 l 00000000 RomHeaderGameTitle
080000ac g 00000000 RomHeaderGameCode
080000b0 l 00000000 RomHeaderMakerCode
080000b2 l 00000000 RomHeaderMagic
080000b3 l 00000000 RomHeaderMainUnitCode
080000b4 l 00000000 RomHeaderDeviceType
080000b5 l 00000000 RomHeaderReserved1
080000bc g 00000000 RomHeaderSoftwareVersion
080000bd l 00000000 RomHeaderChecksum
080000be l 00000000 RomHeaderReserved2
080000c4 g 00000000 GPIOPortData
080000c6 g 00000000 GPIOPortDirection
080000c8 g 00000000 GPIOPortReadEnable
08000100 l 00000000 .gcc2_compiled.
08000100 l 00000104 sGFRomHeader
08000204 g 00000000 start_vector
08000238 l 00000000 sp_usr
0800023c l 00000000 sp_irq
08000248 g 00000000 intr_main
0800031c l 00000000 loop
08000320 l 00000000 jump_intr
08000374 l 00000000 intr_return
080003a4 g 0000010c AgbMain
080003a4 l 00000000 .gcc2_compiled.
080004b0 l 00000014 UpdateLinkAndCallCallbacks
080004c4 l 0000004c InitMainCallbacks
08000510 l 00000034 CallCallbacks
08000544 g 00000014 SetMainCallback2

......

有了符号表和源码,我们就可以为所欲为啦。

至此,最基础的提取就告一段落了,后续将结合源码来看对它进行一些较为完整的逆向利用。

0x01 实现财富自由——浅析Money反作弊机制

想要快速在一个游戏里获得快感,有一个朴素的方法就是暴富()

所以我们的第一步就是弄清楚这个游戏的金钱系统是如何运转的,以及如何破解游戏的反作弊来让自己实现财富自由。

初试

一开始使用了最暴力的方法来尝试修改现金:CheatEngine内存扫描

1679119390931.png

但是事情远远没有这么简单,这个地址几乎就是储存金钱的地方,但是每次重新进行了获取金钱的行为之后,它都会被复原到之前的值。所以说为了防止我们直接扫描内存来找到存储金钱的地址,开发者进行了一定的反作弊工作。

分析

在源码中找到了money.c不讲武德),有以下几个函数可以关注

#define MAX_MONEY 999999

u32 GetMoney(u32 *moneyPtr)
{
    return *moneyPtr ^ gSaveBlock2Ptr->encryptionKey;
}

void SetMoney(u32 *moneyPtr, u32 newValue)
{
    *moneyPtr = gSaveBlock2Ptr->encryptionKey ^ newValue;
}

bool8 IsEnoughMoney(u32 *moneyPtr, u32 cost)
{
    if (GetMoney(moneyPtr) >= cost)
        return TRUE;
    else
        return FALSE;
}

void AddMoney(u32 *moneyPtr, u32 toAdd)
{
    u32 toSet = GetMoney(moneyPtr);

    // can't have more money than MAX
    if (toSet + toAdd > MAX_MONEY)
    {
        toSet = MAX_MONEY;
    }
    else
    {
        toSet += toAdd;
        // check overflow, can't have less money after you receive more
        if (toSet < GetMoney(moneyPtr))
            toSet = MAX_MONEY;
    }

    SetMoney(moneyPtr, toSet);
}

void RemoveMoney(u32 *moneyPtr, u32 toSub)
{
    u32 toSet = GetMoney(moneyPtr);

    // can't subtract more than you already have
    if (toSet < toSub)
        toSet = 0;
    else
        toSet -= toSub;

    SetMoney(moneyPtr, toSet);
}

可以发现,程序通过GetMoney函数来计算出真实的金钱数,然后基于它实现了增加和减少金钱的函数。

增加金钱的时候有一个上限MAX_MONEY, 他的值是999999,所以说游戏内最多只能有这么多钱。

不难看出,反作弊的核心和我们利用的切入点都是GetMoney函数。

简单利用

分析至此,有了几种利用思路。

道具是我的,钱也是我的

第一个便是从RemoveMoney入手,钱不够用的话,那就把扣钱也改成加钱!

结合符号表,找到了RemoveMoney中扣钱的指令,在0809FE00这个位置,

RAM:0809FE00                 CODE16
RAM:0809FE00
RAM:0809FE00 loc_809FE00                             ; CODE XREF: sub_809FDE8+12↑j
RAM:0809FE00                 SUBS            R1, R1, R4
RAM:0809FE02
RAM:0809FE02 loc_809FE02                             ; CODE XREF: sub_809FDE8+16↑j
RAM:0809FE02                 MOVS            R0, R5
RAM:0809FE04                 BL              sub_809FD84
RAM:0809FE08                 POP             {R4,R5}
RAM:0809FE0A                 POP             {R0}
RAM:0809FE0C                 BX              R0

我们把SUBS给patch成ADDS即可让所有开销都变成进账()

1679121580616.png

09 1B改成09 19,然后去游戏里验证一下,

1679121651894.png

短短的几下购买,就赚得盆满钵满啦~

上述方法其实是有一点不优美的,毕竟直接改掉了原本的游戏代码,改掉了代码之后,程序的hash值都已经发生了变化,万一你的小伙伴要求验证你的客户端hash值,一看就能发现客户端被篡改了,那么你再多的钱在他眼里也一文不值咯。

精准数值的修改

玩家的money是与4字节的gSaveBlock2Ptr->encryptionKey异或之后存在内存中的,所以也说明了为什么之前用CE没办法直接扫描出来。

在很多函数中都能发现产生新encryptionKey并应用的代码,有一个思路是patch这个Random函数的返回值为0之类的固定值,不过这个方法任然有悖于不修改客户端的宗旨,所以并不继续尝试了。

// create a new encryption key
encryptionKey = (Random() << 0x10) + (Random());
ApplyNewEncryptionKeyToAllEncryptedData(encryptionKey);
gSaveBlock2Ptr->encryptionKey = encryptionKey;

想要精准修改money,就需要把这个encryptionKey的值给得到。

1679125232239.png

通过动调,得到了这次的key是0xAC728FDF, 然后计算一下现在的钱加密后的值,试一下能不能在CE中找到

10816 ^ 0xAC728FDF得到2893194655, 果然,直接搜出来了。

1679125366858.png

尝试修改之后重新加密,成功将钱变成114514

1679125543303.png

(不要在意为什么人物性转了,动调encryptionkey的时候找到了存档结构体,就试了一下改变性别)

至此,我们已经能够通过纯手工的方法来任意修改金钱了(好耶)。

(另外,通过对encryptionKey的交叉引用,可以发现在存储背包item和powder之类的功能中也使用了它来加密信息)

1679200195333.png

不过,想要编写程序来自动化修改还有一定的难度,因为encryptionkey在内存中的地址并不固定,可能需要挖掘一下指针来帮助定位。

自动化修改Money的一些尝试

加密金钱用的encryptionKey位于SaveBlock2这个结构体中,

struct SaveBlock2
{
    /*0x000*/ u8 playerName[PLAYER_NAME_LENGTH + 1];
    /*0x008*/ u8 playerGender; // MALE, FEMALE
    /*0x009*/ u8 specialSaveWarpFlags;
    /*0x00A*/ u8 playerTrainerId[TRAINER_ID_LENGTH];
    /*0x00E*/ u16 playTimeHours;
    /*0x010*/ u8 playTimeMinutes;
    /*0x011*/ u8 playTimeSeconds;
    /*0x012*/ u8 playTimeVBlanks;
    /*0x013*/ u8 optionsButtonMode;  // OPTIONS_BUTTON_MODE_[NORMAL/LR/L_EQUALS_A]
    /*0x014*/ u16 optionsTextSpeed:3; // OPTIONS_TEXT_SPEED_[SLOW/MID/FAST]
              u16 optionsWindowFrameType:5; // Specifies one of the 20 decorative borders for text boxes
    /*0x15*/  u16 optionsSound:1; // OPTIONS_SOUND_[MONO/STEREO]
              u16 optionsBattleStyle:1; // OPTIONS_BATTLE_STYLE_[SHIFT/SET]
              u16 optionsBattleSceneOff:1; // whether battle animations are disabled
              u16 regionMapZoom:1; // whether the map is zoomed in
    /*0x018*/ struct Pokedex pokedex;
    /*0x090*/ u8 filler_90[0x8];
    /*0x098*/ struct Time localTimeOffset;
    /*0x0A0*/ struct Time lastBerryTreeUpdate;
    /*0x0A8*/ u32 gcnLinkFlags; // Read by Pokemon Colosseum/XD
    /*0x0AC*/ bool8 unkFlag1; // Set TRUE, never read
    /*0x0AD*/ bool8 unkFlag2; // Set FALSE, never read
    /*0x0B0*/ struct BattleTowerData battleTower;
    /*0x898*/ u16 mapView[0x100];
    /*0xA98*/ struct LinkBattleRecords linkBattleRecords;
    /*0xAF0*/ struct BerryCrush berryCrush;
    /*0xB00*/ struct PokemonJumpRecords pokeJump;
    /*0xB10*/ struct BerryPickingResults berryPick;
    /*0xB20*/ u8 filler_B20[0x400];
    /*0xF20*/ u32 encryptionKey;
}; // size: 0xF24

extern struct SaveBlock2 *gSaveBlock2Ptr;

除此之外,还有一个同等重要的结构体SaveBlock1

struct SaveBlock1
{
    /*0x0000*/ struct Coords16 pos;
    /*0x0004*/ struct WarpData location;
    /*0x000C*/ struct WarpData continueGameWarp;
    /*0x0014*/ struct WarpData dynamicWarp;
    /*0x001C*/ struct WarpData lastHealLocation;
    /*0x0024*/ struct WarpData escapeWarp;
    /*0x002C*/ u16 savedMusic;
    /*0x002E*/ u8 weather;
    /*0x002F*/ u8 weatherCycleStage;
    /*0x0030*/ u8 flashLevel;
    /*0x0032*/ u16 mapLayoutId;
    /*0x0034*/ u8 playerPartyCount;
    /*0x0038*/ struct Pokemon playerParty[PARTY_SIZE];
    /*0x0290*/ u32 money;
    /*0x0294*/ u16 coins;
    /*0x0296*/ u16 registeredItem; // registered for use with SELECT button
    /*0x0298*/ struct ItemSlot pcItems[PC_ITEMS_COUNT];
    /*0x0310*/ struct ItemSlot bagPocket_Items[BAG_ITEMS_COUNT];
    /*0x03b8*/ struct ItemSlot bagPocket_KeyItems[BAG_KEYITEMS_COUNT];
    /*0x0430*/ struct ItemSlot bagPocket_PokeBalls[BAG_POKEBALLS_COUNT];
    /*0x0464*/ struct ItemSlot bagPocket_TMHM[BAG_TMHM_COUNT];
    /*0x054c*/ struct ItemSlot bagPocket_Berries[BAG_BERRIES_COUNT];
    /*0x05F8*/ u8 seen1[DEX_FLAGS_NO];
    /*0x062C*/ u16 berryBlenderRecords[3]; // unused
    /*0x0632*/ u8 unused_632[6];
    /*0x0638*/ u16 trainerRematchStepCounter;
    /*0x063A*/ u8 ALIGNED(2) trainerRematches[MAX_REMATCH_ENTRIES];
    /*0x06A0*/ struct ObjectEvent objectEvents[OBJECT_EVENTS_COUNT];
    /*0x08E0*/ struct ObjectEventTemplate objectEventTemplates[OBJECT_EVENT_TEMPLATES_COUNT];
    /*0x0EE0*/ u8 flags[NUM_FLAG_BYTES];
    /*0x1000*/ u16 vars[VARS_COUNT];
    /*0x1200*/ u32 gameStats[NUM_GAME_STATS];
    /*0x1300*/ struct QuestLog questLog[QUEST_LOG_SCENE_COUNT];
    /*0x2CA0*/ u16 easyChatProfile[EASY_CHAT_BATTLE_WORDS_COUNT];
    /*0x2CAC*/ u16 easyChatBattleStart[EASY_CHAT_BATTLE_WORDS_COUNT];
    /*0x2CB8*/ u16 easyChatBattleWon[EASY_CHAT_BATTLE_WORDS_COUNT];
    /*0x2CC4*/ u16 easyChatBattleLost[EASY_CHAT_BATTLE_WORDS_COUNT];
    /*0x2CD0*/ struct Mail mail[MAIL_COUNT];
    /*0x2F10*/ u8 additionalPhrases[NUM_ADDITIONAL_PHRASE_BYTES];
    /*0x2F18*/ OldMan oldMan; // unused
    /*0x2F54*/ struct DewfordTrend dewfordTrends[5]; // unused
    /*0x2F80*/ struct DayCare daycare;
    /*0x309C*/ u8 giftRibbons[GIFT_RIBBONS_COUNT];
    /*0x30A7*/ struct ExternalEventData externalEventData;
    /*0x30BB*/ struct ExternalEventFlags externalEventFlags;
    /*0x30D0*/ struct Roamer roamer;
    /*0x30EC*/ struct EnigmaBerry enigmaBerry;
    /*0x3120*/ struct MysteryGiftSave mysteryGift;
    /*0x348C*/ u8 unused_348C[400];
    /*0x361C*/ struct RamScript ramScript;
    /*0x3A08*/ struct RecordMixingGift recordMixingGift; // unused
    /*0x3A18*/ u8 seen2[DEX_FLAGS_NO];
    /*0x3A4C*/ u8 rivalName[PLAYER_NAME_LENGTH + 1];
    /*0x3A54*/ struct FameCheckerSaveData fameChecker[NUM_FAMECHECKER_PERSONS];
    /*0x3A94*/ u8 unused_3A94[64];
    /*0x3AD4*/ u8 registeredTexts[UNION_ROOM_KB_ROW_COUNT][21];
    /*0x3BA8*/ struct TrainerNameRecord trainerNameRecords[20];
    /*0x3C98*/ struct DaycareMon route5DayCareMon;
    /*0x3D24*/ u8 unused_3D24[16];
    /*0x3D34*/ u32 towerChallengeId;
    /*0x3D38*/ struct TrainerTower trainerTower[NUM_TOWER_CHALLENGE_TYPES];
}; // size: 0x3D68

SaveBlock1SaveBlock2是当前以及后续利用需要重点关注的结构体。

SaveBlock2的分配,以及对encryptionKey的动态更新操作都在load_save.c

void SetSaveBlocksPointers(void)
{
    u32 offset;
    struct SaveBlock1** sav1_LocalVar = &gSaveBlock1Ptr;
    void *oldSave = (void *)gSaveBlock1Ptr;

    offset = (Random()) & ((SAVEBLOCK_MOVE_RANGE - 1) & ~3);

    gSaveBlock2Ptr = (void *)(&gSaveBlock2) + offset;
    *sav1_LocalVar = (void *)(&gSaveBlock1) + offset;
    gPokemonStoragePtr = (void *)(&gPokemonStorage) + offset;

    SetBagPocketsPointers();
    SetQuestLogRecordAndPlaybackPointers(oldSave);
}

void MoveSaveBlocks_ResetHeap(void)
{
    void *vblankCB, *hblankCB;
    u32 encryptionKey;
    struct SaveBlock2 *saveBlock2Copy;
    struct SaveBlock1 *saveBlock1Copy;
    struct PokemonStorage *pokemonStorageCopy;

    // save interrupt functions and turn them off
    vblankCB = gMain.vblankCallback;
    hblankCB = gMain.hblankCallback;
    gMain.vblankCallback = NULL;
    gMain.hblankCallback = NULL;
    gMain.vblankCounter1 = NULL;
    
    saveBlock2Copy = (struct SaveBlock2 *)(gHeap);
    saveBlock1Copy = (struct SaveBlock1 *)(gHeap + sizeof(struct SaveBlock2));
    pokemonStorageCopy = (struct PokemonStorage *)(gHeap + sizeof(struct SaveBlock2) + sizeof(struct SaveBlock1));

    // backup the saves.
    *saveBlock2Copy = *gSaveBlock2Ptr;
    *saveBlock1Copy = *gSaveBlock1Ptr;
    *pokemonStorageCopy = *gPokemonStoragePtr;

    // change saveblocks' pointers
    SetSaveBlocksPointers(); // unlike Emerald, this does not use
                             // the trainer ID sum for an offset.

    // restore saveblock data since the pointers changed
    *gSaveBlock2Ptr = *saveBlock2Copy;
    *gSaveBlock1Ptr = *saveBlock1Copy;
    *gPokemonStoragePtr = *pokemonStorageCopy;

    // heap was destroyed in the copying process, so reset it
    InitHeap(gHeap, HEAP_SIZE);

    // restore interrupt functions
    gMain.hblankCallback = hblankCB;
    gMain.vblankCallback = vblankCB;

    // create a new encryption key
    encryptionKey = (Random() << 0x10) + (Random());
    ApplyNewEncryptionKeyToAllEncryptedData(encryptionKey);
    gSaveBlock2Ptr->encryptionKey = encryptionKey;
}

void ApplyNewEncryptionKeyToHword(u16 *hWord, u32 newKey)
{
    *hWord ^= gSaveBlock2Ptr->encryptionKey;
    *hWord ^= newKey;
}

void ApplyNewEncryptionKeyToWord(u32 *word, u32 newKey)
{
    *word ^= gSaveBlock2Ptr->encryptionKey;
    *word ^= newKey;
}

void ApplyNewEncryptionKeyToAllEncryptedData(u32 encryptionKey)
{
    int i;

    for(i = 0; i < NUM_TOWER_CHALLENGE_TYPES; i++)
        ApplyNewEncryptionKeyToWord(&gSaveBlock1Ptr->trainerTower[i].bestTime, encryptionKey);

    ApplyNewEncryptionKeyToGameStats(encryptionKey);
    ApplyNewEncryptionKeyToBagItems_(encryptionKey);
    ApplyNewEncryptionKeyToBerryPowder(encryptionKey);
    ApplyNewEncryptionKeyToWord(&gSaveBlock1Ptr->money, encryptionKey);
    ApplyNewEncryptionKeyToHword(&gSaveBlock1Ptr->coins, encryptionKey);
}

每次切换场景之后gSaveBlock2的位置都会变化,encryptionKey的值也会重新随机,对内存搜索造成了巨大的阻碍。(跳动指针反作弊)

通过对GetMoney函数交叉引用,可以找到我们最终想要修改的moneyPtr位于gSaveBlock1中,对于encryptionKey,其实我们是没必要特地去leak的,如果知道了内存中存储money的地址,也知道我们现在已经拥有的money数量,就能直接计算出encryptionKey的值了。

按道理来说,想要找到gSaveBlock1的位置,直接从0x03005008,也就是gSaveBlock1Ptr,这个地方来读就好了,但是我们利用的是内存搜索,并不能获取到这里的值,所以这里我还经过了几次动调,找到了在内存中的0x028FE108处会稳定出现gSaveBlock1的值,并且也能根据gSaveBlock1的值稳定调出money的位置。(but这个地址如果重新打开模拟器,也会变,所以想用这个还是有点折磨的,需要再次调出来一个地址。调试的具体过程我就不说了,并不是很麻烦)

代码

#include <windows.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

int main()
{
    HWND hwnd;
    DWORD pid;
    HANDLE process;

    hwnd = FindWindow(NULL, TEXT("No$gba Debugger (Fullversion)"));
    if (hwnd == NULL) {
        puts("window not found");
        getchar();
        exit(-1);
    }

    GetWindowThreadProcessId(hwnd, &pid);
    process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

    printf("[+] pid = %d\n", pid);

    DWORD gSaveBlock1Ptr = 0x28fe108;
    DWORD gSaveBlock1;
    DWORD moneyPtr;
    DWORD encryptionKey;
    DWORD *buf_recv = (DWORD *)malloc(0x1000 * sizeof(DWORD));
    DWORD moneyCur;  /* current money */
    DWORD moneyChg;  /* money you want to change */
    char tmp[0x20] = {};

    int n = ReadProcessMemory(process, (PCVOID)gSaveBlock1Ptr, buf_recv, 8, 0);
    gSaveBlock1 = buf_recv[0];
    printf("[+] gSaveBlock1 addr: 0x%x\n", gSaveBlock1);

    moneyPtr = gSaveBlock1 + 0x290 + 0x8b5100;
    ReadProcessMemory(process, (PCVOID)moneyPtr, buf_recv, 4, 0);
    printf("[+] money addr: 0x%x\n", moneyPtr);

    puts("[+] how many money now: ");
    scanf("%s", tmp);
    moneyCur = atoi(tmp);

    encryptionKey = moneyCur ^ buf_recv[0];

    puts("[+] new money: ");
    scanf("%s", tmp);
    moneyChg = atoi(tmp);
    moneyChg ^= encryptionKey;

    int result = WriteProcessMemory(process, (LPVOID)moneyPtr, &moneyChg, 4, 0);
    if (result) {
        puts("[+] Change succesfully!");
    }

    system("pause");

    return 0;
}

效果图

我们现在有114514块钱,

1679139002443.png

输入现在的钱和想要得到的钱之后,在游戏里就能看到被成功修改了,也能正常花费出去。

1679139075138.png

0x02 很难不谈的变强话题——获得强力宝可梦

熟悉宝可梦的玩家都知道,评判一个宝可梦是否优秀,可以从等级种族值天赋值努力值性格等方便来判断,如果有闲情雅致的话,还能追求一下他是否是闪光。

分析

这一次,我们从宝可梦属性的源码开始分析,

struct PokemonSubstruct0
{
    u16 species;
    u16 heldItem;
    u32 experience;
    u8 ppBonuses;
    u8 friendship;
};

struct PokemonSubstruct1
{
    u16 moves[4];
    u8 pp[4];
};

struct PokemonSubstruct2
{
    u8 hpEV;
    u8 attackEV;
    u8 defenseEV;
    u8 speedEV;
    u8 spAttackEV;
    u8 spDefenseEV;
    u8 cool;
    u8 beauty;
    u8 cute;
    u8 smart;
    u8 tough;
    u8 sheen;
};

struct PokemonSubstruct3
{
 /* 0x00 */ u8 pokerus;
 /* 0x01 */ u8 metLocation;

 /* 0x02 */ u16 metLevel:7;
 /* 0x02 */ u16 metGame:4;
 /* 0x03 */ u16 pokeball:4;
 /* 0x03 */ u16 otGender:1;

 /* 0x04 */ u32 hpIV:5;
 /* 0x04 */ u32 attackIV:5;
 /* 0x05 */ u32 defenseIV:5;
 /* 0x05 */ u32 speedIV:5;
 /* 0x05 */ u32 spAttackIV:5;
 /* 0x06 */ u32 spDefenseIV:5;
 /* 0x07 */ u32 isEgg:1;
 /* 0x07 */ u32 abilityNum:1;

 /* 0x08 */ u32 coolRibbon:3;
 /* 0x08 */ u32 beautyRibbon:3;
 /* 0x08 */ u32 cuteRibbon:3;
 /* 0x09 */ u32 smartRibbon:3;
 /* 0x09 */ u32 toughRibbon:3;
 /* 0x09 */ u32 championRibbon:1;
 /* 0x0A */ u32 winningRibbon:1;
 /* 0x0A */ u32 victoryRibbon:1;
 /* 0x0A */ u32 artistRibbon:1;
 /* 0x0A */ u32 effortRibbon:1;
 /* 0x0A */ u32 marineRibbon:1; // never distributed
 /* 0x0A */ u32 landRibbon:1; // never distributed
 /* 0x0A */ u32 skyRibbon:1; // never distributed
 /* 0x0A */ u32 countryRibbon:1; // distributed during Pok茅mon Festa '04 and '05 to tournament winners
 /* 0x0B */ u32 nationalRibbon:1;
 /* 0x0B */ u32 earthRibbon:1;
 /* 0x0B */ u32 worldRibbon:1; // distributed during Pok茅mon Festa '04 and '05 to tournament winners
 /* 0x0B */ u32 unusedRibbons:4; // discarded in Gen 4
 /* 0x0B */ u32 eventLegal:1; // controls Mew & Deoxys obedience; if set, Pok茅mon is a fateful encounter in FRLG & Gen 4+ summary screens; set for in-game event island legendaries, some distributed events, and Pok茅mon from XD: Gale of Darkness.
};

union PokemonSubstruct
{
    struct PokemonSubstruct0 type0;
    struct PokemonSubstruct1 type1;
    struct PokemonSubstruct2 type2;
    struct PokemonSubstruct3 type3;
    u16 raw[6];
};

struct BoxPokemon
{
    u32 personality;
    u32 otId;
    u8 nickname[POKEMON_NAME_LENGTH];
    u8 language;
    u8 isBadEgg:1;
    u8 hasSpecies:1;
    u8 isEgg:1;
    u8 unused:5;
    u8 otName[PLAYER_NAME_LENGTH];
    u8 markings;
    u16 checksum;
    u16 unknown;

    union
    {
        u32 raw[12];
        union PokemonSubstruct substructs[4];
    } secure;
};

struct Pokemon
{
    struct BoxPokemon box;
    u32 status;
    u8 level;
    u8 mail;
    u16 hp;
    u16 maxHP;
    u16 attack;
    u16 defense;
    u16 speed;
    u16 spAttack;
    u16 spDefense;
};

可以发现Pokemon结构体的组成分为了两个大部分,一个部分是BoxPokemon结构体,我们这里称他为非面板值,剩下的部分就是面板值。

东西有点多,暂时不一一解释了。(其实这个源码取的名字已经很贴心了,大致都能看出来是什么)

要利用,就得先找出来这个结构体在程序中哪些地方被用上了。

一个比较显眼的地方是gSaveBlock1中的struct Pokemon playerParty[PARTY_SIZE];这个成员。PARTY_SIZE是6,很容易想到它就是当前背包里的6只宝可梦的信息。

对这个playerParty进行交叉引用,可以看到他来源于全局数组gPlayerParty

void SavePlayerParty(void)
{
    int i;

    gSaveBlock1Ptr->playerPartyCount = gPlayerPartyCount;

    for (i = 0; i < PARTY_SIZE; i++)
        gSaveBlock1Ptr->playerParty[i] = gPlayerParty[i];
}

void LoadPlayerParty(void)
{
    int i;

    gPlayerPartyCount = gSaveBlock1Ptr->playerPartyCount;

    for (i = 0; i < PARTY_SIZE; i++)
        gPlayerParty[i] = gSaveBlock1Ptr->playerParty[i];
}

跟进gPlayerParty,可以发现在对战,赠送,发送到PC等等功能中,只要是涉及到对身上的Pokemon进行操作的地方都会用到它,并且往往都是配合GetMonData这个函数出现的。

GetMonData被定义在pokemon.c中,

u32 GetMonData(struct Pokemon *mon, s32 field, u8 *data)
{
    u32 ret;

    switch (field)
    {
    case MON_DATA_STATUS:
        ret = mon->status;
        break;
    case MON_DATA_LEVEL:
        ret = mon->level;
        break;
    case MON_DATA_HP:
        ret = mon->hp;
        break;
    case MON_DATA_MAX_HP:
        ret = mon->maxHP;
        break;
    case MON_DATA_ATK:
        ret = GetDeoxysStat(mon, STAT_ATK);
        if (!ret)
            ret = mon->attack;
        break;
    case MON_DATA_DEF:
        ret = GetDeoxysStat(mon, STAT_DEF);
        if (!ret)
            ret = mon->defense;
        break;
    case MON_DATA_SPEED:
        ret = GetDeoxysStat(mon, STAT_SPEED);
        if (!ret)
            ret = mon->speed;
        break;
    case MON_DATA_SPATK:
        ret = GetDeoxysStat(mon, STAT_SPATK);
        if (!ret)
            ret = mon->spAttack;
        break;
    case MON_DATA_SPDEF:
        ret = GetDeoxysStat(mon, STAT_SPDEF);
        if (!ret)
            ret = mon->spDefense;
        break;
    case MON_DATA_ATK2:
        ret = mon->attack;
        break;
    case MON_DATA_DEF2:
        ret = mon->defense;
        break;
    case MON_DATA_SPEED2:
        ret = mon->speed;
        break;
    case MON_DATA_SPATK2:
        ret = mon->spAttack;
        break;
    case MON_DATA_SPDEF2:
        ret = mon->spDefense;
        break;
    case MON_DATA_MAIL:
        ret = mon->mail;
        break;
    default:
        ret = GetBoxMonData(&mon->box, field, data);
        break;
    }
    return ret;
}

该函数会返回查询的Pokemon的对应信息,可以看到面板值全都是直接返回的,而非面板值使用了另一个函数GetBoxMonData来中转,它的定义如下(源码有点长,为了不影响我们的思路,删减了一部分):

u32 GetBoxMonData(struct BoxPokemon *boxMon, s32 field, u8 *data)
{
    s32 i;
    u32 retVal = 0;
    struct PokemonSubstruct0 *substruct0 = NULL;
    struct PokemonSubstruct1 *substruct1 = NULL;
    struct PokemonSubstruct2 *substruct2 = NULL;
    struct PokemonSubstruct3 *substruct3 = NULL;

    if (field > MON_DATA_ENCRYPT_SEPARATOR)
    {
        substruct0 = &(GetSubstruct(boxMon, boxMon->personality, 0)->type0);
        substruct1 = &(GetSubstruct(boxMon, boxMon->personality, 1)->type1);
        substruct2 = &(GetSubstruct(boxMon, boxMon->personality, 2)->type2);
        substruct3 = &(GetSubstruct(boxMon, boxMon->personality, 3)->type3);

        DecryptBoxMon(boxMon);

        if (CalculateBoxMonChecksum(boxMon) != boxMon->checksum)
        {
            boxMon->isBadEgg = 1;
            boxMon->isEgg = 1;
            substruct3->isEgg = 1;
        }
    }

    switch (field)
    {
    case MON_DATA_PERSONALITY:
        retVal = boxMon->personality;
        break;
    case MON_DATA_OT_ID:
        retVal = boxMon->otId;
        break;
    case MON_DATA_NICKNAME:
    {
        if (boxMon->isBadEgg)
        {
            for (retVal = 0;
                retVal < POKEMON_NAME_LENGTH && gText_BadEgg[retVal] != EOS;
                data[retVal] = gText_BadEgg[retVal], retVal++) {}

            data[retVal] = EOS;
        }
        else if (boxMon->isEgg)
        {
            StringCopy(data, gText_EggNickname);
            retVal = StringLength(data);
        }
        else if (boxMon->language == LANGUAGE_JAPANESE)
        {
            data[0] = EXT_CTRL_CODE_BEGIN;
            data[1] = EXT_CTRL_CODE_JPN;

            // FRLG changed i < 7 to i < 6
            for (retVal = 2, i = 0;
                i < 6 && boxMon->nickname[i] != EOS;
                data[retVal] = boxMon->nickname[i], retVal++, i++) {}

            data[retVal++] = EXT_CTRL_CODE_BEGIN;
            data[retVal++] = EXT_CTRL_CODE_ENG;
            data[retVal] = EOS;
        }
        else
        {
            for (retVal = 0;
                retVal < POKEMON_NAME_LENGTH;
                data[retVal] = boxMon->nickname[retVal], retVal++){}

            data[retVal] = EOS;
        }
        break;
    }
    
    /* .................. */
            
    default:
        break;
    }

    if (field > MON_DATA_ENCRYPT_SEPARATOR)
        EncryptBoxMon(boxMon);

    return retVal;
}

这个函数的信息量就非常大了,查询的范围(field)在MON_DATA_ENCRYPT_SEPARATOR控制的区域内的时候,函数会做两件事,对boxMon进行解密和计算checksum并验证合法性。checksum在验证失败的时候会让我们的宝可梦变成一个badegg,然后永久没法孵化,boxMon的加密让我们没法直接获取某些数据。对于这两个流程,我们来挨个分析一下。

先看一下CalculateBoxMonChecksum函数,

static u16 CalculateBoxMonChecksum(struct BoxPokemon *boxMon)
{
    u16 checksum = 0;
    union PokemonSubstruct *substruct0 = GetSubstruct(boxMon, boxMon->personality, 0);
    union PokemonSubstruct *substruct1 = GetSubstruct(boxMon, boxMon->personality, 1);
    union PokemonSubstruct *substruct2 = GetSubstruct(boxMon, boxMon->personality, 2);
    union PokemonSubstruct *substruct3 = GetSubstruct(boxMon, boxMon->personality, 3);
    s32 i;

    for (i = 0; i < 6; i++)
        checksum += substruct0->raw[i];

    for (i = 0; i < 6; i++)
        checksum += substruct1->raw[i];

    for (i = 0; i < 6; i++)
        checksum += substruct2->raw[i];

    for (i = 0; i < 6; i++)
        checksum += substruct3->raw[i];

    return checksum;
}

这个checksum是根据boxMon->personality,通过GetSubStruct计算出相关的四个substruct的地址,然后再用共用体secure将四个指针分成若干个32位无符号整数raw[i]来累加生成的。

GetSubStruct内置了25种情况的跳表(对应25种性格)来完成绑定,原理简单的说就是选定四个地址的某四位取出来结合成一个新的地址,这里不深入讨论它的原理。

然后将目光放到对boxMon的加解密上,

static void EncryptBoxMon(struct BoxPokemon *boxMon)
{
    u32 i;
    for (i = 0; i < 12; i++)
    {
        boxMon->secure.raw[i] ^= boxMon->personality;
        boxMon->secure.raw[i] ^= boxMon->otId;
    }
}

static void DecryptBoxMon(struct BoxPokemon *boxMon)
{
    u32 i;
    for (i = 0; i < 12; i++)
    {
        boxMon->secure.raw[i] ^= boxMon->otId;
        boxMon->secure.raw[i] ^= boxMon->personality;
    }
}

可以看到对加密区域的数据的加密是根据原值对otIdpersonality的异或得到的,这里并没有使用原来的encryptionKey来加密。那么想要动调找到宝可梦信息之后解密也就很简单了。

(坏蛋!)

1679221414711.png

提取背包宝可梦数据

根据gSaveBlock1Ptr找到gSaveBlock1之后,往后0x34就是背包宝可梦的数量和宝可梦的信息了,比如我此时有两个宝可梦,小火龙和波波,他们的信息如下(小火龙从020255AC开始,波波从02025610开始):

020255A0 00 00 02 00 00 00 4F 00 02 00 00 00 8F 23 D0 32  ......O......#.2 
020255B0 1E 67 17 F7 BD C2 BB CC C7 BB C8 BE BF CC 02 02  .g.............. 
020255C0 C5 C9 CE C9 CC C3 FF 00 54 94 00 00 9B 44 EA C5  ........T....D.. 
020255D0 A5 44 C7 C5 B2 6C DE C5 95 44 C7 C5 EB 45 C7 C5  .D...l...D...E.. 
020255E0 91 12 C7 C5 91 1C C2 67 B4 BB C4 DC 91 44 C7 C5  .......g.....D.. 
020255F0 90 44 C6 C7 91 44 C7 C5 91 44 C7 C5 00 00 00 00  .D...D...D...... 
02025600 08 FF 18 00 18 00 0F 00 0C 00 0F 00 10 00 0D 00  ................ 
02025610 1B DD 46 D6 1E 67 17 F7 CA C3 BE C1 BF D3 FF 00  ..F..g.......... 
02025620 00 00 02 02 C5 C9 CE C9 CC C3 FF 00 E5 56 00 00  .............V.. 
02025630 05 DC 55 83 ED 5C 14 01 05 BA 51 21 15 BA 51 21  ..U..\....Q!..Q! 
02025640 65 BA 51 21 05 FD 51 21 05 BA 51 21 05 BA 51 21  e.Q!..Q!..Q!..Q! 
02025650 05 BA 51 21 24 BA 51 21 05 BA 51 21 26 BA 51 21  ..Q!$.Q!..Q!&.Q! 
02025660 00 00 00 00 04 FF 11 00 11 00 09 00 09 00 08 00  ................ 
02025670 07 00 08 00 00 00 00 00 00 00 00 00 00 00 00 00  ................ 

以小火龙的信息为例分析一下,0x32D0238F是他的性格,0xF717671E是otId,其中的0x671E是表id,也就是显示在训练家信息里的id,和我的26398刚好能对上。继续往后是昵称的编码,CHARMANDER对应的编码就是BD C2 BB CC C7 BB C8 BE BF CC这10个hex数据......

(pokemon的字符集并没有使用ASCII,而是他自己的一套)

一直分析到checksum之后,就会发现很难分析出信息了,这一块区域就是被MON_DATA_ENCRYPT_SEPARATOR分离出来的加密区域,再之后,到等级,现HP,最大HP,攻击力这些面板属性的位置又能通过明文看懂了。

解密

动调DecryptBoxMon拿到的小火龙原始数据如下,

02024280 00 00 00 00 8F 23 D0 32 1E 67 17 F7 BD C2 BB CC  .....#.2.g...... 
02024290 C7 BB C8 BE BF CC 02 02 C5 C9 CE C9 CC C3 FF 00  ................ 
020242A0 54 95 00 00 0A 00 2D 00 34 00 00 00 23 28 19 00  T....#.24...#(.. 
020242B0 04 00 00 00 7A 01 00 00 00 57 00 00 00 58 05 A2  ....z....W...X.. 
020242C0 25 FF 03 19 00 00 00 00 01 00 01 02 00 00 00 00  %............... 
020242D0 00 00 00 00 00 00 00 00 08 FF 18 00 18 00 0F 00  ................ 
020242E0 0C 00 0F 00 10 00 0D 00 1B DD 46 D6 1E 67 17 F7  ..........F..g.. 

现在就很清晰了,比如说0x20242AC处的23 28 19 00就分别代表它的4个技能的剩余PP值,又比如0x20242C0处的0x1903FF25就代表这只小火龙的个体值,按二进制展开来看如下

00 01100 10000 00111 11111 11001 00101
非梦特 非蛋
特防: 12
特攻: 16
速度: 7
防御: 31 (V)
攻击: 25
生命: 5

结合源码就能相对容易的看出来需要的信息了。

打造一只完美宝可梦

至此,我们已经分析了一遍宝可梦数据的存储和反作弊机制,是时候小试牛刀,打造一个理想中的完美宝可梦了。

闪光

闪光(异色)这个概念对于常玩Pokemon系列游戏的玩家都不陌生,遇到一只闪光宝可梦的概率非常低,大概是1/4096,这就决定了它的逼格是非常高的。

因此,我们的第一个目标就是将我们的小火龙变成闪光小火龙。

细心的小伙伴会发现,之前的Pokemon结构体中好像并没有用于标记一个宝可梦是否闪光的变量,那么闪光究竟是如何判断的呢?

从第三世代起,宝可梦是否异色不再与其能力挂钩,而是由ID No.、里ID No.、初训家以及性格值共同决定。

(ID No. xor 里ID No.) xor (性格值前16位 xor 性格值后16位) < 8

这是宝可梦百科对闪光的描述,并且我们分析的宝可梦火红正好是第三世代。

可以看出闪光只与otId和personality挂钩。

不想改personality是因为personality涉及到了几个substruct的指针构造,害怕出问题。所以我直接修改了里ID,

计算里ID的代码如下,

#include <windows.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

int main()
{
    unsigned int part1 = 0x115f;
    unsigned int otid = 0x671e0000;

    for (unsigned int i = 0; i< 0xFFFFFF; ++i) {
        unsigned int pp = otid + i;
        unsigned int part2 = ((pp >> 16) & 0xFFFF) ^ (pp & 0xFFFF);
        if ((part1 ^ part2) < 8) {
            printf("otId: 0x%x\n", pp);
            break;
        }
    }

    return 0;
}

/* otId: 0x671e7640 */
1679225726645.png

6V & 进化 & 强力技能

强度的大部分因素其实在secure保护下的几个结构体中,如果贸然改的话会导致checksum检查不通过,然后被变成badegg,经过辛苦的动调(内存断点下在小火龙的badegg位,然后跟进了一段时间),发现对checksum的比对是在0x803FDBC这个位置,那么我们只需要抢在系统判定之前把checksum修改为CalculateBoxMonChecksum计算出来的值就能绕过判断。

RAM:0803FDA6
RAM:0803FDA6                 MOVS            R5, R0
RAM:0803FDA8                 MOV             R0, R8
RAM:0803FDAA                 BL              sub_803F930
RAM:0803FDAE                 MOV             R0, R8
RAM:0803FDB0                 BL              loc_803E3FC
RAM:0803FDB4                 LSLS            R0, R0, #0x10
RAM:0803FDB6                 LSRS            R0, R0, #0x10
RAM:0803FDB8                 MOV             R1, R8
RAM:0803FDBA                 LDRH            R1, [R1,#0x1C]
RAM:0803FDBC                 CMP             R0, R1
RAM:0803FDBE                 BEQ             loc_803FDD6
RAM:0803FDC0                 MOV             R2, R8
RAM:0803FDC2                 LDRB            R0, [R2,#0x13]
RAM:0803FDC4                 MOVS            R1, #1
RAM:0803FDC6                 ORRS            R0, R1
RAM:0803FDC8                 MOVS            R1, #4
RAM:0803FDCA                 ORRS            R0, R1
RAM:0803FDCC                 STRB            R0, [R2,#0x13]
RAM:0803FDCE                 LDRB            R0, [R5,#7]
RAM:0803FDD0                 MOVS            R1, #0x40 ; '@'
RAM:0803FDD2                 ORRS            R0, R1
RAM:0803FDD4                 STRB            R0, [R5,#7]

这里我先让小火龙进化成喷火龙,也就是把species的4改成6,然后动调跟到对比小火龙checksum的部分,改掉对应的checksum值,即可绕过反作弊的检查。

1679226519852.png

以此类推,我们还能让他变成6V宝可梦,只需要将原本的个体值0x1903FF25修改为0x3FFFFFFF

我们还能找到存技能的位置,帮他配出来一些很牛逼的招式。这里给出我尝试的结果:

1679228984639.png

0x03 总结

总的来说,这一次对Pokemon FireRed的逆向分析的完成度和预期差不多,不过分析出来的大部分逻辑都依赖了源码,动调找位置也依赖了符号表。在更多情况下的逆向分析并没有这么好的条件给我用,所以说还需要继续努力。学到了挺多基础的反作弊机制,也试着自己去思考着找到了绕过方法,收获颇丰!