HGAME-2026-WEEK2-REVERSE

60次阅读
没有评论

WEEK2-REVERSE

Androuge

最原始下载到一个apk附件,使用jadx解包打开

HGAME-2026-WEEK2-REVERSE

在MainActivity中找到关键点游戏启动参数,这些附件都在assest目录中,将其提取
HGAME-2026-WEEK2-REVERSE

capoo@DESKTOP-4JQA10R:/mnt/c/Users/15589/Desktop/Androuge$ file waw
waw: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, with debug_info, not stripped
capoo@DESKTOP-4JQA10R:/mnt/c/Users/15589/Desktop/Androuge$ file game
game: data

这个waw是个arm64的elf文件,所以需要在wsl上运行的话需要安装arm64内核

直接使用IDA打开这个waw文件

HGAME-2026-WEEK2-REVERSE

HGAME-2026-WEEK2-REVERSE

这似乎是一个会加载luac文件的脚本,那么通常来说只需要对luac反编译进行修改就能修改游戏,我们要么把加密之前的flag找出来解密,要么修改通关条件,让我们能够简单的拿到flag。

在这里用010打开game文件,文件的十六进制头:

HGAME-2026-WEEK2-REVERSE

标准的 Lua 字节码文件头通常是 1B 4C 75 61​ (Esc L u a)。这里的 CB FD EB 看起来不仅被修改过,而且可能是加密的。

分析waw中的文件加载函数(分析 load​ 或 undump 相关逻辑,我们发现了一个简单的异或(XOR)加密模式:

  // ... 读取数据 ...
  if ( luaZ_read(a2) ) // 读取文件头剩余部分 (除去第一个字节 0x1B)
    goto LABEL_33;
    
  // 这里的 v19 和 v20 存储了读取的头文件字节
  // 0x9C 就是那个异或密钥
  v19 ^= 0x9C9Cu;  // 同时异或两个字节 (解密 'W' 和 'a')
  v20 ^= 0x9Cu;    // 异或第三个字节 (解密 'w')

  // 24919 = 0x6157 (小端序 'Wa')
  // 119   = 0x77   ('w')
  // 这里验证解密后的头是否为 "Waw"
  if ( v19 != 24919 || v20 != 119 )
    error(&v16, "not a binary chunk");
  • 第 1 字节 (0x1B) 保持不变。
  • 从第 2 字节开始​,每个字节都异或了 0x9C

验证解密:

  • 原文:CB​ ^ 0x9C​ = 0x57 (‘W’)
  • 原文:FD​ ^ 0x9C​ = 0x61 (‘a’)
  • 原文:EB​ ^ 0x9C​ = 0x77​ (‘w’)
    解密后的头为 \0x1bWaw。这证实了这是魔改了魔数(Magic Number)的 Lua 5.4 字节码文件。

编写脚本 把这个game文件揭秘出来 变成luac

import os

def recover():
    input_path = 'game'
    output_path = 'game_recover.luac'
    
    if not os.path.exists(input_path):
        print(f"Error: '{input_path}' not found.")
        return

    with open(input_path, 'rb') as f:
        data = bytearray(f.read())

    print(f"Read {len(data)} bytes from {input_path}")
    
    # Transformation found:
    # Byte 0 is kept as is (0x1B)
    # Byte 1..N are XORed with 0x9C
    
    decoded = bytearray()
    if len(data) > 0:
        decoded.append(data[0]) 
    
    for i in range(1, len(data)):
        decoded.append(data[i] ^ 0x9C)

    # Check header
    header = decoded[:4]
    print(f"Decoded header: {header.hex()}")
    
    # Expected: 1B 57 61 77 (Esc W a w)
    if header == b'\x1bWaw':
        print("Found custom 'Waw' header. Patching to 'Lua'...")
        decoded[1] = 0x4C # L
        decoded[2] = 0x75 # u
        decoded[3] = 0x61 # a
    else:
        print("Warning: Header did not match expected 'Waw'. Verify evidence.")

    with open(output_path, 'wb') as f:
        f.write(decoded)
    
    print(f"Recovered file saved to '{output_path}'")

if __name__ == '__main__':
    recover()

将其解密并临时作为 game_recover.luac 进行分析。

虽然我们无法完美反编译成 Lua 源码(因为自定义魔数),但可以通过分析字节码结构(或修正头部为 \0x1bLua 后尝试通用反编译工具)提取关键信息。

我们编写如下脚本:

import struct

def read_unsigned_int(f, size=4):
    b = f.read(size)
    if not b: return None
    return int.from_bytes(b, 'little')

def read_string(f, size_t=8):
    # Lua 5.4 Strings
    # size: 1 byte if < 0xFD?, else ... logic
    # Actually Lua 5.4 uses a variable length encoding for sizes sometimes, 
    # but in structure dump it might be 8 bytes size_t if not stripped?
    # Standard format:
    # 1 byte for short size?
    # Let's try to read 1 byte size.
    val = f.read(1)
    if not val: return None
    size = val[0]
    if size == 0:
        return b""
    if size == 0xFF:
        # Long string, read size_t
        size = read_unsigned_int(f, 8) # Assuming size_t is 8
    else:
        # Short string, size is actual size including null?
        size = size - 1
    
    return f.read(size)

def parse_header(f):
    header = f.read(4) # 1B 4C 75 61
    ver = f.read(1)
    fmt = f.read(1)
    data = f.read(6) # 19 93 0D 0A 1A 0A
    sizes = f.read(5) # sizeof(int), sizeof(size_t), sizeof(Inst), sizeof(Int), sizeof(Num)
    endian = f.read(8) # LUAC_INT
    float_test = f.read(8) # LUAC_NUM
    return True

def parse_constants(f):
    # Count
    b = f.read(1)
    if not b: return []
    # Lua 5.4 uses compact integer for size?
    # It depends on `dump` implementation.
    # Usually `luaU_dump` calls `dumpInt`.
    # `dumpInt` writes a variable length integer.
    # If < 127, 1 byte.
    
    def load_int(f):
        # Simplified parser for simple packed int (if < 128)
        # This is a HACK. Real logic is more complex.
        # But standard `luac` usually outputs regular binary chunks.
        # Wait, Lua 5.4 DOES use variable length integers for list counts.
        res = 0
        shift = 0
        while True:
            b = ord(f.read(1))
            res |= (b & 0x7F) << shift
            if not (b & 0x80):
                break
            shift += 7
        return res
        
    n = load_int(f)
    print(f"Constants count: {n}")
    
    constants = []
    for i in range(n):
        tag = ord(f.read(1))
        # Tag: 0=Nil, 1=Bool, 3=Int, 4=Float, 19=Int?, 20=String? 
        # Lua 5.4 Tags:
        # LUA_VNIL 0, LUA_VFALSE 1, LUA_VTRUE 1 | 16??
        # LUA_VNUMINT 3, LUA_VNUMFLT 19 (0x13)
        # LUA_VSHRSTR 4, LUA_VLNGSTR 20 (0x14)
        
        val = None
        if tag == 0: # Nil
            val = "Nil"
        elif tag == 1: # False
            val = False
        elif tag == 17: # True (Tag 1 | Variant bit?) - Standard is boolean tag is 1, value encoded?
            # Actually Lua 5.4 constants tags:
            # TNIL 0
            # TBOOLEAN 1. FALSE=0, TRUE=1? No, 5.4 might differ.
            # Let's assume standard byte dump:
            # boolean is usually 1 byte value.
            # But let's check standard source.
            pass
        elif tag == 3: # Int (VNUMINT)
            val = int.from_bytes(f.read(8), 'little', signed=True)
            constants.append(f"Int: {val}")
        elif tag == 0x13: # Float (VNUMFLT)
            val = struct.unpack('<d', f.read(8))[0]
            constants.append(f"Flt: {val}")
        elif tag == 4 or tag == 0x14: # String (Short or Long)
            s = read_string(f) # Need robust string reader
            try:
                constants.append(f"Str: {s.decode('utf-8', 'ignore')}")
            except:
                constants.append(f"Str(raw): {s}")
        else:
            constants.append(f"Unknown Tag {tag}")
            
    return constants

# This is a very rough parser just to find the integers and strings in valid format
# Since we stripped the game file, we can just linear scan for patterns if this fails.

def linear_scan():
    f = open('game_recover.luac', 'rb')
    content = f.read()
    
    # Scan for Integers (Tag 03 + 8 bytes)
    import re
    
    # We are looking for key_seed. It's likely a big integer.
    # And target_floor 100.
    
    ints = []
    i = 0
    while i < len(content) - 9:
        if content[i] == 3: # Int tag
            val = int.from_bytes(content[i+1:i+9], 'little', signed=True)
            # Filter reasonable values.
            if abs(val) < 2**63: # Valid int64
                if val > 1000 or val == 100:
                    ints.append((i, val))
        i += 1
        
    print(f"Found {len(ints)} integer candidates.")
    for off, val in ints:
        print(f"Offset {hex(off)}: {val}")
        
    # Scan for Long Strings (Tag 04/14 + Length)
    # enc_flag is likely long.
    # Look for the 'Flag:' string usage context.
    
linear_scan()

等脚本扫描常量表,我们发现了关键变量和常量:

  • 字符串: Goal: Reach Floor 100.​, key_seed​, target_floor​, enc_flag
  • 整数常量: 100​ (目标层数), 18​ (Key Seed), 200 (Boss HP).

之前我们尝试,直接修改目标层数,比如把100改成1,但是这样的flag是错误的。

游戏中有如下逻辑(伪代码):

正确密钥 = 18​ (Seed) + 100​ (Target) = 118

这解释了为什么直接修改层数会导致 Flag 乱码。Flag 的解密密钥依赖于当前的层数变量

因此,不仅仅只是修改层数这么简单,我们还需要打上正确keyseed的补丁

为了拿到正确的 Flag,我们必须保证 Seed + Floor = 118,同时又不想真的打 100 层。
于是我们制定了以下补丁方案(Patch):

  1. 降低目标层数​:将 target_floor​ 修改为 ​2,这样我们在第 2 层就能通关,大大缩短游戏流程。

  2. 修正密钥种子​:为了抵消层数的变化,我们修改 key_seed

    • 原式:18 + 100 = 118

    • 新式:X + 2 = 118​ => X = 116

    • 我们将 game

      文件中的常量 18​ 修改为 ​116

  3. 削弱 Boss​:为了防止卡关,我们在字节码中定位到 FLOOR GUARDIAN​ 附近,将其 HP 200​ 修改为 1​,攻击力 10​ 修改为 0

编写脚本:

import os

def patch_final():
    input_path = 'game'
    if not os.path.exists(input_path):
        return

    # 1. Read & Decrypt
    with open(input_path, 'rb') as f:
        data = bytearray(f.read())
    
    decrypted = bytearray()
    decrypted.append(data[0])
    for i in range(1, len(data)):
        decrypted.append(data[i] ^ 0x9C)

    # 2. Patch Int(18) -> Int(116)
    # 18 = 0x12 -> 116 = 0x74
    pattern_18 = b'\x03\x12\x00\x00\x00\x00\x00\x00\x00'
    pattern_116 = b'\x03\x74\x00\x00\x00\x00\x00\x00\x00'
    
    count_18 = decrypted.count(pattern_18)
    print(f"Found {count_18} of Int(18). Patching to 116.")
    new_decrypted = decrypted.replace(pattern_18, pattern_116)

    # 3. Patch Int(100) -> Int(2)
    # 100 = 0x64 -> 2 = 0x02
    pattern_100 = b'\x03\x64\x00\x00\x00\x00\x00\x00\x00'
    pattern_2 = b'\x03\x02\x00\x00\x00\x00\x00\x00\x00'
    
    count_100 = new_decrypted.count(pattern_100)
    print(f"Found {count_100} of Int(100). Patching to 2.")
    new_decrypted = new_decrypted.replace(pattern_100, pattern_2)
    
    # 4. Boss Nerf (Int 200 -> Int 1, Int 10 -> Int 0)
    # Ensure Boss is weak if HP is low.
    pattern_boss_hp = b'\x03\xc8\x00\x00\x00\x00\x00\x00\x00' # 200
    pattern_hp_1 = b'\x03\x01\x00\x00\x00\x00\x00\x00\x00'
    
    pattern_boss_atk = b'\x03\x0a\x00\x00\x00\x00\x00\x00\x00' # 10
    pattern_atk_0 = b'\x03\x00\x00\x00\x00\x00\x00\x00\x00'
    
    # Context-aware patch for boss is safer, but replace all 200 is risky?
    # Boss HP 200 is likely unique.
    new_decrypted = new_decrypted.replace(pattern_boss_hp, pattern_hp_1)
    
    # 5. Encrypt
    encrypted = bytearray()
    encrypted.append(new_decrypted[0])
    for i in range(1, len(new_decrypted)):
        encrypted.append(new_decrypted[i] ^ 0x9C)

    with open(input_path, 'wb') as f:
        f.write(encrypted)
        
    print("Patch applied: KeySeed+98, Target=2, BossNerfed.")

if __name__ == '__main__':
    patch_final()

自动化执行了以下步骤:

  1. 读取 game

    文件并进行异或解密。

  2. 搜索整数常量 18​ (0x12) 并替换为 116 (0x74)。

  3. 搜索整数常量 100​ (0x64) 并替换为 2 (0x02)。

  4. 搜索 Boss 属性 200​ (0xC8) 替换为 1

  5. 重新进行异或加密并保存覆盖。

这次再次运行游戏的时候,我们拿到了正确的flag

HGAME-2026-WEEK2-REVERSE

正文完
 0
评论(没有评论)