SUSCTF2024-Redroid-出题笔记与解题思路
- Step1
- Step2
- Step3
- Step4
描述:题目要求使用安卓13/14系统真机
Step1
Java层的逻辑比较简单,两个Activity
MainActivity读并验证password,正确即进入CheckActivity,同时会传递password
password先后传入了util.init和util.calc,计算得到hash与已知的secret比较
Util实际上是HMAC-SHA1的标准哈希过程,只不过这里key和data同时为输入的password
password长度为6,并且均为数字,于是可以爆破得到,为122608
import hashlib
import hmac
for i in range(1000000):
s = str(i).encode()
h = hmac.new(s, s, hashlib.sha1).hexdigest()
if h == "59e7027803adaa7bcad8e40770c3c50f288ca5ac":
print(i)
break
# 122608
如果没有认出来HMAC-SHA1算法,也可以直接扣Java代码爆破,Util没有用到android类库
Step2
输入password进入CheckActivity
开头两个字符串,两个Native方法,加载libcheck.so
onCreate通过intent读传入的password,然后和liblabel以及getAssets()一起传入init方法
只有init方法返回true,程序流才会继续向下去做flag的校验,因此要求password必须正确
init方法的第一个参数是个AssetManager,和资源相关,在assets目录下确实存在很多jpg,还不知道怎么处理
按钮onClick只是读flag并校验长度,然后传入check方法,返回true说明flag正确
关键就是init和check两个native方法,因此开始分析libcheck.so
没有jni_onload,搜java只找到init方法,没有check方法
这是因为check方法根本不在libcheck.so
libcheck.so实际上是一个壳so,init方法是在做解密以及模仿linker加载so
而通过libcheck.so加载的新so(librealcheck.so)才真正包含了check方法校验flag的逻辑
这也就是为什么Java层要判断init方法的返回值:因为加载librealcheck.so不成功的话就根本找不到check方法
且为什么要求使用安卓13/14系统真机:因为加载新so最后需要修复soinfo,soinfo结构成员在不同版本下偏移不同
于是这题要求对linker加载so的过程比较熟悉
参考:
[1]自定义Linker实现分析之路(一)
[2]基于linker实现so加壳技术基础上篇
[3]基于linker实现so加壳技术基础下篇
[4]自定义linker加固so
[5]获取soinfo+
配合源码食用更佳:
[6]AOSPXRef
回到题目,先看看源码正向的加密过程
编译出librealcheck.so后重命名为nevercheck114.jpg,放到assets目录下
为什么要改成jpg后缀:
一方面是隐藏在其他jpg里,顺便玩梗
另一方面是安卓认为jpg是已经压缩好的,它不会再去压缩,如果还是so后缀,它会压缩导致找不到资源文件
这里的key还是前面的password,作为加密密钥
// 加密:librealcheck.so => nevercheck114.jpg => nevercheck114.jpg.packed => nevercheck114.jpg
AAssetManager * aAssetManager = AAssetManager_fromJava(env, assetmanager);
AAsset * aAsset = AAssetManager_open(aAssetManager, "nevercheck114.jpg", AASSET_MODE_UNKNOWN);
off64_t start = 0, length = 0;
int fd = AAsset_openFileDescriptor64(aAsset, &start, &length);
lseek(fd, start, SEEK_CUR);
AAsset_close(aAsset);
if(fd!=-1){
const char * key = env->GetStringUTFChars(libkey,0);
shell ashell;
if(ashell.pack(fd,length,key)){
return JNI_TRUE;
}
}
ashell.pack就是对so文件的变形以及加密,参考[4]
先定义一个CosELFHeader结构体,存储原始ELF头的偏移和大小,原始程序头表的偏移和大小
然后对原始ELF头和原始程序头表做RC4加密,顺便拷贝到加密so的末尾
对PT_LOAD的段做RC4加密,偏移不变,拷贝到加密so的对应位置
把自定义的CosELFHeader放到加密so开头,用来找原始ELF头和原始程序头表
最后填充随机字节到非PT_LOAD的其他部分,这对解密加载没有影响,只是不想让加密so看起来太奇怪(大片的0)
bool shell::pack(int libfd, size_t liblength, const char *libkey) {
if(libkey!= nullptr){
size_t libkeysize = strlen(libkey);
size_t libsize = liblength;
char * libdata = reinterpret_cast<char *>(malloc(libsize));
if(libdata!= nullptr) {
memset(libdata, 0, libsize);
if (read(libfd, libdata, libsize) == libsize) {
close(libfd);
Elf64_Ehdr * oriELFHeader = reinterpret_cast<Elf64_Ehdr *>(libdata);
CosELFHeader cosElfHeader;
cosElfHeader.oriELFHeaderOff = libsize + oriELFHeader->e_phnum * oriELFHeader->e_phentsize;
cosElfHeader.oriELFHeaderSize = oriELFHeader->e_ehsize;
cosElfHeader.oriELFPHTOff = libsize;
cosElfHeader.oriELFPHTNum = oriELFHeader->e_phnum;
cosElfHeader.oriELFPHTSize = oriELFHeader->e_phnum * oriELFHeader->e_phentsize;
size_t libpacksize = libsize + cosElfHeader.oriELFPHTSize + cosElfHeader.oriELFHeaderSize;
char * libpackdata = reinterpret_cast<char *>(calloc(libpacksize,1));
if(libpackdata!= nullptr){
rc4(libpackdata+cosElfHeader.oriELFHeaderOff,libdata,cosElfHeader.oriELFHeaderSize,libkey,libkeysize);
rc4(libpackdata+cosElfHeader.oriELFPHTOff,libdata+oriELFHeader->e_phoff,cosElfHeader.oriELFPHTSize,libkey,libkeysize);
Elf64_Phdr * phdr = reinterpret_cast<Elf64_Phdr *>(libdata + oriELFHeader->e_phoff);
size_t phdrloadsize = 0;
for(size_t i = 0; i < oriELFHeader->e_phnum; i++){
if(phdr->p_type == PT_LOAD){
rc4(libpackdata+phdr->p_offset,libdata+phdr->p_offset,phdr->p_filesz,libkey,libkeysize);
phdrloadsize = phdrloadsize < phdr->p_offset + phdr->p_filesz ? phdr->p_offset + phdr->p_filesz : phdrloadsize;
}
phdr = reinterpret_cast<Elf64_Phdr *>((char*)phdr + sizeof(Elf64_Phdr));
}
memset(libpackdata,0, cosElfHeader.oriELFHeaderSize);
memcpy(libpackdata,&cosElfHeader,sizeof(Elf64_Ehdr));
srand(atoi(libkey));
for(char * ptr = libpackdata+phdrloadsize;ptr<libpackdata+cosElfHeader.oriELFPHTOff;ptr++){
*ptr = (char)rand()%0x100;
}
FILE * libpackfile = fopen("/data/user/0/com.susctf.redroid/cache/nevercheck114.jpg.packed","w");
if(libpackfile!= nullptr) {
fwrite(libpackdata, libpacksize, 1, libpackfile);
fclose(libpackfile);
return true;
}
}
}
}
}
return false;
}
然后看源码的解密过程,也就是init方法
这里的nevercheck114.jpg就是经过变形和加密的librealcheck.so
key仍然是password,label是固定的"check",也就是壳so的名称,后面找壳so的soinfo时会用到
AAssetManager * aAssetManager = AAssetManager_fromJava(env, assetmanager);
AAsset * aAsset = AAssetManager_open(aAssetManager, "nevercheck114.jpg", AASSET_MODE_UNKNOWN);
off64_t start = 0, length = 0;
int fd = AAsset_openFileDescriptor64(aAsset, &start, &length);
lseek(fd, start, SEEK_CUR);
AAsset_close(aAsset);
if(fd!=-1){
const char * label = env->GetStringUTFChars(liblabel,0);
const char * key = env->GetStringUTFChars(libkey,0);
shell ashell;
if(ashell.unpack(fd,length,label,key)){
return JNI_TRUE;
}
}
ashell.unpack是对加密so的解密和加载
由加密so开头数据定义并填充CosELFHeader
然后通过CosELFHeader找原始ELF头和原始程序头表并解密,这里解密后没有将它们恢复到原始位置
程序头表解密后即可对PT_LOAD的段原地解密
bool shell::unpack(int libfd, size_t liblength, const char *liblabel, const char *libkey) {
if(libkey!= nullptr){
size_t libkeysize = strlen(libkey);
size_t libpacksize = liblength;
char * libpackdata = reinterpret_cast<char *>(malloc(libpacksize));
if(libpackdata!= nullptr) {
memset(libpackdata, 0, libpacksize);
if (read(libfd, libpackdata, libpacksize) == libpacksize) {
close(libfd);
CosELFHeader cosElfHeader = {};
memcpy(&cosElfHeader,libpackdata,sizeof(cosElfHeader));
rc4(libpackdata+cosElfHeader.oriELFHeaderOff,libpackdata+cosElfHeader.oriELFHeaderOff,cosElfHeader.oriELFHeaderSize,libkey,libkeysize);
rc4(libpackdata+cosElfHeader.oriELFPHTOff,libpackdata+cosElfHeader.oriELFPHTOff,cosElfHeader.oriELFPHTSize,libkey,libkeysize);
Elf64_Phdr * phdr = reinterpret_cast<Elf64_Phdr *>(libpackdata + cosElfHeader.oriELFPHTOff);
for(int i = 0; i < cosElfHeader.oriELFPHTNum; i++){
if(phdr->p_type == PT_LOAD){
rc4(libpackdata+phdr->p_offset,libpackdata+phdr->p_offset,phdr->p_filesz,libkey,libkeysize);
}
phdr = reinterpret_cast<Elf64_Phdr *>((char*)phdr + sizeof(Elf64_Phdr));
}
ElfReader elfReader;
elfReader.SetFile(libpacksize - cosElfHeader.oriELFPHTSize - cosElfHeader.oriELFHeaderSize,libpackdata);
elfReader.ReadElfHeader(cosElfHeader.oriELFHeaderOff);
elfReader.ReadProgramHeaders(cosElfHeader.oriELFPHTOff);
if(elfReader.ReserveAddressSpace() && elfReader.LoadSegments()){
ElfW(Phdr) * oriPhdr = reinterpret_cast<ElfW(Phdr) *>(malloc(cosElfHeader.oriELFPHTSize));
memcpy(oriPhdr,libpackdata + cosElfHeader.oriELFPHTOff,cosElfHeader.oriELFPHTSize);
elfReader.SetPhdr(oriPhdr);
std::string libname = "";
libname.append("lib");
libname.append(liblabel);
libname.append(".so");
ElfLoader elfLoader(libname);
intptr_t shellSoInfo = elfLoader.GetShellSoInfo();
if (shellSoInfo != 0) {
ElfFixer elfFixer(elfReader, shellSoInfo);
if (elfFixer.fix_soinfo() && elfFixer.prelink_image() && elfFixer.link_image()) {
return true;
}
}
}
}
}
}
return false;
}
随后的ElfReader、ElfLoader和ElfFixer就是在对解密so做加载、链接、重定位以及修复soinfo,参考[1][2][3]
需要特别说明的是,获取libcheck.so的soinfo的方法参考[5]
主动调用__dl__Z15solist_get_headv获取solist头,然后通过next成员遍历寻找目标so
区别在于安卓13/14系统的soinfo结构成员的偏移不太一样,一个是next的偏移,另一个是so名称字符串的偏移
GetLibBase就是去扫/proc/self/maps,第一次匹配到目标so,返回起始地址
void ElfLoader::SetShellSoInfo(std::string libname) {
ELFIO::elfio linker;
if (linker.load("/system/bin/linker64")){
ELFIO::symbol_section_accessor symbols(linker, linker.sections[".symtab"]);
for (unsigned int i = 0; i < symbols.get_symbols_num(); ++i) {
std::string name;
ELFIO::Elf64_Addr value;
ELFIO::Elf_Xword size;
unsigned char bind;
unsigned char type;
ELFIO::Elf_Half section_index;
unsigned char other;
symbols.get_symbol(i, name, value, size, bind, type,section_index, other);
if (name == "__dl__Z15solist_get_headv") {
intptr_t linkerbase = GetLibBase("linker64");
using solist_get_head_t = void *(*)();
static solist_get_head_t solist_get_head = reinterpret_cast<solist_get_head_t>(linkerbase + value);
intptr_t si = reinterpret_cast<intptr_t>(solist_get_head());
const char * libpath = nullptr;
while (si) {
libpath = (const char *) *(intptr_t *)(si + 0xD8);
if(strstr(libpath,libname.c_str())){
shellSoInfo = si;
break;
}
si = *(intptr_t *) (si + 0x28);
}
}
}
}
}
Step3
现在开始逆向,libcheck.so和librealcheck.so在编译时都做了控制流平坦化和字符串加密的混淆
d810可以去掉大部分平坦化混淆,字符串不多,手动解也能看
去混淆后的init方法还算看得清楚,与源码差别不大
进来unpack函数会发现它的控制流几乎没有被混淆,只有开头的一点字符串解密,然后就是读jpgdata
从jpgdata开头读原始ELF头的偏移和大小以及原始程序头表的偏移和大小
对原始ELF头的RC4解密
对原始程序头表的RC4解密
编译出来的libcheck.so是把RC4过程直接内联进了unpack函数,看起来很庞大,实际上看到256大概能猜到是RC4
然后是对p_type==1的也就是PT_LOAD的段做RC4解密
elfreader
elfloader
elffixer
解密so的加载、链接、重定位以及soinfo的修复的识别需要比较强的逆向功底和比较多的逆向经验
这里能够大概感受到是读jpgdata然后做RC4解密然后被加载即可
加载的细节可以不必深入,但要求能够手动还原librealcheck.so
(可以尝试调试提取解密数据,然后拼起来,但是笔者调试发现断不下来
(按理说jni_onload都能断下,init在onCreate调用却断不下来,8太懂
from struct import unpack
from Crypto.Cipher import ARC4
from elftools.elf.elffile import ELFFile
# 读jpgdata
with open("nevercheck114.jpg", "rb") as f:
jpgdata = f.read()
# 读原始ELF头偏移和大小
oriELFHeaderOff = unpack("<Q", jpgdata[:8])[0]
oriELFHeaderSize = unpack("<H", jpgdata[8:10])[0]
# 读原始程序头表偏移和大小
oriELFPHTOff = unpack("<Q", jpgdata[16:24])[0]
oriELFPHTNum = unpack("<H", jpgdata[24:26])[0]
oriELFPHTSize = unpack("<H", jpgdata[26:28])[0]
# RC4密钥
key = b"122608"
# 解密原始ELF头
rc4 = ARC4.new(key)
oriELFHeaderData = jpgdata[oriELFHeaderOff:oriELFHeaderOff+oriELFHeaderSize]
oriELFHeaderData = rc4.decrypt(oriELFHeaderData)
# 解密原始程序头表
rc4 = ARC4.new(key)
oriELFPHTData = jpgdata[oriELFPHTOff:oriELFPHTOff+oriELFPHTSize]
oriELFPHTData = rc4.decrypt(oriELFPHTData)
# 把原始ELF头和原始程序头表先写到一个文件,方便后续通过ELFFile读程序头表条目
with open("tmpelf", "wb") as f:
f.write(oriELFHeaderData)
f.write(oriELFPHTData)
# 初始化ELFFile
f = open("tmpelf", "rb")
elffile = ELFFile(f)
# 原始ELF文件
orielf = open("orielf", "wb")
for seg in elffile.iter_segments():
# 只处理PT_LOAD段
if seg.header["p_type"] == "PT_LOAD":
seg_p_offset = seg.header["p_offset"]
seg_p_filesz = seg.header["p_filesz"]
rc4 = ARC4.new(key)
orielf.write(rc4.decrypt(
jpgdata[seg_p_offset:seg_p_offset+seg_p_filesz]))
f.close()
orielf.close()
这里orielf的前0x40字节,也就是ELF头,是不正确的,因为第一个PT_LOAD段包含了ELF头的范围
手动修复为oriELFHeaderData,也就是从tmpelf复制前0x40字节到orielf
orielf保存后可以用ida打开,此时可以看到check方法
Step4
开始分析check方法,输入长度应为32
前16字节正常赋值,后16字节与前16字节依次异或再赋值
前后16字节经过wbaes函数变换后分别与enc1和enc2比较,都相等说明输入正确,即为flag
wbaes函数虽然没被混淆,但是很长,这里实际上是一个标准白盒AES的循环展开过程
白盒AES攻击关键是在第九轮列混淆前破坏状态矩阵中的一个字节,使得最终的密文被破坏四字节
进而恢复最后一轮密钥的四字节,如果选择破坏的字节合适,可以完整恢复出最后一轮密钥,进而恢复初始密钥
破坏字节需要hook程序流,这里选择unidbg模拟执行,然后hook
unidbg模拟的好处是不用去找解密so的加载地址,解密so的加载地址是在加载过程中由壳so分配的,不易获取
apk后缀改成zip,重命名orielf为librealcheck.so,然后加到zip,zip再改回apk
这里给出关键的攻击代码
public static byte[] hexStringToBytes(String hexString) {
if (hexString.isEmpty()) {
return null;
}
hexString = hexString.toLowerCase();
final byte[] byteArray = new byte[hexString.length() >> 1];
int index = 0;
for (int i = 0; i < hexString.length(); i++) {
if (index > hexString.length() - 1) {
return byteArray;
}
byte highDit = (byte) (Character.digit(hexString.charAt(index), 16)
& 0xFF);
byte lowDit = (byte) (Character.digit(hexString.charAt(index + 1),
16) & 0xFF);
byteArray[i] = (byte) (highDit << 4 | lowDit);
index += 2;
}
return byteArray;
}
public static String bytesTohexString(byte[] bytes) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(bytes[i] & 0xFF);
if (hex.length() < 2) {
sb.append(0);
}
sb.append(hex);
}
return sb.toString();
}
public void callNative(){
MemoryBlock inblock = androidEmulator.getMemory().malloc(16, true);
UnidbgPointer inPtr = inblock.getPointer();
byte[] stub = hexStringToBytes("30313233343536373839616263646566");
assert stub != null;
inPtr.write(0, stub, 0, stub.length);
dalvikModule.getModule().callFunction(androidEmulator, 0x000000000007FC88, inPtr);
String ret = bytesTohexString(inPtr.getByteArray(0, 0x10));
System.out.println(ret);
inblock.free();
}
/*
* 0x000000000008874C UC_ARM64_REG_X10
* 0x0000000000088450 UC_ARM64_REG_X8
* 0x00000000000876EC UC_ARM64_REG_X10
* 0x0000000000087B48 UC_ARM64_REG_X8
* */
public void attack(){
androidEmulator.attach().addBreakPoint(dalvikModule.getModule().base + 0x0000000000087B48, new BreakPointCallback() {
int count = 0;
@Override
public boolean onHit(Emulator<?> emulator, long address) {
emulator.getBackend().reg_write(Arm64Const.UC_ARM64_REG_X8,count);
count+=1;
return true;
}
});
}
public static void main(String[] args){
Redroid redroid = new Redroid();
redroid.callNative();
redroid.attack();
for(int i = 0;i<10;i++){
redroid.callNative();
}
}
0x000000000008874C、0x0000000000088450、0x00000000000876EC和0x0000000000087B48
是四个破坏一字节而最终破坏密文四字节的地址
这里因为循环被展开了,轮加密的结果存在中间变量,而不是直接写回,不太好找第九轮列混淆
有个技巧是,从最后写回密文用到的中间变量开始找
比如这里用v478,对它交叉引用,找它最后一次写操作
如果破坏这个写操作,导致最后的密文只破坏了一字节,说明破坏晚了(从最后找肯定是晚的
然后找它倒数第二次写操作,如果破坏这个写操作,导致密文破坏四字节,说明破坏成功,保留模拟的几条结果
依次类推,找不同的中间变量,直到找到最终密文的十六字节都能被破坏的至少四处写操作(就是上面的几个地址
用phoenixAES恢复最后一轮密钥
import phoenixAES
path = "tracefile"
with open(path, "wb") as f:
f.write("""
59c604bb9a72247f3ac8ee52a5fd3e4f
59c649bb9a7c247fb3c8ee52a5fd3e30
59c659bb9a8a247ff4c8ee52a5fd3ef4
596704bb1572247f3ac8ee8da5fd254f
596004bbd972247f3ac8ee12a5fdc64f
3ac604bb9a7224e23ac81d52a5013e4f
98c604bb9a7224403ac85a52a56d3e4f
59c604339a72b97f3a12ee527efd3e4f
59c604b39a72097f3ad0ee52e1fd3e4f
""".encode("utf8"))
phoenixAES.crack_file(path, verbose=1)
# Last round key #N found:
# 89274E962B51F09F449F5E59868C63BF
用Stark恢复初始密钥,为6A3C0545876924612949ED0B6CD34CDC
p1umh0@p1umh0:~/ctftools/Stark$ ./aes_keyschedule 89274E962B51F09F449F5E59868C63BF 10
K00: 6A3C0545876924612949ED0B6CD34CDC
K01: 0D1583158A7CA774A3354A7FCFE606A3
K02: 817A899F0B062EEBA833649467D56237
K03: 86D0131A8DD63DF125E5596542303B52
K04: 8A32133607E42EC7220177A260314CF0
K05: 5D1B9FE65AFFB12178FEC68318CF8A73
K06: F765104BAD9AA16AD56467E9CDABED9A
K07: D530A8F678AA099CADCE6E75606583EF
K08: 18DC772660767EBACDB810CFADDD9320
K09: C200C0B3A276BE096FCEAEC6C2133DE6
K10: 89274E962B51F09F449F5E59868C63BF
初始密钥实际上是一个md5
最后解密aes即可
from Crypto.Cipher import AES
enc1 = [0x4d, 0xcd, 0xde, 0xb2, 0xc8, 0x35, 0x06, 0xdc,
0x49, 0x2c, 0x7c, 0x35, 0x08, 0x9f, 0x46, 0x9b]
enc2 = [0x45, 0x9b, 0x79, 0x83, 0x6f, 0xc8, 0x62, 0xa1,
0x87, 0x60, 0x3a, 0x9e, 0x56, 0x92, 0x61, 0x1c]
key = bytes.fromhex("6A3C0545876924612949ED0B6CD34CDC")
aes = AES.new(key, AES.MODE_ECB)
flag1 = list(aes.decrypt(bytes(enc1)))
aes = AES.new(key, AES.MODE_ECB)
flag2 = list(aes.decrypt(bytes(enc2)))
for i in range(len(flag2)):
flag2[i] ^= flag1[i]
print("".join(chr(j) for j in flag1), end="")
print("".join(chr(j) for j in flag2))
# SUSCTF{u_4r3_m4573r_0f_4ndr01d!}
当然也可以用frida去hook,前提是将AndroidManifest.xml中的android:extractNativeLibs改为true并重打包
否则/proc/self/maps根本找不到libcheck.so模块,更别说找librealcheck.so的加载地址
AndroidManifest.xml编解码使用xml2axml
重打包后安装说没对齐
adb: failed to install redroid_Mod.apk: Failure [-124: Failed parse during installPackageLI: Targeting R+ (version 30 and above) requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary]
参考zipalign对齐,再次安装说是没签名
adb: failed to install redroid_Mod_unsign.apk: Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: Scanning Failed.: No signature found in package of version 2 or newer for package com.susctf.redroid]
使用MT管理器签名后安装成功
init是在onCreate被调用的,调用时机很早
一个可能hook到的时机是,在linker准备初始化libcheck.so,也就是准备调用call_constructors的时候去hook
用frida hook感觉很麻烦,感兴趣的读者可以一试