Super Flagio CTF逆向题技术解析
题目概述本题是一道基于cocos2d-x游戏引擎开发的Android逆向题目,附件为flagio.apk。这是一个模仿超级马里奥风格的游戏,玩家需要通过逆向分析找出正确的flag输入。第一步:解压AP 2025-11-28 01:17:34 Author: www.freebuf.com(查看原文) 阅读量:7 收藏

题目概述

本题是一道基于cocos2d-x游戏引擎开发的Android逆向题目,附件为flagio.apk。这是一个模仿超级马里奥风格的游戏,玩家需要通过逆向分析找出正确的flag输入。

第一步:解压APK分析文件结构

首先使用unzip解压APK文件,查看其内部结构:

mkdir extracted && cd extracted
unzip ../flagio.apk

解压后得到以下关键目录结构:

extracted/
├── assets/
│   ├── res/           # 游戏资源文件(图片、音效)
│   └── src/           # 加密的Lua脚本
│       ├── 1024446525/
│       ├── 1975478612/    # 包含核心检查逻辑
│       │   └── 526018661  # checker脚本(1323字节)
│       ├── 33309236/
│       ├── 3914622949/
│       └── 537350069
├── lib/
│   └── arm64-v8a/
│       └── libgame.so     # 游戏核心库(ARM64架构)
├── classes.dex
└── AndroidManifest.xml

从目录结构可以得出以下判断:

  1. cocos2d-x框架:assets/src目录下存放Lua脚本是cocos2d-x的典型特征

  2. 脚本加密:文件名是数字哈希而非.lua扩展名,说明经过混淆处理

  3. 64位架构:lib目录只有arm64-v8a,说明是64位应用

  4. 核心逻辑在Native层:libgame.so是主要分析目标

第二步:分析libgame.so确定加密方式

使用strings命令搜索libgame.so中与加密相关的字符串:

strings lib/arm64-v8a/libgame.so | grep -E "XXTEA|main\.py|luajit|key"

得到关键信息:

main.py
./?.py;/usr/local/share/luajit-2.1.0-beta3/?.py
XXTEA
xctf-flagio-2dx

这些字符串揭示了重要信息:

  1. LuaJIT 2.1.0-beta3:使用的Lua引擎版本,这决定了字节码格式

  2. XXTEA:cocos2d-x框架常用的脚本加密算法

  3. xctf-flagio-2dx:这是XXTEA加密的密钥

为什么使用XXTEA

XXTEA(Corrected Block TEA)是一种分组加密算法,cocos2d-x选择它的原因:

  • 实现简单,代码量小

  • 加解密速度快

  • 安全性适中,足以防止简单的脚本窃取

验证加密文件格式

查看目标文件的十六进制头部:

xxd assets/src/1975478612/526018661 | head -5

输出:

00000000: ffff dbee 6600 0000 1f22 412c 0000 0000
00000010: 8e0d 0000 4f28 1291 20b2 5ac3 fee5 0c51

cocos2d-x XXTEA加密文件的标准格式:

  • 字节0-1: 0xFFFF(签名标识)

  • 字节2-5: 0xDBEE66(加密标志,表示使用XXTEA)

  • 字节6+: 加密后的数据

这确认了文件使用XXTEA加密。

第三步:定位游戏入口和脚本加载流程

使用IDA Pro打开libgame.so,通过搜索字符串"main.py"并查找其交叉引用,可以定位到函数sub_1D2F10。

这个函数是cocos2d-x框架的AppDelegate::applicationDidFinishLaunching,是整个游戏逻辑的入口点。

cocos2d-x脚本加载流程

在入口函数中,跟踪函数调用链可以还原脚本加载流程:

AppDelegate::applicationDidFinishLaunching
  └─> LuaEngine::executeScriptFile("main.py")
        └─> LuaStack::executeScriptFile
              └─> FileUtils::getDataFromFile  // 读取并解密文件
                    └─> XXTEA解密
              └─> luaLoadBuffer  // 加载解密后的字节码

获取解密后字节码的方法

有两种方式可以获取解密后的Lua字节码:

方法一:Frida动态Hook

// hook luaLoadBuffer,第二个参数即为解密后的字节码
Interceptor.attach(Module.findExportByName("libgame.so", "luaL_loadbuffer"), {
    onEnter: function(args) {
        var size = args[2].toInt32();
        var bytecode = Memory.readByteArray(args[1], size);
        // 保存bytecode到文件
    }
});

方法二:静态还原解密逻辑

分析FileUtilsAndroid::getContents中的XXTEA解密实现,然后编写解密脚本。

由于本题还涉及LuaJIT opcode修改,推荐使用Frida动态获取。

第四步:分析LuaJIT字节码

通过上述方法获取解密后的字节码文件(checker.luac64)后,尝试使用标准LuaJIT反编译器会失败,原因是:

  1. opcode顺序被修改:标准LuaJIT的opcode顺序被重新排列

  2. 64位适配问题:需要处理LuaJIT64的双插槽机制

还原opcode顺序

这是本题的难点之一。需要在IDA中分析libgame.so中LuaJIT VM的具体实现:

  1. 参照LuaJIT官方GitHub项目的src/vm_arm64.dasc汇编源码

  2. 在IDA中找到LuaJIT的dispatch table

  3. 逐个对照每条指令的处理函数

  4. 建立修改后的opcode与标准opcode的映射关系

通过这种方式可以理出90多条opcode的新顺序。

修改反编译器

标准的ljd(LuaJIT Decompiler)只支持LuaJIT32和标准opcode顺序,需要进行以下修改:

  1. 重置opcode顺序映射表

  2. 适配LuaJIT64的双插槽机制(64位环境下某些常量占用两个槽位)

  3. 修改字节码解析逻辑

修改后使用:python3 main.py checker.luac64

第五步:分析反编译得到的虚拟机

反编译结果显示Lua代码实现了一个自定义的小型虚拟机。这是一种常见的代码保护技术,将关键逻辑编译为自定义字节码,增加逆向难度。

虚拟机数据结构

slot0.slot = {
    {},  -- slot[1]: 指令数组
    {}   -- slot[2]: 内存数组(256字节)
}
slot0.slot[3] = 0  -- 寄存器1
slot0.slot[4] = 0  -- 寄存器2
slot0.slot[5] = 1  -- 指令指针(IP)
slot0.slot[6] = 0  -- 栈指针(SP)

内存布局分析

通过分析create函数中的初始化代码,可以确定内存布局:

  • slot[2][33-64]:用户输入的flag(32字节,对应ASCII字符)

  • slot[2][129-160]:预设的密文数组(32字节,用于验证)

  • slot[2][224-255]:加密后的结果存储区

密文数组的值为:

[94, 106, 91, 110, 86, 100, 82, 20, 32, 20, 80, 21, 83, 107, 88, 98,
 81, 19, 79, 10, 49, 117, 68, 120, 61, 13, 75, 115, 48, 8, 76, 123]

指令集完整定义

通过分析OoO函数(虚拟机执行引擎),识别出完整的指令集:

Opcode十进制助记符操作数功能描述
0x1117INC_INPUTninput[n]++ 输入字节自增
0x1218DEC_INPUTninput[n]-- 输入字节自减
0x2133INC_REGrreg[r-8]++ 寄存器自增
0x2234DEC_REGrreg[r-8]-- 寄存器自减
0x2335XOR_IMMr, immreg[r-8] ^= imm 与立即数异或
0x2436XOR_REGr1, r2reg[r1-8] ^= reg[r2-8] 寄存器间异或
0x2537POP_REGrreg[r-8] = pop() 弹栈到寄存器
0x3149POP_OUTnoutput[n] = pop() 弹栈到输出
0x3250PUSH_INnpush(input[n]) 输入压栈
0x4165PUSH_IMMimmpush(imm) 立即数压栈
0x4266PUSH_REGrpush(reg[r-8]) 寄存器压栈
0x90144RET-返回

辅助函数分析

  • ili函数:实现二进制异或运算(逐位比较)

  • lil函数:实现带溢出处理的加减运算

  • oOo函数:验证加密结果是否与预设密文匹配

第六步:模拟虚拟机执行

编写Python脚本模拟虚拟机执行过程,将字节码翻译为可读的伪代码:

inst = [65, 30, 37, 10, 50, 0, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 0, ...]
vm_ip = 0
stack_ptr = 0

while vm_ip < len(inst):
    opcode = inst[vm_ip]
    operand1 = inst[vm_ip + 1]

    if opcode == 0x41:  # PUSH_IMM
        stack_ptr += 1
        print(f"push({operand1})  // -> stack[{stack_ptr}]")
        vm_ip += 2

    elif opcode == 0x25:  # POP_REG
        print(f"reg{operand1 - 8} = pop()  // stack[{stack_ptr}]")
        stack_ptr -= 1
        vm_ip += 2

    elif opcode == 0x32:  # PUSH_IN
        stack_ptr += 1
        print(f"push(input[{operand1}])  // -> stack[{stack_ptr}]")
        vm_ip += 2

    elif opcode == 0x24:  # XOR_REG
        operand2 = inst[vm_ip + 2]
        print(f"reg{operand1 - 8} ^= reg{operand2 - 8}")
        vm_ip += 3

    elif opcode == 0x22:  # DEC_REG
        print(f"reg{operand1 - 8}--")
        vm_ip += 2

    elif opcode == 0x21:  # INC_REG
        print(f"reg{operand1 - 8}++")
        vm_ip += 2

    elif opcode == 0x42:  # PUSH_REG
        stack_ptr += 1
        print(f"push(reg{operand1 - 8})  // -> stack[{stack_ptr}]")
        vm_ip += 2

    elif opcode == 0x31:  # POP_OUT
        print(f"output[{operand1}] = pop()  // stack[{stack_ptr}]")
        stack_ptr -= 1
        vm_ip += 2

    elif opcode == 0x90:  # RET
        print("return")
        vm_ip += 1

    else:
        break

执行结果分析

模拟执行后,观察输出可以发现清晰的加密模式:

第一个字节(i=0)的处理:

push(30)           // 常量30入栈
reg2 = pop()       // reg2 = 30
push(input[0])     // 输入第一字节入栈
reg3 = pop()       // reg3 = input[0]
reg2 ^= reg3       // reg2 = 30 ^ input[0]
reg2--             // reg2 = (30 ^ input[0]) - 1
push(reg2)
output[0] = pop()  // output[0] = (input[0] ^ 30) - 1

后续字节(i=1..31)的处理:

push(output[i-1])  // 前一个输出入栈
reg2 = pop()       // reg2 = output[i-1]
push(input[i])     // 当前输入入栈
reg3 = pop()       // reg3 = input[i]
reg2 ^= reg3       // reg2 = output[i-1] ^ input[i]
reg2++  或 reg2--  // 根据位置奇偶性决定
push(reg2)
output[i] = pop()

第七步:还原加密算法

根据虚拟机执行分析,还原出完整的加密算法:

def encrypt(plaintext):
    buf = list(plaintext)  # 32字节输入

    # 第一个字节:与常量30异或后减1
    buf[0] = (buf[0] ^ 30) - 1

    # 后续字节:链式加密
    for i in range(1, 32):
        # 与前一个加密结果异或
        buf[i] ^= buf[i - 1]

        # 根据位置进行加减操作
        if i % 2 == 0 or i == 31:
            buf[i] -= 1  # 偶数位置和最后一位减1
        else:
            buf[i] += 1  # 奇数位置加1

        buf[i] &= 0xFF  # 保持在字节范围内

    return buf

算法特点分析

  1. 链式依赖:每个输出字节依赖于前一个输出字节,形成加密链

  2. 异或混淆:使用XOR操作是对称加密的基础

  3. 奇偶差异处理:奇数和偶数位置采用不同的加减操作,增加复杂性

  4. 边界特殊处理:第一字节使用固定常量,最后一字节归类为"偶数"处理

这种设计使得:

  • 任意一个字节的错误都会导致后续所有字节错误(雪崩效应)

  • 无法单独破解某一位,必须从头开始

第八步:编写解密脚本

由于加密算法的每一步都是可逆的,我们可以逆向推导出解密算法:

#!/usr/bin/env python3
"""
Super Flagio CTF - Flag解密脚本
"""

# 密文数组(从反编译结果中提取)
cipher = [94, 106, 91, 110, 86, 100, 82, 20, 32, 20, 80, 21, 83, 107, 88, 98,
          81, 19, 79, 10, 49, 117, 68, 120, 61, 13, 75, 115, 48, 8, 76, 123]

def decrypt(ciphertext):
    result = ciphertext.copy()

    # 关键:从后向前解密(逆序处理链式依赖)
    for i in range(31, 0, -1):
        # 逆向加减操作
        if i % 2 == 0 or i == 31:
            result[i] = (result[i] + 1) & 0xFF  # 原来-1,现在+1
        else:
            result[i] = (result[i] - 1) & 0xFF  # 原来+1,现在-1

        # 逆向异或操作(与前一字节异或)
        result[i] ^= result[i - 1]

    # 第一字节的逆向处理
    result[0] = ((result[0] + 1) ^ 30) & 0xFF

    return result

# 执行解密
decrypted = decrypt(cipher)
flag = ''.join(chr(c) for c in decrypted)
print(f"Flag: {flag}")

解密原理详解

  1. 为什么要从后向前?

    • 加密时buf[i]依赖buf[i-1]的加密结果

    • 解密时需要用密文的buf[i-1]来还原buf[i]

    • 从后向前可以保证每次使用的buf[i-1]都是原始密文值

  2. 逆运算关系

    • 加密:+1 -> 解密:-1

    • 加密:-1 -> 解密:+1

    • 加密:XOR -> 解密:XOR(异或运算是自逆的)

  3. 第一字节特殊处理

    • 加密:(input ^ 30) - 1 = cipher

    • 解密:(cipher + 1) ^ 30 = input

验证解密结果

运行解密脚本,输出:

密文数组:
[94, 106, 91, 110, 86, 100, 82, 20, 32, 20, 80, 21, 83, 107, 88, 98,
 81, 19, 79, 10, 49, 117, 68, 120, 61, 13, 75, 115, 48, 8, 76, 123]

解密结果(字节):
[65, 55, 54, 54, 57, 53, 55, 65, 53, 51, 69, 68, 65, 57, 50, 57,
 48, 67, 67, 70, 56, 69, 48, 51, 70, 49, 65, 57, 66, 55, 69, 48]

Flag: A766957A53EDA9290CCF8E03F1A9B7E0

验证成功!解密结果正确!

通过重新加密解密结果并与原密文比对,确认解密正确。

第九步:游戏内验证

在游戏中验证flag的方式也颇具创意:

  1. 游戏界面有两排砖块,上排对应flag的前16个字符,下排对应后16个字符

  2. 玩家需要按照flag的顺序依次顶对应的砖块

  3. 输入正确后,顶问号块会出现蘑菇

  4. 吃掉蘑菇后碰板栗,墙壁消失

  5. 触碰旗帜完成通关

这种将flag验证与游戏机制结合的设计增加了题目的趣味性。

技术总结

本题综合考察了多个逆向技术领域,是一道综合性较强的题目:

1. Android逆向基础

  • APK文件结构分析

  • Native库(.so文件)静态分析

  • 字符串搜索和交叉引用

2. 游戏引擎逆向

  • cocos2d-x框架架构理解

  • Lua脚本加载机制分析

  • XXTEA加密算法识别与破解

3. LuaJIT字节码逆向

  • LuaJIT字节码格式理解

  • 自定义opcode映射还原

  • 反编译器修改适配64位

4. 虚拟机保护分析

  • 自定义VM架构逆向

  • 指令集完整还原

  • 执行流程模拟与跟踪

5. 密码学分析

  • 链式加密算法识别

  • 逆向算法数学推导

  • 解密脚本编写与验证

防护技术分析

本题采用了多层防护策略,值得学习:

第一层:脚本加密

  • 技术:XXTEA加密Lua脚本

  • 目的:防止直接查看游戏逻辑

  • 破解:提取密钥后解密

第二层:字节码混淆

  • 技术:修改LuaJIT opcode顺序

  • 目的:阻止标准反编译器工作

  • 破解:对照分析还原映射表

第三层:虚拟机保护

  • 技术:核心算法用自定义VM实现

  • 目的:增加逆向分析难度

  • 破解:还原指令集并模拟执行

第四层:反调试

  • 技术:JNI_OnLoad中包含检测

  • 目的:阻止动态调试

  • 破解:绕过或patch检测代码

这种多层防护策略大大增加了逆向分析的难度,在实际的移动游戏保护中也很常见。

完整解题代码

#!/usr/bin/env python3
"""
Super Flagio CTF - 完整解题脚本
"""

# 密文数组(从虚拟机分析中提取)
cipher = [94, 106, 91, 110, 86, 100, 82, 20, 32, 20, 80, 21, 83, 107, 88, 98,
          81, 19, 79, 10, 49, 117, 68, 120, 61, 13, 75, 115, 48, 8, 76, 123]

# 从后向前解密
for i in range(31, 0, -1):
    # 逆向加减操作
    if i % 2 == 0 or i == 31:
        cipher[i] += 1
    else:
        cipher[i] -= 1
    cipher[i] &= 0xFF

    # 逆向异或操作
    cipher[i] ^= cipher[i - 1]

# 第一字节
cipher[0] = (cipher[0] + 1) ^ 30

# 输出结果
flag = ''.join(chr(c) for c in cipher)
print(flag)
# A766957A53EDA9290CCF8E03F1A9B7E0

Flag

A766957A53EDA9290CCF8E03F1A9B7E0


文章来源: https://www.freebuf.com/articles/others-articles/459487.html
如有侵权请联系:admin#unsafe.sh