Catch-22
Overview
-
Overall difficulty for me (From 1-10 stars): ★★★★☆☆☆☆☆☆
-
Challenge difficulty: ★☆☆☆☆
Background
You need a key to open the door… but what if the key is in the room?
http://chal-a.hkcert22.pwnable.hk:28251 , http://chal-b.hkcert22.pwnable.hk:28251
Attachment: catch-22_c445efdb7185cb4c1a7b3002462179d6.zip
Solution: https://hackmd.io/@blackb6a/hkcert-ctf-2022-ii-en-6a196795
Find the flag
In this challenge, we can download an attachment:
┌──(root🌸siunam)-[~/ctf/HKCERT-CTF-2022/Crypto/Catch-22]
└─# unzip catch-22_c445efdb7185cb4c1a7b3002462179d6.zip
Archive: catch-22_c445efdb7185cb4c1a7b3002462179d6.zip
inflating: package.json
creating: src/
inflating: src/app.js
creating: src/views/
inflating: src/views/home.handlebars
creating: src/views/layouts/
inflating: src/views/layouts/main.handlebars
inflating: src/views/register.handlebars
inflating: src/util.js
inflating: src/actions.js
inflating: src/constants.js
inflating: yarn.lock
Home page:

Let's register an account for testing!


In here, we can see that the map has 2 keys, 1 key is reachable, and has 3 locked door?
That's impossible to unlock all the door in a normal way!
Let's look at the source code!
util.js:
const crypto = require('crypto')
const key = crypto.randomBytes(16)
function encryptToken (state) {
const token = JSON.stringify(state)
const cipher = crypto.createCipheriv('aes-128-ecb', key, null)
cipher.setAutoPadding(true)
const encryptedToken = Buffer.concat([
cipher.update(token),
cipher.final()
])
return encryptedToken.toString('hex')
}
function decryptToken (encryptedTokenHex) {
const encryptedToken = Buffer.from(encryptedTokenHex, 'hex')
const cipher = crypto.createDecipheriv('aes-128-ecb', key, null)
cipher.setAutoPadding(true)
const token = Buffer.concat([
cipher.update(encryptedToken),
cipher.final()
]).toString()
const state = JSON.parse(token)
return { token, state }
}
module.exports = {
encryptToken,
decryptToken
}
Hmm… We can see the token is being decrypted and encrypted via AES ECB mode.
app.js:
[...]
app.post('/register', bodyParser.urlencoded(), function (req, res) {
try {
const { username } = req.body
const newToken = encryptToken({
username,
x: 13,
y: 5,
inventory: [],
onMapItems: [
{item: ITEMS.KEY, x: 3, y: 4},
{item: ITEMS.DOOR, x: 4, y: 5},
{item: ITEMS.DOOR, x: 5, y: 5},
{item: ITEMS.DOOR, x: 6, y: 5},
{item: ITEMS.KEY, x: 15, y: 1},
]
})
res.cookie('game-token', newToken)
return res.redirect('/')
} catch (err) {
console.error(err)
return res.status(500).json({ error: 'unexpected error' })
}
})
[...]
In app.js, when we send a POST request in /register, it'll assign us a token, and parse it to set a cookie.
decryptToken:

{"username":"siunam","x":13,"y":5,"inventory":[],"onMapItems":[{"item":0,"x":3,"y":4},{"item":1,"x":4,"y":5},{"item":1,"x":5,"y":5},{"item":1,"x":6,"y":5},{"item":0,"x":15,"y":1}]}
Cookies:

aca1fae0ede7f469260b38ab325fc0a7c0a578b67ff8e93a7ca255c1802b5178d092ffc0b18edeb060f3fc660a8d84abcf4f7a9b0dccc8ae94e09439f208e9e06ed7d7778ae3d0b1ded2363941565dae601cd81f55858da6fff04a27e13d8f435b7c4b8abbb05ecc99b4679ba42b5538d48ffbe7581c43f177590f71650580b5dfe0cd22903b61749a6b151e921fe4f8e1350780de45150a9fcfb910b4479a4a2c58fc700f9be2af04a501028627908aa6087df656b66d2d2ca83810e594022e
Armed with the above information, we can try to dig much deeper!
Block ciphers, like AES (Advanced Encryption Standard), are only able to encrypt messages with a fixed length. In our instance, AES can only encrypt messages of 16 bytes.
AES electronic codebook (ECB) mode:

In this challenge, we can see in util.js, it's using ECB mode, and each message block is encrypted by every 16 bytes.
Let's look at our cookie value!
Translate each message block to 16 bytes:
| Plaintext Block | Ciphertext Block |
|---|---|
| {"username":"siu | aca1fae0ede7f469260b38ab325fc0a7 |
| nam","x":13,"y": | c0a578b67ff8e93a7ca255c1802b5178 |
| 5,"inventory":[] | d092ffc0b18edeb060f3fc660a8d84ab |
| ,"onMapItems":[{ | cf4f7a9b0dccc8ae94e09439f208e9e0 |
| "item":0,"x":3," | 6ed7d7778ae3d0b1ded2363941565dae |
| y":4},{"item":1, | 601cd81f55858da6fff04a27e13d8f43 |
| "x":4,"y":5},{"i | 5b7c4b8abbb05ecc99b4679ba42b5538 |
| tem":1,"x":5,"y" | d48ffbe7581c43f177590f71650580b5 |
| :5},{"item":1,"x | dfe0cd22903b61749a6b151e921fe4f8 |
| ":6,"y":5},{"ite | e1350780de45150a9fcfb910b4479a4a |
| m":0,"x":15,"y": | 2c58fc700f9be2af04a501028627908a |
| 1}]}____ | a6087df656b66d2d2ca83810e594022e |
Note: 16 bytes = 32 hex characters,
_= padding character
Also, since every blocks are independently encrypted, we can just modify one of those block!
What if I fill the inventory with bunch of keys by swaping the ciphertext block??
After I fumbling around, when I pick up a key, the inventory will add a value called 0.
To swap the ciphertext block, I'll register an account named siu0,0,0,0,0,0,0,0 0nam, the ciphertext block will become:
| Plaintext Block | Ciphertext Block |
|---|---|
| {"username":"siu | aca1fae0ede7f469260b38ab325fc0a7 |
| 0,0,0,0,0,0,0,0 | 8c1842fb0df889fd6ed5416f7507a0b0 |
| 0nam","x":13,"y" | 5762c9742bf323de4593eee85eca5067 |
| :5,"inventory":[ | f90d10d66852d845107a087525c48918 |
| ],"onMapItems":[ | 42924e9de696556ccd87cd2fc3521bb5 |
| {"item":0,"x":3, | a3b6abc3b51b9451c9830d89b581440c |
| "y":4},{"item":1 | f7e8419ca8f122e0493ddfc213523685 |
| ,"x":4,"y":5},{" | 8922e4617b39d6b06abdbb05c8b1a619 |
| item":1,"x":5,"y | 5656e0d6c9f584984e11a2397e28bdd3 |
| ":5},{"item":1," | c19796e2c753f799143f2f5e314ff561 |
| x":6,"y":5},{"it | 90fdf086bfd8d8eb1f8076079eb2d15c |
| em":0,"x":15,"y" | e3dd02803671e9359bef00fbc4936c1e |
| :1}]}{padding} | d38069bc2385c77065fa5fe3781739a1 |
You can see that we have a new ciphertext block which filled with bunch of 0's.
Now, what if I move the second ciphertext block (0's) to between the fourth and the fifth blocks? If we successfully modify the ciphertext block, it'll be:
| Plaintext Block | Ciphertext Block |
|---|---|
| {"username":"siu | aca1fae0ede7f469260b38ab325fc0a7 |
| 0nam","x":13,"y" | 5762c9742bf323de4593eee85eca5067 |
| :5,"inventory":[ | f90d10d66852d845107a087525c48918 |
| 0,0,0,0,0,0,0,0 | 8c1842fb0df889fd6ed5416f7507a0b0 |
| ],"onMapItems":[ | 42924e9de696556ccd87cd2fc3521bb5 |
| {"item":0,"x":3, | a3b6abc3b51b9451c9830d89b581440c |
| "y":4},{"item":1 | f7e8419ca8f122e0493ddfc213523685 |
| ,"x":4,"y":5},{" | 8922e4617b39d6b06abdbb05c8b1a619 |
| item":1,"x":5,"y | 5656e0d6c9f584984e11a2397e28bdd3 |
| ":5},{"item":1," | c19796e2c753f799143f2f5e314ff561 |
| x":6,"y":5},{"it | 90fdf086bfd8d8eb1f8076079eb2d15c |
| em":0,"x":15,"y" | e3dd02803671e9359bef00fbc4936c1e |
| :1}]}{padding} | d38069bc2385c77065fa5fe3781739a1 |
Which we'll have 8 keys!
We can modify the cookie via document.cookie="game-token=<cookie_value_here>".
Modified cookie:
document.cookie="game-token=aca1fae0ede7f469260b38ab325fc0a75762c9742bf323de4593eee85eca5067f90d10d66852d845107a087525c489188c1842fb0df889fd6ed5416f7507a0b042924e9de696556ccd87cd2fc3521bb5a3b6abc3b51b9451c9830d89b581440cf7e8419ca8f122e0493ddfc2135236858922e4617b39d6b06abdbb05c8b1a6195656e0d6c9f584984e11a2397e28bdd3c19796e2c753f799143f2f5e314ff56190fdf086bfd8d8eb1f8076079eb2d15ce3dd02803671e9359bef00fbc4936c1ed38069bc2385c77065fa5fe3781739a1"
Let's fire up the developer console and change our cookie!

Now, if we move once, we should see 8 keys in our inventory!

Yes!! Let's unlock all the doors and get the flag!

We got the flag!
Conclusion
What we've learned:
- Cut-and-Paste Attack in AES ECB Mode