XCTF Final flappy-bird-cheat

Preface

有幸能和队友们一起参与第 7 届 XCTF Final,也是我生涯中第一次体验 Final 的氛围。这道题目是比赛第一天解题赛中我一直尝试攻破的题目,但很可惜直到最后一刻我也没能解出。赛后反思一下,归因于对题目中非对称密码的签名验证原理不够熟悉以及面对较大逆向工作量的题目时没有保持同一个清晰的思路。

A glimpse

题目是一道 unity 游戏相关的逆向,并且从游戏的 lib 目录下可以获知游戏是将 IL2cpp 作为后端构建出来的,于是最初的思路是使用 IL2cppDumper 提取 global-metadata 为 il2cpp.so 恢复符号表。

尝试过后,发现游戏中并没有获取 flag 的逻辑,推测这就是正常的 flappy bird 游戏,而 flag 藏在题目提供的流量与 zygisk 模块中。

通过 https://github.com/topjohnwu/zygisk-module-sample 简单了解一下 zygisk 模块的生成过程与使用方法,我们可以在使用高于 Magisk v24.0 root 的 Android 机上安装题目提供的 flappy-bird-cheat 模块。

安装并运行题目附件给出的 flappy bird 游戏,可以看到左上角窗口已经加载了这个 cheat 模块,窗口中有一个 Login 按钮,点击后会提示错误。

分析 cheat 模块中给出的 so 文件,通过对 Login 字符串的交叉引用可以定位到核心逻辑位于 sub_19D0D4,进一步分析函数的调用,我们可以得到 zygisk_module_entrysub_198AB4sub_1990D8 by pthread_create 这样一条函数调用栈,在 sub_1990D8 中我们关注 sub_1A0E28,结合函数内的字符串信息,可以获知该函数原型是:

PUBLIC int DobbyHook(void *address, dobby_dummy_func_t replace_func, dobby_dummy_func_t *origin_func)

sub_19D0D4 作为该函数的第二个参数,显然是使用 https://github.com/jmpews/Dobby Hook 后的游戏作弊代码。

Make things easier

比赛过程中我是直接选择硬着头皮逆 sub_19D0D4 这个函数的,但由于没有符号表与结构体信息,在实际过程中很容易瞻前不能顾后,在递归分析函数的过程中迷失方向,失去原本的程序逻辑。为了让逆向的过程更为顺利,有必要先恢复一下部分函数的符号表以及程序中用到的核心结构体信息。

Recover symbols

再回头观察一下 strings,我们可以得知关于生成该 so 文件的不少信息。例如,cheat 模块实现的过程中使用到了 DobbyHook、libcurl 7.84.0-DEV 与 openssl 3.0.5,而这些信息可以极大帮助我们恢复 so 的符号表,以 openssl 为例:

#!/bin/bash -e

OPENSSL_VERSION=3.0.5
NDK_VERSION=android-ndk-r22b
ANDROID_TARGET_API=21

WORK_PATH=$(pwd)
ANDROID_NDK_PATH=${WORK_PATH}/${NDK_VERSION}
OPENSSL_SOURCE_PATH=${WORK_PATH}/openssl-${OPENSSL_VERSION}

ANDROID_TARGET=arm64-v8a

OUTPUT_PATH=${WORK_PATH}/openssl_${OPENSSL_VERSION}_${ANDROID_TARGET}
OPENSSL_TMP_DIR=${WORK_PATH}/tmp/openssl_${ANDROID_TARGET}

# download android ndk
wget https://dl.google.com/android/repository/${NDK_VERSION}-linux-x86_64.zip
unzip ${NDK_VERSION}-linux-x86_64.zip

# download openssl
wget https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz
tar -zxvf openssl-${OPENSSL_VERSION}.tar.gz

mkdir -p ${OPENSSL_TMP_DIR}
cp -r ${OPENSSL_SOURCE_PATH}/* ${OPENSSL_TMP_DIR}

function build_library {
    mkdir -p ${OUTPUT_PATH}
    make -j4
    make install
    rm -rf ${OPENSSL_TMP_DIR}
    rm -rf ${OUTPUT_PATH}/bin
    rm -rf ${OUTPUT_PATH}/share
    rm -rf ${OUTPUT_PATH}/ssl
    rm -rf ${OUTPUT_PATH}/lib/engines*
    rm -rf ${OUTPUT_PATH}/lib/pkgconfig
    rm -rf ${OUTPUT_PATH}/lib/ossl-modules
    echo "Build completed! Check output libraries in ${OUTPUT_PATH}"
}

export ANDROID_NDK_ROOT=${ANDROID_NDK_PATH}
PATH=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin:$ANDROID_NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/bin:$ANDROID_NDK_ROOT/toolchains/aarch64-linux-android-4.9/prebuilt/linux-x86_64/bin:$PATH
cd ${OPENSSL_TMP_DIR}

echo "Starting compile..."

./Configure android-arm64 -D__ANDROID_API__=${ANDROID_TARGET_API} shared no-tests --prefix=${OUTPUT_PATH}
build_library

bash 脚本执行后我们可以在 $OUTPUT_PATH/lib 目录下找到 libcrypto.so,通过 bindiff 比对可以恢复一部分特征明显的函数符号。在后续题目的分析中,主要用到的是 openssl 的 evp 系列函数,其中包括 RSA 的加解密、签名的验证、消息摘要以及对称密码 AES 的使用。

Recover structures

结构体的构建主要是通过分析程序上下文信息推断出来的,主要涉及两个关键的结构体,对后续分析很有帮助

  1. String

    在逆向分析的过程中,可以发现如下的代码结构反复出现:

    __int64 arg;
    tmp = *(unsigned __int8 *)arg
    if ( (tmp & 1) == 0 )
        var = tmp >> 1;
    else
        var = *(_DWORD *)(arg + 8);
    if ( (tmp & 1) == 0 )
        ptr = arg + 1;
    else
        ptr = *(_QWORD *)(arg + 16);

    不难得知代码对应的是字符串相关操作,第一个 if else 判断是获得字符串的长度,第二个是获得字符串指针。参考 C++ 中 std::string 类型在内存中的分布,可以相应地推测出 so 中 String 的具体实现:

    // short string
    struct Short_String {
        // mask: flags & 1 == 0
        // length: flags >> 1
        char flags;
        char str[23];
    };
    
    // long string
    struct Long_String {
        // mask: flags & 1 == 1
        char flags;
        char unused[7];
        unsigned int length;
        char* str;
    };
    
    union String {
        struct Short_String s_str;
        struct Long_String l_str;
    };
  2. CheatContext

    回到 sub_1A0E28 函数,该函数调用 sub_48AB60 为全局变量 0x51AB38 指向的地址分配了 0x60 大小的内存空间。交叉引用这个全局变量,结合程序的上下文信息,最终可以逆向得知该全局变量对应的结构体原型大致为:

    // 0x51AB38 at .bss
    struct CheatContext {
        bool is_auth;
        bool is_shading_init;
        bool is_login_enable;
        bool is_login_fail;
        bool is_get_flag;
        bool invincible;
        bool multiple_score;
        char unknown_off_0x7;
        unsigned int multiplier;
        bool same_obstacle;
        bool more_obstacle;
        bool speed_up;
        char unknown_off_0xf;
        float speed_rate;
        int err_type;
        void* flag_part_1;
        void* auth_token;
        void* map_key_off_0x38;
        void* map_key_off_0x40;
        void* unknown_off_0x48;
        void* unknown_off_0x50;
        float unknown_off_0x58;
        int unknown_off_0x5C;
    };

    其中,带有 unknown 前缀的结构体成员在实际分析中并没有很好的上下文关系帮助推断,且与解出题目及了解程序运行逻辑没有太大关联,对后续分析影响不大。

Just reverse it

有了加解密部分的函数符号表已经关键变量的结构体,我们便可以较为愉悦地重新逆向分析这个 cheat 模块。不难得知,我们分析的主要目标集中在 sub_19D5C4sub_19DAE0 这两个函数,前者对应 Login 按钮按下后的逻辑,后者对应 Login 成功后,按下 Get Flag 按钮的逻辑。

分析 Login 逻辑,cheat 模块首先会读出 /data/data/re.ctf.flappybird/files/key.txt 中的内容,传入 sub_19BA24 函数进行 sha1 后得到 token,在流量中可以找到对应的字段为 token-ab003dd3b5eeea4d2100f9e1322536e00d10920f ,随后调用 sub_19B60C 函数通过 java 层的 API 读取了主机的 DEVICE_NAME,通过读 /proc 获取了 cmdline、tcp、tcp6 等信息进行拼接,接下来传入 sub_19BE78 进行加密得到最终在 HTTP 协议层中发送的 Content。

加密过程首先使用 srand(time(0) & 0xFFFFFFF8) 设置伪随机种子,随后用 rand() 生成了 32 字节的 AES CBC 加密密钥。Content 的内容由三部分构成,前 512 字节为使用 RSA 公钥加密后的 AES CBC 密钥,中间 512 字节为 AES CBC 加密后的信息,最后 512 字节为使用私钥签名后的 sha256 摘要。

得到最终 HTTP 协议中发送的 Content 后,程序调用 sub_19C750 函数构造 HTTP 请求与服务端交互,之后将服务端返回的内容先后进行 sub_19D784 验签 → sub_19D794 AES CBC 解密 → sub_19CDA4 运算解密得到服务端返回内容的明文。在这一过程中,AES CBC 解密所使用的密钥是硬编码在 .data 段中的。

分析 Get Flag 逻辑,可以得知 flag 的内容由两部分拼接而成,第一部分是 DEVICE_NAME 进行 sha256 后前 16 字节的 hex,第二部分是服务端返回内容进行格式化后 sha256 哈希的后 16 字节的 hex。

于是我们就有了题目的两种解法。第一种是完全按照 cheat 模块客户端与服务端交互的流程,根据密钥与加解密算法解出前后两段的 flag 内容;第二种是通过 AES CBC 解密获取请求流量中包含的 DEVICE_NAME 即前半段 flag,同时自实现服务端,使得客户端与其交互时自动生成 flag 的后半段。

My solution

这里采用的是第二种解法,相比第一种解法省去了逆向后半段解密算法这一繁琐易错的过程。

from http.server import HTTPServer, BaseHTTPRequestHandler

class Server(BaseHTTPRequestHandler):
    def do_POST(self):
        self.send_response(200)
        content = bytes.fromhex('d9b2bff81aaeb058b96d10106fb5c35edd0f7abf8dc5b425fcfda918e1e7e342bf6c18307a7f7902b23f08d8b17bcb32656b03ff1ee1793d578247623095442a6f64c9dd717c9301d187bfca5733383d218570bde1227ee160928ed5b36c0c6429762e754500a3445531444a4386a6ed3eb33fcb3ad823c0321d5d5c7ade907613ec4486203f6b067eb1f83c68e9ba33894ac3247f7ed0217753574ca52e213720b3fb1c4153e4644f3e1e4883b8530ca196f8bedeffe692a8c7f80459f6935def1d1041ae011f5d31fa13e6630e2c336af90e7dba2a405f89ca80059dfd84c524283cc576fc46da814ffdcc6cfe47fe3d879c0150174a725adfd19ea29bfff6b326f77eb218cf42cddf1b6e007a4fadde98d2ccdf4e72a69168e4fd3cca80d71fbbd8af19dec14bb1598e6a035b8dfb5403dc0de3aa6bd9fa33ac633b020625d499703dc379a7cd1dfd71e9934890ab9399389d4085a184bc1d1b86c34652455b8339a757c803337979e101a91c2f3b92e32b58d5d04ba3a3250bf30298466cdd75d29402e2044f7bc379fb271f44ec79f72be29e573dca5dc8aa3c07aa6ee3667405ee0db60d3b2a7079ada3c06e21a5a55f76fa66ca013b80b11a0319e72cc5a7d3c7ce4cc42c0de0361f427200ed669bd923d4a55c51ca3c029c01b0ce76bb0452fe8f3e2ea807b45a0e8da9ed747907915b4d661e383a7b04aed7f5f47f87099d9db5cd026660a7338774bcb485527ab01673ce15422b45459e7b6ac9da857d8285bbaa092954260e4bc03a6f6a4fe66ec2c4a7449c96b1862f4fc1577040e2bb8e4c01725596512b45f960d76f24d8e51daac72b4719f41a493747f999a77b9647f4c987a360e99e880d3faf8e58158b416f99842fe0203b126115b7a18c019de995d830f5857fd1f8f5538a8aad18382ec8c30d17745c7edb63bc707d')
        self.send_header('Content-Length', len(content))
        self.end_headers()
        self.wfile.write(content)

if __name__ == '__main__':
    host = ('0.0.0.0', 5000)
    server = HTTPServer(host, Server)
    server.serve_forever()

sub_19C750 函数中请求的 ip 地址稍作 patch,重新打包 zygisk 模块即可在游戏中点击 Login 后出现 Get Flag 按钮,点击后即可出现 flag 字符串,有效的为花括号内后 32 字符。

flag-part-2.png

前半部分 flag 的获取无论解法一或是解法二都是相同的,需要关注的是伪随机数密钥的生成,我们可以在流量中将 tcp 三次握手过程中首个包的时间作为彼时时间,即可还原密钥。

import ctypes
import datetime
from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64

request_data = bytes.fromhex('69569bb31920366e450c9dcdc668f869a65ed492c2167d18abf75f74f5ca151b1f4567ba56927b6f214ff902f...')
# Aug 28, 2022 19:03:15.073124000 UTC+8
utc8 = datetime.timezone(datetime.timedelta(hours=8))
seed = int(datetime.datetime(2022, 8, 28, 19, 3, 15, tzinfo=utc8).timestamp()) & 0xfffffff8
libc = ctypes.CDLL("libc.so.6")
libc.srand(seed)
key = bytes([libc.rand() & 0xFF for i in range(32)])

aes = AES.new(key, mode=AES.MODE_CBC, iv=b"\x00"*16)
key_enc, ciphertext, signature = request_data[0:512], request_data[512:-512], request_data[-512:]
plaintext = unpad(aes.decrypt(ciphertext), 16)
flag_part1 = sha256(plaintext.split(b"======\n")[1].strip()).hexdigest()[0:32]
print(flag_part1)

# 941d52d3ca23967191aee16dd541778d