XCTF-Final Flappy-Bird-Cheat题目复现
一、引言
这是一道有关Magisk模块的题目,虽然一直在用Magisk,但是对其模块作弊机制还不是很了解,之前比赛的时候没做出来(之前没恢复OpenSSL
的符号,看起来很难看放弃了),有时间翻出来再看看。难点在于这道题目是采取静态分析的手段看的,暂时没找到什么办法对模块内的so文件进行动调和Hook,之后再研究研究(在内存中找到加载so的基址后就可以使用frida进行Hook)。
二、解题
-
将APP和题目给出的
zygisk模块
安装后打开APP,发现左上角出现一个作弊窗口,但是需要进行校验,这里它读取/data/data/re.ctf.flappybird/file/key.txt
进行校验,如果通过才会打开作弊功能。题目中给出了另一个流量包,这个流量包有一个对/auth
接口的访问流量。
-
一般情况下对模块内的so进行分析入口点在
zygisk_module_entry
,这里off_4B3C08
指向几个地址,其中有进行包名匹配的,也有进行Hook的,重点对Hook部分进行分析。
-
Hook部分创建一个线程,内使用
DobbyHook
将两so中的两个方法Hook到sub_19D0D4
和sub_19DF40
,第二个函数内部大致是一些按键或者图像相关API,而非作弊器校验,因此主要分析19D0D4
位置的函数。
-
19D0D4位置的函数则能够看到在Auth处进行了校验,此处的符号是需要自己猜测恢复的,则主要分析
0x19D5C4
位置的函数。
-
分析发现逻辑大概就是打开
/data/data/re.ctf.flappybird/file/key.txt
读取内容并且计算sha1,拼接为token-sha1(key)
,附加在流量中Http头部,这里的sha1也是通过恢复符号才能分析,再后面又是很多加密操作
-
重点是符号的恢复,否则后面很难看,这里可以提取手机中的libcrypto.so或者编译一份arm64架构的共享库,这里直接编译,因为编译出来的符号全一点
首先在github拉一份OpenSSL的源码,题目中so字符串中有指示版本为3.0.5
https://github.com/openssl/openssl
checkout出3.0.5版本
git checkout openssl-3.0.5
然后配置NDK,先从google下一份NDK,这里用的是r25c,配好环境变量
export ANDROID_NDK_ROOT=/root/android-ndk-r25c
export 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
然后编译就行
./Configure android-arm64 -D__ANDROID_API__=21 shared no-tests
make -j4
-
编译完成后得到libcrypto.so,直接拖入IDA,等IDA处理完成后关闭IDA并pack database。使用bindiff的IDA插件,导入libcrypto.so.i64进行比对,完成后再打开插件选择
Import symbol and comments
,然后相似度和置信度最小都设置为0.7
即可。
-
恢复后的分析会好很多,后面先是获取了DEVICE_NAME、cmdline、tcp、tcp6 等信息然后作为内容进行加密,加密完成后通过/auth接口发请求
-
重点分析encypto,主要加密经过几个阶段
-
rand 随机生成一个AES密钥
-
用AES密钥加密数据
-
用RSA公钥A加密AES密钥
-
拿到sha256(session_key_enc + data_enc)
-
用私钥B签名数据
先是通过
v4 = time(0LL); srand(v4 & 0xFFFFFFF8);
设置种子得到32字节随机数,这32个随机数是作为AES-CBC加密的密钥,
具体是因为在生成随机数后传入0019BBBC位置的函数,该函数是对传入的数据(即包括文件内容和其他信息的内容)进行AES-CBC加密,EVP_EncryptInit声明是这个
__owur int EVP_EncryptInit(EVP_CIPHER_CTX *ctx, const EVP_CIPHER *cipher,const unsigned char *key, const unsigned char *iv);
第二个参数包含了一些加密算法的信息,这里的第二个参数指向一个函数sub_328478()
,函数则指向一个全局变量
这里导入EVP_CIPHER结构体的部分内容,对该内存进行分析,发现算法就是AES-CBC,也即对32字节的随机数进行了AES加密
struct evp_cipher_st {
int nid;
int block_size;
/* Default value for variable length ciphers */
int key_len;
int iv_len;
/* Legacy structure members */
/* Various flags */
unsigned long flags;
/* How the EVP_CIPHER was created. */
int origin;
/* init key */
int *init;
/* encrypt/decrypt data */
int (*do_cipher);
/* cleanup ctx */
int (*cleanup);
/* how big ctx->cipher_data needs to be */
int ctx_size;
/* Populate a ASN1_TYPE with parameters */
int (*set_asn1_parameters);
/* Get parameters from a ASN1_TYPE */
int (*get_asn1_parameters);
/* Miscellaneous operations */
int (*ctrl);
/* Application data */
void *app_data;
/* New structure members */
/* Above comment to be removed when legacy has gone */
int name_id;
char *type_name;
const char *description;
} /* EVP_CIPHER */ ;
然后使用公钥加密随机的32字节数据,然后对结果进行了拼接
然后获取sha256摘要,这里的摘要是计算
sha256(公钥加密AES_KEY的密文 + AES加密主体内容的密文)
最后使用私钥进行签名,并再次进行拼接,最终的结构就是SessionKey_enc + data_enc + sign
-
将数据发送出去后收到响应数据,对于响应数据先是用公钥验证签名,然后用AES解密数据,但是这里的AES的密钥不是之前的随机数,而是写死的一个密钥位于
byte_515220
,
然后就是sub_19CDA4
内有一个看起来比较复杂的解密运算,所以参考https://arcovegle.github.io/2023/04/05/XCTF%20Final%20flappy-bird-cheat/
给出的方案,伪造一个服务器,直接Patch模块让它自己解包。
-
到此并未结束,因为在Get FLag按钮被触发后会开始构造flag,在内部先是拿到
sha256(DEVICE_NAME)
的前16 字节的 hex,然后是服务端返回内容进行解密并且反序列化后sha256的后16字节的 hex,因此需要手动解密第一部分的DEVICE_NAME,这里用官方给出的脚本
import datetime
import ctypes
import struct
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from hashlib import sha256
raw = open("./a.bin", "rb").read() # dump http stream
post_data = raw[raw.index(b"Content-Length: 5696\r\n\r\n")+len(b"Content-Length: 5696\r\n\r\n"):raw.index(b"HTTP/1.1 200 OK\r\nServer: Werkzeug")]
rsp_data = raw[raw.index(b"Connection: close\r\n\r\n") + len(b"Connection: close\r\n\r\n"):]
assert len(post_data) == 5696
assert len(rsp_data) == 672
"28 Aug 2022 18:18:54"
ts = int(datetime.datetime(2022, 8, 28, 19, 3, 14).timestamp()) & 0xFFFFFFF8
libc = ctypes.CDLL("libc.so.6")
libc.srand(ts)
session_key = bytes([libc.rand() & 0xFF for i in range(32)])
print(session_key)
aes = AES.new(session_key, mode=AES.MODE_CBC, iv=b"\x00"*16)
session_key_enc, ciphertext, signature = post_data[0:512], post_data[512:-512], post_data[-512:]
s1 = unpad(aes.decrypt(ciphertext), 16)
print(s1.split(b"======\n")[1].strip())
s1 = sha256(s1.split(b"======\n")[1].strip()).hexdigest()[0:32]
print(s1)
# 941d52d3ca23967191aee16dd541778d
- 最终Flag就是
flag{941d52d3ca23967191aee16dd541778da4f1a8ff674264e8c1e4e404afc1ccdd}
三、总结
总的来说这题还是很难做的。
主要问题在于单靠静态看很难看,存在大量字符串结构体和一个大的作弊结构体,还是想找个办法动调或者Hook一下。
再者就是符号恢复,不进行符号恢复看破脑袋都解不出来。
然后就是尽管恢复完符号,但是还是存在一些序列化和加密的内容,arm64架构编出来又很难看。
四、Frida Hook作弊模块的so
有时间研究了一下Magisk和zygisk注入,这块稍微熟悉了一点后回头看这道题,能够成功对模块的so进行Hook操作。
- 在Magisk源码中如果开启了zygisk,那么在模块挂载阶段会将通过
__NR_memfd_create
打开对应模块的so,然后为每一个模块的so,创建匿名内存文件jit-cache并返回描述符进行保存,在后续应用启动时通过android_dlopen_ext
打开描述符指向的内存,然后找到入口执行。
// native/src/core/module.cpp
static void collect_modules(bool open_zygisk) {
foreach_module([=](int dfd, dirent *entry, int modfd) {
if (faccessat(modfd, "remove", F_OK, 0) == 0) {
LOGI("%s: remove\n", entry->d_name);
auto uninstaller = MODULEROOT + "/"s + entry->d_name + "/uninstall.sh";
if (access(uninstaller.data(), F_OK) == 0)
exec_script(uninstaller.data());
frm_rf(xdup(modfd));
unlinkat(dfd, entry->d_name, AT_REMOVEDIR);
return;
}
unlinkat(modfd, "update", 0);
if (faccessat(modfd, "disable", F_OK, 0) == 0)
return;
module_info info;
if (zygisk_enabled) {
// Riru and its modules are not compatible with zygisk
if (entry->d_name == "riru-core"sv || faccessat(modfd, "riru", F_OK, 0) == 0) {
LOGI("%s: ignore\n", entry->d_name);
return;
}
if (open_zygisk) {
#if defined(__arm__)
info.z32 = openat(modfd, "zygisk/armeabi-v7a.so", O_RDONLY | O_CLOEXEC);
#elif defined(__aarch64__)
info.z32 = openat(modfd, "zygisk/armeabi-v7a.so", O_RDONLY | O_CLOEXEC);
info.z64 = openat(modfd, "zygisk/arm64-v8a.so", O_RDONLY | O_CLOEXEC);
#elif defined(__i386__)
info.z32 = openat(modfd, "zygisk/x86.so", O_RDONLY | O_CLOEXEC);
#elif defined(__x86_64__)
info.z32 = openat(modfd, "zygisk/x86.so", O_RDONLY | O_CLOEXEC);
info.z64 = openat(modfd, "zygisk/x86_64.so", O_RDONLY | O_CLOEXEC);
#else
#error Unsupported ABI
#endif
unlinkat(modfd, "zygisk/unloaded", 0);
}
} else {
// Ignore zygisk modules when zygisk is not enabled
if (faccessat(modfd, "zygisk", F_OK, 0) == 0) {
LOGI("%s: ignore\n", entry->d_name);
return;
}
}
info.name = entry->d_name;
module_list->push_back(info);
});
if (zygisk_enabled) {
bool use_memfd = true;
auto convert_to_memfd = [&](int fd) -> int {
if (fd < 0)
return -1;
if (use_memfd) {
int memfd = syscall(__NR_memfd_create, "jit-cache", MFD_CLOEXEC);
if (memfd >= 0) {
xsendfile(memfd, fd, nullptr, INT_MAX);
close(fd);
return memfd;
} else {
// memfd_create failed, just use what we had
use_memfd = false;
}
}
return fd;
};
std::for_each(module_list->begin(), module_list->end(), [&](module_info &info) {
info.z32 = convert_to_memfd(info.z32);
#if defined(__LP64__)
info.z64 = convert_to_memfd(info.z64);
#endif
});
}
}
// native/src/core/hook.cpp
void HookContext::run_modules_pre(const vector<int> &fds) {
for (int i = 0; i < fds.size(); ++i) {
struct stat s{};
if (fstat(fds[i], &s) != 0 || !S_ISREG(s.st_mode)) {
close(fds[i]);
continue;
}
android_dlextinfo info {
.flags = ANDROID_DLEXT_USE_LIBRARY_FD,
.library_fd = fds[i],
};
if (void *h = android_dlopen_ext("/jit-cache", RTLD_LAZY, &info)) {
if (void *e = dlsym(h, "zygisk_module_entry")) {
modules.emplace_back(i, h, e);
}
} else if (g_ctx->flags[SERVER_FORK_AND_SPECIALIZE]) {
ZLOGW("Failed to dlopen zygisk module: %s\n", dlerror());
}
close(fds[i]);
}
// ......
}
-
所以就可以在模块作用的APP内存区域找到匿名的内存空间,其中
jit-cache
只有部分是我们的目标,直接找最低基址的即可
-
也可以使用frida定位
// find memfd:jit-cache Modules
var modules = Process.enumerateModules();
var myModule = null;
for (var i = 0; i < modules.length; i++) {
// console.log(modules[i].name);
if (modules[i].name == 'memfd:jit-cache (deleted)') {
// 如果找到了名称为'my_module'的模块,检查它的基地址
if (myModule == null || (modules[i].base < myModule.base)) {
// 如果之前没有找到过'my_module',或者当前模块的基地址更小,将当前模块设置为myModule
myModule = modules[i];
}
}
}
console.log(myModule.base);
var base = myModule.base;
- 使用Frida hook EVP_EncryptUpdate可以看到这里的参数,在args[3]为输入的数据,即通过fmt构造的一些设备信息,但是这个模块没有读到
/proc/self/tcp
的信息,所以长度只有0x47,验证了Frida Hook的可行性
// EVP_EncryptUpdate 0x32C8E8
Interceptor.attach(ptr(base).add(0x32C8E8), {
onEnter: function (args) {
console.log("---------------------------------")
console.log("[+]EVP_EncryptUpdate : ")
console.log('args[0](ctx) ' + args[0]);
console.log('args[1](out) ' + args[1]);
// console.log(hexdump(args[1]));
console.log('args[2](outlen) ' + args[2]);
console.log('args[3](data) ' + args[3]);
console.log(hexdump(args[3]));
console.log('args[4](datalen) ' + args[4]);
},
onLeave: function(){
}
});
- 也可以直接patch http的请求ip,就不需要打patch再重新安装了
// fix http address
// http://192.168.106:5000->http://192.168.0.105:5000
var http_addr = (ptr(base).add(0xA41a3))
console.log(hexdump(http_addr));
Memory.protect(http_addr, 1, 'rw-')
http_addr.writeByteArray([0x35])
console.log(hexdump(http_addr));
五、参考
https://arcovegle.github.io/2023/04/05/XCTF Final flappy-bird-cheat/
https://mp.weixin.qq.com/s?__biz=MzI2MDE4MzkzMQ==&mid=2247484521&idx=1&sn=8f5119a0cd930ce5355a6b0c9446e182&chksm=ea6cc67ddd1b4f6bdb0651408a5d03c539c65d84050519c9eb35308833357bb8c55e8c11cfb5#rd