Published: Thu 19 October 2023
By florian0
In Writeup .
tags: Jeopardy Misc
Challenge author: pspaul
Can you unravel the secret of
the Soul Splitter ?
The challenge features a console app written in python. The app will give you 16 shards to recover a soul from. In order
to solve the challenge, you need to recover 20 souls in a row.
👹 I am the Soul Splitter !
👹 If you can recover 20 souls , I will tell you my secret .
👹 Here are 16 shards :
eJw7tOAQVvhoWgN2CQTkwif5aFoLXqOwaibCUhx6kawjYDAxmnG6gwtFC5HOJSq8KNZK2M34dWMEQQuSZgLBi8NiDCeRYAwXio + waMQb + LQNaqxOADuRC6fLiEgrAGgFZ9M =
eJx7NK3h0ALyINejaS2HFjwizwAuEEGyXqgGLjQ + Fg4Bm8myFkhxwQRacKvFKUWGzZheJlV3C9l60azF4zEsdoKdjdVitGjCiDW4PRQFFzkpA4unkfRiMQSruRSENgC1aWjz
eJx7NK3l0IJDCx5NawBRpEEuZA6JJnCRopgMrTicg1PvI0g4wDWj8OEKaGI1lXyM5gfaWkswtvFaS0A37UMKq0Ow6MUaoljtJTH5oygHAGsIY6k =
eJw7tOAQBnw0rQUHBxVy4dVIQAJDMz74aFoDil4wH02QSHvBNoPF8fgMN + TC6iACAQVXTZKnKQgvPFqBboGGHkokY3gH3dVw9cR6nXRPYzGZCnoJe5RgmBHwL4b3cScSwhAAzwprZw ==
eJw7tODRtIZDCxDw0bQWZC4 + yIUpRLRuLhLsIWgv3AtgBhKvBbsKiOV4XYBHEou38UEUk7jQQpskB5BoMUVakdxJlF4c4YVVLxa1QCFyPQx0KdZA5UIowOo0vNFPw / DCmwIAEvhqPw ==
eJw7tOAQHD6a1gAl0CFWwUMLuCByLdjkEFqxSgOFuYhQhgty4XUXUXrJgVi0Qh0OdQncG1hdRpbNUCNJ0osSmkCnYNWM5ES8oY9bM15tUOPJDu1H0zqI1gt2CFqQkxhgDdRxNUVaAWDlaQs =
eJw7tOAQCfDRtAYEhwuF92haCw4ODkEuAvI4bMWhmXhIvL3E6300rYMI83A4mij7certQAkjHGZh1Y2mFkso47Uau5ENZOulwFoMJ1DfWgyfYQ1pLjzBSAgCAMi7Z1E =
eJw7tOAQFD6a1nBoATpEE3w0rQWZy4WpAa4Gq3EENePVCZSAygEpnNoJQ5K04vIykitRmC04jQErw6afCM0N5IYZlX2Nx24whdULeFyNmVQwfEJJROMMURrrJd / JALfqasU =
eJw7tOAQGfDRtIZDC7iIU9kCVQ7nQSCGbrgiLAagQxxWY1WLYQNR7sYOqa8Vh69RJIBMSi0mEDI4XUO2veS5GWw / auLCGUCYenGnLRJDi + hgQnMcBfYCAEf3ZgE =
eJw7tOAQmZALQj2a1oJP1aNpDXg0Y9eB10C8eol0Mxan4XAnmma42wg6EkMBQVej6EBzDheSKBEupU14UVkrRgghCSD7F5tOLMJIglw4TCXKJLwpE6tzsOglMY7AygFrHGc5
eJw7tODQgkfTGg4tIB7ClXMRVtmC0wguvLbi1EmUzXh1c6H5AqvXcJpI0M9kO5psrYTDCs1bcC5QJ5CNIdtCmZ9RzCNJL5pLqBlgSL5qwK8VqBI1bRIIXzRloKRNvrsBZP5qMw ==
eJw7tOAQGD6a1nBoASkQqIGLKFVYmCDIhSKKIvloWguYD6RRzWpBMwC36eS5HcMQNCvR3E4OxKkVaDdBP3AR9CkeSYKORtOL4ndSNdM6tEjQSkTKwFBCWyfjdBEAhq5o6w ==
eJw7tOAQBnw0rQFEtKCKtWAq5MIqC9aOCyJJcuFWRQji1YrVqUTqpcBaSn2LpBYjBAloxutjDL0EwgfFcuoGFloyIeRoAgmpBacZMN1oBuA1jxS78QQaF1ZR4iAAuWRoaQ ==
eJw7tOAQHD6a1nJoAbEQqJiLeNXokAthTAPZenE7DcNMuBAJboYGB5JpXCSHEpGuxmImihDlIY1hA1HeoEYMEx3HyAFNZiDDLSaoH24ZWCV6HBPlZqhOFItI9zUCAgDPRWff
eJw7tOAQmZALU + jRtAYcHAxBLLoJaEVItnARqRSrErw2EzAFQy8R1sPtBTocwydYvYdVnAuHeqwuBatEcRtpQYZhNREOJ0Iv0TZDFXIhaaDc1URbi6QXh634o5Jom7GYDgCXuWrB
eJw7tOAQXvhoWgMuKS6CKrAZ1wJESLrJsJegXrymEK0XiwlcRPkX6kG8FhMwBMMIUjQT7WMizOGCqiHRSqgPuBC8BlRJHKZhhjapkLjURVZwITkSRxSTYS + SjwEqsWfH
👹 Recover the soul to prove you are worthy :
Walk through the code
When no environment vars are present, the main
function loops 20 times. According to the output of the challenge
connection, the challenge host also uses 20 here.
The loop is generating something, then asks for an input. If the given input does not match the expected input, the loop
exits. If we complete all 20 iterations without exiting early, we get the flag.
This is python3, so input does not use eval anymore 🥲.
SOULS = int ( os . getenv ( 'SOULS' , '20' ))
def main ():
print ( '👹 I am the Soul Splitter!' )
print ( f '👹 If you can recover { SOULS } souls, I will tell you my secret.' )
for _ in range ( SOULS ):
soul , shards = generate_challenge ()
print ( f '👹 Here are { len ( shards ) } shards:' )
print ( ' \n ' . join ( shards ))
recovered = input ( '👹 Recover the soul to prove you are worthy: ' )
if recovered != soul :
print ( '👹 Wrong! You are unworthy. ' + soul )
return
print ( '👹 You got lucky with this one...' )
print ( '👹 You haven proven yourself! Here \' s my secret:' )
print ( FLAG )
generate_challenge
will just call two functions: select_soul
and split_soul
. select_soul
is just generating a
random string of 16 characters. The string could look like this 'Rb6izPy6oVv-KLvYfR9VQg'
.
SHARDS = int ( os . getenv ( 'SHARDS' , '17' ))
def select_soul ():
return secrets . token_urlsafe ( 16 )
def generate_challenge ():
soul = select_soul ()
shards = split_soul ( soul , SHARDS )
return soul , random . sample ( shards , SHARDS - 1 )
In order to understand split_soul
, we first have to have a look at soul_code
and code_atoms
.
soul_code
will take our random string and make a qrcode from it. Note that error-correction is set to high.
def soul_code ( data ):
code = qrcode . QRCode (
border = 0 ,
error_correction = qrcode . constants . ERROR_CORRECT_H ,
)
code . add_data ( data )
code . make ()
return code
Code atoms takes the qrcode and flattens the modules into a list of tuples.
def code_atoms ( code ):
size = len ( code . modules )
atoms = []
for y in range ( size ):
for x in range ( size ):
atoms . append (( x , y , code . modules [ y ][ x ]))
return atoms , size
The modules of the qrcode object are pretty much a boolean representation of the pixels of the code in a 2d array. For
example, you can see one of the ID-squares as true
& false
values.
So after code_atoms
is done, we have a list of pixels .
Now we finally get to split_soul
. The function will shuffle these atoms. This will order them randomly. The atoms are
then grouped into equally sized chunks. Each chunk now contains some of the pixels of the qrcode. The chunk is called
shard_code .
def split_soul ( soul , n ):
code = soul_code ( soul )
atoms , size = code_atoms ( code )
random . shuffle ( atoms )
atom_count = len ( atoms )
r = atom_count % n
atoms_per_shard = atom_count // n
shard_codes = []
for i in range ( 0 , atom_count - r , atoms_per_shard ):
shard_codes . append ( atoms [ i : i + atoms_per_shard ])
for i in range ( r ):
shard_codes [ i ] . append ( atoms [ - 1 - i ])
return [ atoms_token ( size , shard_code ) for shard_code in shard_codes ]
Using list-comprehension, shard_code is turned into something else using atoms_token
. The function is not very
spectacular. It calls atoms_to_text
, compresses the result and turns it into base64. So atoms_to_text
contains all
the magic.
atoms_to_text
first make a 1d-buffer that can hold all pixels of the qrcode. The buffer is later used like a
2d-buffer (buf[y * size + x]
).
The shard_code
is now called atoms
again, yay. The list of pixels is now turned back into a pseudo-2d-array again.
So now we have a buffer with some randomly selected pixels of a qrcode.
That buffer is then iterated, but every second line is skipped (for y in range(0, size, 2):
stepsize is 2).
Depending on the value of the current pixel a
and the value of the same pixel in the next line (b
), a
character is printed.
So in the end, we get a string that represents a subset of all the pixels in a qrcode, and each character tells the
value of two pixels.
BLOCK_FULL = chr ( 9608 )
BLOCK_UPPER = chr ( 9600 )
BLOCK_LOWER = chr ( 9604 )
BLOCK_EMPTY = chr ( 160 )
def atoms_token ( size , atoms ):
text = atoms_to_text ( size , atoms )
return base64 . b64encode ( zlib . compress ( text . encode ( 'utf-8' ))) . decode ( 'utf-8' )
def atoms_to_text ( size , atoms ):
buf = [ False ] * size ** 2
for atom in atoms :
x , y , is_set = atom
buf [ y * size + x ] = is_set
text = io . StringIO ()
for y in range ( 0 , size , 2 ):
for x in range ( size ):
a = buf [ y * size + x ]
b = buf [( y + 1 ) * size + x ] if ( y + 1 ) * size + x < len ( buf ) else False
if a and b :
text . write ( BLOCK_FULL )
elif a :
text . write ( BLOCK_UPPER )
elif b :
text . write ( BLOCK_LOWER )
else :
text . write ( BLOCK_EMPTY )
text . write ( ' \n ' )
return text . getvalue () . strip ( ' \n ' )
Now we have a list of 17 of these strings. split_soul
will select 16 of these. The main
will print these.
TL;DR
The program generates a qrcode from a random string. The pixels of the qrcode are randomly selected, distributed into
chunks and converted into 17 strings. From these 17 strings, only 16 are printed in the main. So some data is missing.
Our task is reconstructing the value of the qrcode 20 times in a row.
Reconstructing the qrcode
Let's start by connecting to the server. That's probably the easiest part 😜.
from pwn import remote
def main ():
conn = remote ( "flu.xxx" , 10120 )
for _ in range ( 20 ):
conn . recvuntil ( b 'Here are 16 shards: \n ' )
shards = conn . recvuntil ( b 'Recover the soul to prove you are worthy' )
shardlines = shards . splitlines ()
restore_qr ( shardlines [: - 1 ])
code = input ( "Enter code:" )
conn . sendline ( code . encode ())
conn . interactive ()
restore_qr
accepts an array of bytes (shards).
But first we need to undo base64 and zlib compression for each line:
def decode_qr ( qr ):
for q in qr :
yield zlib . decompress ( base64 . b64decode ( q )) . decode ()
We make a 2d array of booleans that represents the pixels of the qrcode.
We loop over the result. Each shard is now a text of 4 different characters, each line relates to two lines of the
original qr code.
Depending on the character we find, we either set one or two pixels in the current and/or the next line.
def restore_qr ( shards ):
cols = 32
buf = [[ False for _ in range ( cols )] for _ in range ( cols )]
for shard in decode_qr ( shards ):
# make 2d
image = shard . splitlines ()
for y in range ( len ( image )):
for x in range ( len ( image [ 0 ])):
pixel = image [ y ][ x ]
if pixel == BLOCK_FULL :
buf [ y * 2 ][ x ] = True
buf [ y * 2 + 1 ][ x ] = True
elif pixel == BLOCK_UPPER :
buf [ y * 2 ][ x ] = True
elif pixel == BLOCK_LOWER :
buf [ y * 2 + 1 ][ x ] = True
elif pixel == BLOCK_EMPTY :
pass
else :
print ( f "Unknown value { ord ( pixel ) } " )
image = boolean_array_to_image ( buf )
with open ( "code.png" , "wb" ) as f :
image . save ( f )
After putting all the pixels into the buffer, we make an image from it. (ChatGPT wrote that code. Nice!)
def boolean_array_to_image ( array ):
width , height = len ( array [ 0 ]), len ( array )
image = Image . new ( "1" , ( width , height ))
for y in range ( height ):
for x in range ( width ):
pixel_color = 0 if array [ y ][ x ] else 1
image . putpixel (( x , y ), pixel_color )
return image
Our goal was to simply scan it with a smartphone. Scanning 20 codes shouldn't be that big of a deal. But it turns out
that the missing pixels were a problem for a our scanner app.
Try scanning it on your phone (Trust me bro, Kappa).
For reference, I dumped the original qrcode from the script. You can clearly see some differences.
Of course, we did not have that for the challenge. So we had to find another way to make the scanner app scan it. We
found that by accident.
We found this image on Wikipedia . It was drawn by Wtuvell.
Thank you!
The interesting parts are the Red and Purple ones. These are used to identify the qrcode.
We tried fixing the code manually in Paint. We accidentally filmed the process with the scanner app and at some point it
detected the code and read it.
Since that worked one time, we decided to repeat that 19 more times. Get the code, fix it by hand, scan it, send
the resulting text to the server, repeat.
To quickly get the text from the phone to the PC, we paired it using KDE Connect and used
the Clipboard Sharing -Feature.