This was a very charming level, although it was definitely the easiest one in the CTF. I like how the challenge author designed a logo, mascot, and even a soundtrack for this fictional payment service.
JS Code
One of the first things I did was upload a random file and try sending it, and check the chrome devtools network tab to see what kind of request was being sent. To my surprise, there was no request at all. Taking a look at the javascript code, it becomes clear that checks are being done client side:
document.addEventListener("DOMContentLoaded", function () {
...
document
.getElementById("parseButton")
.addEventListener("click", parseFile);
});
async function parseFile() {
const fileInput = document.getElementById("fileInput");
const file = fileInput.files[0];
if (!file) {
alert("Please select a file");
return;
}
const arrayBuffer = await file.arrayBuffer();
const dataView = new DataView(arrayBuffer);
const signature = getString(dataView, 0, 5);
if (signature !== "AGPAY") {
alert("Invalid Card");
return;
}
const version = getString(dataView, 5, 2);
const encryptionKey = new Uint8Array(arrayBuffer.slice(7, 39));
const reserved = new Uint8Array(arrayBuffer.slice(39, 49));
const footerSignature = getString(
dataView,
arrayBuffer.byteLength - 22,
6
);
if (footerSignature !== "ENDAGP") {
alert("Invalid Card");
return;
}
const checksum = new Uint8Array(
arrayBuffer.slice(arrayBuffer.byteLength - 16, arrayBuffer.byteLength)
);
const iv = new Uint8Array(arrayBuffer.slice(49, 65));
const encryptedData = new Uint8Array(
arrayBuffer.slice(65, arrayBuffer.byteLength - 22)
);
const calculatedChecksum = hexToBytes(
SparkMD5.ArrayBuffer.hash(new Uint8Array([...iv, ...encryptedData]))
);
if (!arrayEquals(calculatedChecksum, checksum)) {
alert("Invalid Card");
return;
}
const decryptedData = await decryptData(
encryptedData,
encryptionKey,
iv
);
const cardNumber = getString(decryptedData, 0, 16);
const cardExpiryDate = decryptedData.getUint32(20, false);
const balance = decryptedData.getBigUint64(24, false);
document.getElementById("cardNumber").textContent =
formatCardNumber(cardNumber);
document.getElementById("cardExpiryDate").textContent =
"VALID THRU " + formatDate(new Date(cardExpiryDate * 1000));
document.getElementById("balance").textContent =
"$" + balance.toString();
console.log(balance);
if (balance == 313371337) {
function arrayBufferToBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
const base64CardData = arrayBufferToBase64(arrayBuffer);
const formData = new FormData();
formData.append("data", base64CardData);
try {
const response = await fetch("submit", {
method: "POST",
body: formData,
});
const result = await response.json();
if (result.success) {
alert(result.success);
} else {
alert("Invalid Card");
}
} catch (error) {
alert("Invalid Card");
}
}
}
async function decryptData(encryptedData, key, iv) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
key,
{ name: "AES-CBC" },
false,
["decrypt"]
);
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: "AES-CBC", iv: iv },
cryptoKey,
encryptedData
);
return new DataView(decryptedBuffer);
}
Binary file format
After reading the parseFile()
function, I realised the code is basically parsing a custom binary file format (also each field is stored in big endian format):
5 bytes: header 'AGPAY'
2 bytes: version number
32 bytes: AES (CBC mode) encryption key
10 bytes: reserved section
16 bytes: IV for above AES encryption
32 bytes: AES-encrypted data as follows:
- 16 bytes: card number
- 8 bytes: expiry date
- 8 bytes: card balance
6 bytes: footer 'ENDAGP'
16 bytes: MD5 hash of the IV + encrypted data part
The home page tells us we can "join the agleets", "only for $313371337". So I wrote a python script that generates an alligator pay card with that balance:
Python script
import hashlib from Crypto.Cipher import AES from Crypto.Util.Padding import pad from Crypto.Random import get_random_bytes def encrypt_text(text, key, iv): cipher = AES.new(key, AES.MODE_CBC, iv) padded_text = pad(text, 16) return cipher.encrypt(padded_text) key = b'A' * 32 iv = b'C' * 16 data = b'' data += b'AGPAY' # header signature data += b'\x00\x01' # 2 byte version data += key # 32 byte encryption key data += b'B' * 10 # 10 byte reserved section body = b'' body += iv # 16 byte iv body += encrypt_text( b'1234567890123456' # 16 byte card number + b'\x00\x00\x00\x00h\xc6\xa1\x82' # expiry date (timestamp, big endian) + b'\x00\x00\x00\x00\x12\xad\xaa\xc9', # balance key, iv ) # encrypted data part checksum = hashlib.md5(body).digest() assert len(checksum) == 16 data += body data += b'ENDAGP' # footer signature data += checksum # 16 byte checksum with open('card.agp', 'wb') as f: f.write(data)
card_maker.py
Running the script and uploading card.agp
gives us the flag:
TISC{533_Y4_L4T3R_4LL1G4T0R_a8515a1f7004dbf7d5f704b7305cdc5d}