MetaCTF - Security Services

Client-side password validation with per-character SHA-512 hashing. The password was recoverable by brute-forcing each character independently against the hash array exposed in the page source.


Challenge

Platform: MetaCTF Category: Web Exploit Points: 50

The challenge linked to a login page for a fictional company called “Security Services - Trusted by thousands of clients!”

Looking at the challenge description, the word “client” appeared repeatedly - “real clients”, “you as the client”, “thousands of real clients”. This was a deliberate hint pointing toward a client-side attack rather than anything server-side.

MetaCTF challenge card for Security Services showing 50 points and solved by 420 teams

Challenge card – 50 points, solved by 420 teams


Reconnaissance

Opening the page source immediately revealed two things.

Security Services login page with username and password fields

The challenge login page

First, the username check was hardcoded:

1if (username === 'client' && await check(password)) {
2    container.innerHTML = '<h1>Security Status</h1><hr><p>you are very secure</p>'
3} else {
Page source showing username hardcoded to the string client in the if statement

Username hardcoded to ‘client’ in the client-side validation logic

Username: client. No guessing needed.

Second, the password validation logic was fully client-side in the check function:

 1const check = async (password) => {
 2    if (password.length !== lmao.length)
 3        return false
 4
 5    const hash = [...password].map((char) =>
 6        shajs('sha512')
 7            .update('SECURITY' + char + 'SECURITY')
 8            .digest('hex')
 9    )
10
11    return hash.every((h, i) => h === lmao[i])
12}

The variable lmao was an array of 61 SHA-512 hashes also defined in the page source, immediately revealing the password length.


Full Source (script.js)

The entire authentication logic was contained in a single script loaded by the page:

 1const lmao = [
 2    'a24bfc1745d525b08684283447f7726466b89816534e48663766e463e98b5c06e0899ce5fea079b7b8025c079a123ca9c68170e68f4742976a3917576743ef9c',
 3    '67a67e62c2b780aa3ad8d4989d2a6c01e3b2a3546b3f35127b8a8dc5ec849749e976edbc66ce8e5c4d8ce97fbae28a0b2abf72e4d2b0a478a80e014d26e53dc0',
 4    '7f4f3329c56608986ecf1fa51605553c05c63ee545340429e7caf54941df8fc866cc81ce2d3a406ec69d69acaf9c39c373407df398e4a4fc7126224d202fc7b6',
 5    // ... 61 hashes total
 6    '9e8eb155afd46416ef8c2bd764c91e184cf12f1bcde15ba1d7fcc41662b321277416803f3e8aa9223fcb7e4e6560e4a7c9e9c8d1c2f2abe0eb4ca85ad92b6817',
 7]
 8
 9const container = document.querySelector('.container')
10const form = document.querySelector('#login')
11
12const check = async (password) => {
13    if (password.length !== lmao.length) return false
14    const hash = [...password].map((char) => shajs('sha512').update('SECURITY' + char + 'SECURITY').digest('hex'))
15    return hash.every((h, i) => h === lmao[i])
16}
17
18form.addEventListener('submit', async (e) => {
19    e.preventDefault()
20    const formData = new FormData(form)
21    const username = formData.get('username')
22    const password = formData.get('password')
23
24    if (username === 'client' && await check(password)) {
25        container.innerHTML = '<h1>Security Status</h1><hr><p>you are very secure</p>'
26    } else {
27        container.classList.remove('error')
28        container.offsetWidth
29        container.classList.add('error')
30    }
31})

Everything needed to recover the flag is here: the hash array, the hashing scheme, and the username. No server request is ever made - the submit handler runs entirely in the browser.


Vulnerability

The validation scheme hashes each character of the password individually using the pattern:

sha512("SECURITY" + char + "SECURITY")

Then it compares each resulting hash against the corresponding entry in lmao[] position by position.

This is fundamentally broken. Because each character is hashed independently and the hash array is exposed client-side, the password can be cracked one character at a time. There is no interaction between characters, no salt per position, and no server-side verification - the entire secret is derivable from the page source alone.


Bypass Attempt

Before cracking the password, a quick bypass was tried by submitting with an empty or arbitrary password. The page returned the success message, but the flag was not displayed - it appeared the actual flag was the password itself.

Browser console showing the login bypass succeeded with the security status message displayed

Login bypass worked, but the flag was not here – the password itself was the flag


Cracking the Password

Since each character hashes independently, the crack script just iterates through a charset, hashes each candidate against the known format, and compares to lmao[i] for each position:

 1const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_!@#$%^&*()-=+[]";
 2
 3async function crack() {
 4    let password = "";
 5
 6    for (let i = 0; i < lmao.length; i++) {
 7        for (let c of chars) {
 8            const h = shajs('sha512')
 9                .update('SECURITY' + c + 'SECURITY')
10                .digest('hex');
11
12            if (h === lmao[i]) {
13                password += c;
14                console.log(`Found char ${i}: ${c}`);
15                break;
16            }
17        }
18    }
19
20    console.log("Password:", password);
21}
22
23crack();

This was run directly in the browser console on the challenge page, where lmao and shajs were already in scope.


Result

Password: MetaCTF{[redacted]}
Browser console output showing the crack function recovering the full password character by character

crack() running in the browser console – password recovered in full

MetaCTF challenge page showing the challenge marked as solved

Challenge marked solved after submitting the flag


Takeaways

  • Never perform authentication client-side. Any logic running in the browser is fully visible and controllable by the user.
  • Hashing characters individually destroys the entropy of the full password - it reduces the problem from cracking a full hash to cracking N single-character hashes independently.
  • Exposing the hash array in the page source is equivalent to exposing the password.