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

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

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文件


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

标准的 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):
-
降低目标层数:将
target_floor 修改为 2,这样我们在第 2 层就能通关,大大缩短游戏流程。 -
修正密钥种子:为了抵消层数的变化,我们修改
key_seed。-
原式:
18 + 100 = 118 -
新式:
X + 2 = 118 =>X = 116 -
我们将 game
文件中的常量
18 修改为 116。
-
-
削弱 Boss:为了防止卡关,我们在字节码中定位到
FLOOR GUARDIAN 附近,将其 HP200 修改为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()
自动化执行了以下步骤:
-
读取 game
文件并进行异或解密。
-
搜索整数常量
18 (0x12) 并替换为116(0x74)。 -
搜索整数常量
100 (0x64) 并替换为2(0x02)。 -
搜索 Boss 属性
200 (0xC8) 替换为1。 -
重新进行异或加密并保存覆盖。
这次再次运行游戏的时候,我们拿到了正确的flag
