CVE-2018-13784: PrestaShop 1.6.x Privilege Escalation

Instead of using the usual PHP session ID and storing data locally, PrestaShop stores session data in a cookie. For the cookie not to get altered, a checksum is appended, and the whole thing is then ciphered. The process is flawed and allows attackers to read and write session data and therefore hijack customers or employees' sessions, resulting in partial or complete control over the website. By exploiting this vulnerability, one can:

Take over any customer session

Steal business data such as customer information, orders, coupons, statistics etc.

Gain full access to the administrator panel through CSRF or other vectors, resulting in code execution

Exploits are available on GitHub: here and here.

About PrestaShop

PrestaShop is a freemium, open source e-commerce solution. The software is published under the Open Software License. It is written in the PHP programming language with support for the MySQL database management system.

The most recent version is 1.7, which is not vulnerable. Nevertheless the targeted version, 1.6.1.19 and below, is still maintained, and widely used.

Session handling

In PrestaShop, your customer or employee cookie will contain every information associated with your session. This data contains what you would expect to find in a standard session: customer/employee ID, cart ID, hashed password, first name, last name, and many more. After serialization, a checksum is appended, and the whole string is encrypted. The cookie generation procedure is done in ./classes/Cookie.php .

Dissecting a cookie

Here's a step-by-step explanation of the cookie generation process.

To store session data as a cookie, PrestaShop first serializes every key-value pair by separating individual keys and values by | , and the pairs by ¤ . This results in a string.

A checksum is then computed by applying the CRC32 algorithm on the value, prefixed by a salt.

It is then appended as a last key-value pair named checksum .

The cookie is then ciphered using one of two methods (see next section). In the example image, we depict the oldest one, which uses 8-byte blocks.

As a final step, the full size of the plaintext is added at the end of the cookie - because it is not necessarily 8-byte aligned. When reading session data, this size will be used to trim the decrypted plaintext.

Here is the final form of the said cookie:

There are two distinct cookies, one for employees, and another for customers. They generally contain different values but the encryption key and salt preprended to the checksum are the same. One cannot add an arbitrary key in the cookie, but some values are easily controllable. For instance, the first name, last name and email of customers are stored in the cookie, and they can be modified using the "User Preferences" page.

Encryption modes

Two encryption modes are available: the original one, which uses a custom implementation of Blowfish / ECB, and the new one, using Rijndael (AES) via openssl_encrypt() . Both modes can be broken and values can be read and written at will.

Low-performance servers are generally using the Blowfish implementation, and more robust ones are using the AES implementation. Blowfish cookies can easily be spotted by the presence of a lot of = (equal sign) in the cookie, as each 8-byte block is base64'd independently.

The attack on the most recent mode (AES using openssl_encrypt() ) will be described first, as it is trivial.

Breaking the AES implementation

Since openssl_decrypt() uses PKCS#7 padding, it is potentially vulnerable to Padding Oracle. A successful attack requires a way to distinguish invalid paddings from valid ones. This is easily done because after the proper unpadding from openssl_decrypt() , PrestaShop trims the plaintext again by applying substr($plaintext, 0, $size) (remember, the full size of the plaintext is added at the end of the cookie).

The POC fits in a few lines: here.

The attack on the original implementation is more complex.

Breaking the Blowfish implementation

In this implementation, the plaintext session data is encrypted using Blowfish (8-byte blocks) using the ECB (Electronic CodeBook) mode of operation, and then every 8-byte block is base64-encoded.

The whole process of computing a CRC checksum and then ciphering with ECB has obvious downsides.

Flaws

CRC32

Those familiar with CRC32 (Cyclic Redundancy Check, 32 bits) know that the algorithm, although it qualifies as a hash function, is not meant for cryptographic use. Along with being extremely small (32 bits), it has two dangerous properties, which we'll be using in our attack.

Affinity

CRC is affine.

This means that, if an attacker knows the checksum value C0 of a message M0 and wants to modify a part of it to get M1 , he can compute: C1 = CRC(M0 ⊕ M1) ⊕ C0 ⊕ C

The C is a constant relative to the size of A and B . For instance, if A and B are of size n , C = CRC(0..0) with n zero bytes.

Bytewise, left-to-right

The CRC algorithm works from the first byte to the last one, one byte at a time. You can see this as an iterative process, that takes the CRC of a prefix and a new byte, and computes the new CRC:

def CRC ( bytes , crc_init = 0xFFFFFFFF ) crc = crc_init for byte in bytes : crc = CRC_step ( byte , crc ) return crc

This has terrible security implications; one of these is that if an attacker knows the crc C0 of a prefix M0 , he can compute the checksum of M0 | M1 by "continuing" the algorithm (i.e. using C0 as crc_init ):

CRC(M0) = C0 => CRC(M0 | M1) = CRC(M1, C0)

Electronic CodeBook (ECB)

To cipher data, the ECB mode splits it in same-size blocks (in this case, 8 bytes), and computes their ciphertext separately.

This means that, if for any reason, two 8-byte plaintext blocks are identical, their ciphertext is also, independently from their position.

Partial access: Writing session values

To gain partial administrator access, the id_employee value needs to be set. Our goal is to spoof this value in our original cookie. We'll do so by making use of every aforementioned flaws.

Same CRC, different data

Since the CRC is appended before the cookie is ciphered, its value is not known to the attacker. However, CRC is extremely small and due to its affine property, it is very easy to generate a collision, even without knowing the checksum. Our goal is to modify part of the cookie's plaintext without changing its (unknown) checksum. We can work with a small amount of known data -- we know the value of customer_firstname , customer_lastname , and email for instance, but not the value of the hashed password, cart ID, etc. We describe here the process to change the data without altering the checksum.

We have a cookie of plaintext P0 , whose checksum is C0 . Since we control part of this cookie ( customer_firstname , customer_lastname , email , etc.), we have: P0 = Up | K0 | Us where Up and Us designate unknown parts of the plaintext, and K0 the known part of the plaintext. We want to alter K0 to K1 without changing the checksum. We get P1 = Up | K1 | Us , the new plaintext; its checksum is also C0 . Due to the affine property of CRC, we have:

C0 = CRC(P0 ⊕ P1) ⊕ C0 ⊕ C .

This translates to:

CRC(P0 ⊕ P1) = C

Now, we know the value of P0 ⊕ P1 :

P0 ⊕ P1 = (Up ⊕ Up) | (K0 ⊕ K1) | (Us ⊕ Us) = 0..0 | (K0 ⊕ K1) | 0..0 .

Due to the fact that the constant C is in fact CRC32(0..0) with |P0| zeros, we have: CRC(0..0 | (K0 ⊕ K1) | 0..0) = CRC(0..0) . Therefore, due to the bytewise LTR property of CRC32, we get the final equation:

CRC(K0 ⊕ K1) = CRC(0..0) (with |K0| zeros)

This means we need to find a collision. Since we're only interested in changing a part of K1 , we can use the rest of it as a "correction" so that the equation stands. In practice, that means choosing the beginning of K1 , and bruteforcing the rest. Since we want to spoof the session value id_employee , K1 will look like something like this: ¤id_employee|1¤Ad7SssDU ( Ad7SssDU being the bruteforced value).

We are now able to change the plaintext of the cookie without changing its checksum. The next step is handling the encryption.

Obtaining the ciphertext

We need to obtain encrypted equivalent to the ¤id_employee|1¤ plaintext. Obviously, it is not possible to force PrestaShop into encrypting this string as-is. What we can do is, by carefully choosing controlled values, get small parts of the payload, and merge them afterwards. For instance, since we control customer_lastname , we can set its value to A..Aemployee (with an arbitrary number of A s) so that the cookie contains a block whose plaintext is employee . Another example: since we do not control keys, we cannot encrypt a block of the form ...|1 . Instead, we can set our email to 0000001@test.fr , and pad the cookie before so that we get a ciphered block equivalent to |0000001 . By repeating the technique, we can virtually cipher anything.

As shown in the example, we need 4 blocks to spoof id_employee to 1 . After these, we bruteforce an alphanumeric value such that CRC(K0 ⊕ K1) = CRC(0..0) . We can then change 5 blocks in the original cookie (the 4 we spoofed and a correction block), and it will be valid, despite containing a spoofed value.

Partial access

We successfully created a valid cookie with a spoofed id_employee and a valid checksum. We now have partial access to the administrator panel: every business-related data (user information, coupons, orders...) is available to us. Nevertheless, our end game is getting code execution, and there are several ways to get it.

Full access: Reading session values

Passwords, tokens, they're all the same

Whenever PrestaShop needs to hash a value, either a verification token or a password, it uses this function:

Since our hashed password is contained in the cookie, by setting a specific password and reading its hash from the session cookie, we would have a valid token.

There are two tokens of high interest in PrestaShop.

CSRF token

Administrators are protected from CSRF using a token sent through HTTP GET and generated like so:

Obtaining such a token means that CSRF attacks are possible. There are ways to execute code (RCE) using only one request.

Recover cart token

PrestaShop has a "Recover cart" function: if you send a cart ID and its associated token, it will restore your session state. You'll be logged in as the customer who owns the cart. Getting a recover cart token allows to take over a customer cookie, which contains the hashed password of the customer.

In order to obtain complete access to the backoffice, our session needs to contain the ID of an employee, and his/her hashed password. A probable hypothesis is that in a given PrestaShop website, an employee also has a customer account with the same password. If we were to take over an employee's customer account, we'd then just have to spoof id_employee (as we have done before) in order to get complete access to the backoffice. If this does not work (i.e. no employee has a customer account with the same password), we can generate a CSRF token to bypass Prestashop's CSRF protection.

Reading tokens

Reading a session value is more difficult than writing one. Luckily, one mistake allows to do it, giving us complete control over the cookie values.

Deserialization oversight

Due to how the serialization algorithm works, the last key/value pair (abbreviated: KVP) of the cookie will always be the checksum, as it is computed last and then appended. In the deserialization procedure, when PrestaShop verifies the checksum of a cookie, it removes this last KVP before computing the expected checksum, as we can see in the code ( ./classes/Cookie.php ).

Lines 288 to 291 show us that there is no guarantee that the last KVP is indeed checksum . We can add another KVP as a last value (for instance, customer_lastname ) and it won't be included in the checksum computation, and therefore its value will not affect the checksum. Nevertheless, it will still be included in the session. We can use this to build a read oracle: instead of checksum , we can put customer_lastname along with unknown ciphertexts as the last KVP. After deserialization, the session value customer_lastname will contain the plaintext equivalent to the unknown ciphertexts. Since customer_lastname is displayed on the page, we'll be able to see the plaintext of the aforementioned ciphertexts.

However, with this setup, the checksum KVP will be used in the checksum computation (line 291). So, how can we include the checksum value in the checksum computation without altering it ? In the same way as before, we can just add a correction block after the checksum, so that it remains unchanged.

This involves knowing the checksum value, though. We'll need to find a way to leak it.

Getting a cookie's checksum

A cookie's checksum is usually constituted of 10 base-10 digits (CRC32 as a UINT32), and therefore it will be contained in the last two blocks of the cookie. Straight up bruteforcing those two blocks, while doable, is extremely expensive and could very well be detected or blocked. Here is a more effective method that takes less than 80 requests.

1. 10 one-digit blocks

We generate 10 one-digit block by setting our email to: 0xxxxxxx1xxxxxxx2xxxxxxx...9xxxxxxx@test.fr

2. Padding the cookie

We pad the cookie for the last digit of the checksum to be in the last block of the cookie. We'll name this cookie Cookie A since the customer_lastname field ends with an A .

3. Find digit value

We replace the last block of the padded cookie by one of the email blocks until one works.

4. Combine

We can conclude:

Nevertheless, 9 digits are left to be found. We repeat operation 2 and 3 for Cookie B:

Now, we have a system of two equations with two variables:

However, using the affine property of CRC, we can express CRC(Cb) as a function of CRC(Ca) :

We can substitute the value in our original equation system:

We now have a system of two equations, but with only one variable.

Repeating the operation with Cookie C, D, E, ..., we get:

After a while, only one solution will remain, revealing the checksum of the cookie.

Combining everything

Original cookie:

By applying the previous method, we can compute its checksum. Then, we can locally bruteforce a correction block so that the checksum stays the same.

The last KVP is now free, and we can set customer_firstname as the key and the blocks from password as the value.

Since our last name is displayed on the page (top right corner), the password hash will be readable.

Since this hash corresponds to a token, we now have a valid token. We can use this token to perform a CSRF attack, or obtain a full cookie of any customer (via the "Recover Cart" function). We can then write an id_employee KVP in the cookie, and if the passwords are indeed the same, we'll have full access to the backoffice.

Exploitation

One-shot attack

To perform the attack, we have two tools we can work with: we can read and write cookies. Our goal is to find an employee who also has a customer account with the same password. The ID, name, and email of this employee (and customer) are previously unknown. If we find no such employee, the attack fails and our only other option is to generate a token in order to perform a CSRF attack.

Full exploit is here. The condition for this exploit to work is for an employee to have the same password as a customer.

Sample output:

Getting RCE is easy after you've got access to the backoffice. RIPS already covered the bug here.

CSRF attack

By modifying the exploit, one can easily generate a CSRF token (check ReadableCookie ).

Other stuff

The exploit can be used to get partial access to the backoffice (as described in the beginning of the article) and access every customer account. You'll need to change the code a bit (or a lot).

Notes

The full exploit can fail for a variety of reasons, and as such it is merely a POC. Padding everything correctly was a real pain, and PrestaShop modules/extensions can mess up the cookie. For instance, if during the exploitation, an ID goes from 9 to 10, or 99 to 100, the padding will change and the exploit will likely fail. Retrying the exploit once or twice is a good idea. All in all, this is a work in progress, but a persistent attacker should be able to tweak the exploit in order to make it work in his own specific situation.

Vendor Response

The PrestaShop team was contacted on May, 1st 2018 and after a few emails describing the issues, the bug was patched on the 28th of the same month. Versions 1.6.1.20 and 1.7.3.4 fix the bug, and the changelog indicates "Improve cookie encryption" -- while being technically true, this is a bit obscure.