> Challenge author: pspaul>> Can you unravel the secret of> the [Soul Splitter](https://challenge.zip/soulsplitter_dfe9a6fd6693b213beacbcf771c7f7ba.zip)?
(I'm kinda curious how long that link will work. Plis notify me if it doesn't work anymore)
The challenge features a console app written in python. The app will give you 16 shards to recover a soul from. In orderto 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+Fg4Bm8myFkhxwQRacKvFKUWGzZheJlV3C9l60azF4zEsdoKdjdVitGjCiDW4PRQFFzkpA4unkfRiMQSruRSENgC1aWjzeJx7NK3l0IJDCx5NawBRpEEuZA6JJnCRopgMrTicg1PvI0g4wDWj8OEKaGI1lXyM5gfaWkswtvFaS0A37UMKq0Ow6MUaoljtJTH5oygHAGsIY6k=eJw7tOAQBnw0rQUHBxVy4dVIQAJDMz74aFoDil4wH02QSHvBNoPF8fgMN+TC6iACAQVXTZKnKQgvPFqBboGGHkokY3gH3dVw9cR6nXRPYzGZCnoJe5RgmBHwL4b3cScSwhAAzwprZw==eJw7tODRtIZDCxDw0bQWZC4+yIUpRLRuLhLsIWgv3AtgBhKvBbsKiOV4XYBHEou38UEUk7jQQpskB5BoMUVakdxJlF4c4YVVLxa1QCFyPQx0KdZA5UIowOo0vNFPw/DCmwIAEvhqPw==eJw7tOAQHD6a1gAl0CFWwUMLuCByLdjkEFqxSgOFuYhQhgty4XUXUXrJgVi0Qh0OdQncG1hdRpbNUCNJ0osSmkCnYNWM5ES8oY9bM15tUOPJDu1H0zqI1gt2CFqQkxhgDdRxNUVaAWDlaQs=eJw7tOAQCfDRtAYEhwuF92haCw4ODkEuAvI4bMWhmXhIvL3E6300rYMI83A4mij7certQAkjHGZh1Y2mFkso47Uau5ENZOulwFoMJ1DfWgyfYQ1pLjzBSAgCAMi7Z1E=eJw7tOAQFD6a1nBoATpEE3w0rQWZy4WpAa4Gq3EENePVCZSAygEpnNoJQ5K04vIykitRmC04jQErw6afCM0N5IYZlX2Nx24whdULeFyNmVQwfEJJROMMURrrJd/JALfqasU=eJw7tOAQGfDRtIZDC7iIU9kCVQ7nQSCGbrgiLAagQxxWY1WLYQNR7sYOqa8Vh69RJIBMSi0mEDI4XUO2veS5GWw/auLCGUCYenGnLRJDi+hgQnMcBfYCAEf3ZgE=eJw7tOAQmZALQj2a1oJP1aNpDXg0Y9eB10C8eol0Mxan4XAnmma42wg6EkMBQVej6EBzDheSKBEupU14UVkrRgghCSD7F5tOLMJIglw4TCXKJLwpE6tzsOglMY7AygFrHGc5eJw7tODQgkfTGg4tIB7ClXMRVtmC0wguvLbi1EmUzXh1c6H5AqvXcJpI0M9kO5psrYTDCs1bcC5QJ5CNIdtCmZ9RzCNJL5pLqBlgSL5qwK8VqBI1bRIIXzRloKRNvrsBZP5qMw==eJw7tOAQGD6a1nBoASkQqIGLKFVYmCDIhSKKIvloWguYD6RRzWpBMwC36eS5HcMQNCvR3E4OxKkVaDdBP3AR9CkeSYKORtOL4ndSNdM6tEjQSkTKwFBCWyfjdBEAhq5o6w==eJw7tOAQBnw0rQFEtKCKtWAq5MIqC9aOCyJJcuFWRQji1YrVqUTqpcBaSn2LpBYjBAloxutjDL0EwgfFcuoGFloyIeRoAgmpBacZMN1oBuA1jxS78QQaF1ZR4iAAuWRoaQ==eJw7tOAQHD6a1nJoAbEQqJiLeNXokAthTAPZenE7DcNMuBAJboYGB5JpXCSHEpGuxmImihDlIY1hA1HeoEYMEx3HyAFNZiDDLSaoH24ZWCV6HBPlZqhOFItI9zUCAgDPRWffeJw7tOAQmZALU+jRtAYcHAxBLLoJaEVItnARqRSrErw2EzAFQy8R1sPtBTocwydYvYdVnAuHeqwuBatEcRtpQYZhNREOJ0Iv0TZDFXIhaaDc1URbi6QXh634o5Jom7GYDgCXuWrBeJw7tOAQXvhoWgMuKS6CKrAZ1wJESLrJsJegXrymEK0XiwlcRPkX6kG8FhMwBMMIUjQT7WMizOGCqiHRSqgPuBC8BlRJHKZhhjapkLjURVZwITkSRxSTYS+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 challengeconnection, 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 loopexits. If we complete all 20 iterations without exiting early, we get the flag.
This is python3, so input does not use eval anymore ?.
```pythonSOULS = 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 arandom string of 16 characters. The string could look like this `'Rb6izPy6oVv-KLvYfR9VQg'`.
```pythonSHARDS = 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.
```pythondef 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.
```pythondef 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. Forexample, you can see one of the ID-squares as `true` & `false` values.
![modules is a 2d list of booleans, representing the qr code like pixels](https://zeroflagsinc.gitlab.io/images/soul-splitter-modules.png)
So after `code_atoms` is done, we have a list of *pixels*.
![The atoms list contains tuples with an x and y coordinate, and the value of that coordinate (true or false)](https://zeroflagsinc.gitlab.io/images/soul-splitter-atoms.png)
Now we finally get to `split_soul`. The function will shuffle these atoms. This will order them randomly. The atoms arethen grouped into equally sized chunks. Each chunk now contains some of the *pixels* of the qrcode. The chunk is called*shard_code*.
```pythondef 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 veryspectacular. It calls `atoms_to_text`, compresses the result and turns it into base64. So `atoms_to_text` contains allthe magic.
`atoms_to_text` first make a 1d-buffer that can hold all *pixels* of the qrcode. The buffer is later used like a2d-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`), acharacter 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 thevalue of two pixels.
```pythonBLOCK_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 intochunks 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 ?.
```pythonfrom 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:
```pythondef 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 theoriginal qr code.
Depending on the character we find, we either set one or two *pixels* in the current and/or the next line.
```pythondef 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!)
```python
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 outthat the missing pixels were a problem for a our scanner app.
Try scanning it on your phone (Trust me bro, Kappa).
![The resulting qr code with some features missing](https://zeroflagsinc.gitlab.io/images/soul-splitter-shard-qr.png)
For reference, I dumped the original qrcode from the script. You can clearly see some differences.
![The original qrcode for reference](https://zeroflagsinc.gitlab.io/images/soul-splitter-original-qr.png)
Of course, we did not have that for the challenge. So we had to find another way to make the scanner app scan it. Wefound that by accident.
We found this image on [Wikipedia](https://en.wikipedia.org/wiki/File:QRCode-2-Structure.png). It was drawn by Wtuvell.Thank you!
The interesting parts are the Red and Purple ones. These are used to identify the qrcode.
![A great qrcode explanation](https://zeroflagsinc.gitlab.io/images/soul-splitter-qrcode-explain.png)
We tried fixing the code manually in Paint. We accidentally filmed the process with the scanner app and at some point itdetected 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, sendthe resulting text to the server, repeat.
To quickly get the text from the phone to the PC, we paired it using [KDE Connect](https://kdeconnect.kde.org/) and usedthe *Clipboard Sharing*-Feature.
