Introduction

On a recent project we gained the ability to specify environment variables but not the process that was executed. We were also unable to control the contents of a file on disk, and bruteforcing process identifiers (PIDs) and file descriptors found no interesting results, eliminating remote LD_PRELOAD exploitation. Fortunately, a scripting language interpreter was executed which enabled us to execute arbitrary commands by specifying particular environment variables. This blog post discusses how arbitrary commands can be executed by a range of scripting language interpreters when supplied with malicious environment variables.

Perl

A quick read of the ENVIRONMENT section of the perlrun(1) man page reveals plenty of environment variables worth investigating. The PERL5OPT environment variable allows specifying command-line options, but is restricted to only accepting the options CDIMTUWdmtw. This unfortunately means that -e, which allows supplying perl code to run, is out.

All is not lost though, as demonstrated in the exploit for CVE-2016-1531 by Hacker Fantastic. The exploit writes a malicious perl module to /tmp/root.pm and supplies the environment variables PERL5OPT=-Mroot and PERL5LIB=/tmp to achieve arbitrary code execution. However this was an exploit for a local privilege escalation vulnerability and a generic technique should ideally not require access to the file system. Looking at blasty’s exploit for the same CVE, the exploit did not require creating a file and used the environment variables PERL5OPT=-d and PERL5DB=system("sh");exit;. The same environment variables were also used to solve a CTF challenge in 2013.

One final nicety of a generic technique would be to use a single environment variable instead of two. @justinsteven found this was possible by leveraging PERL5OPT=-M. While either -m or -M can be used to load a perl module, the -M option allows adding extra code after the module name.

Proof of Concept

Figure-0: arbitrary code execution achieved using an environment variable against perl running an empty script (/dev/null)
$ docker run --env 'PERL5OPT=-Mbase;print(`id`)' perl:5.30.2 perl /dev/null
uid=0(root) gid=0(root) groups=0(root)

Python

Reading the ENVIRONMENT VARIABLES section of the python(1) man page, PYTHONSTARTUP initially appears like it may be a piece of a straightforward solution. It allows specifying a path to a Python script that will be executed prior to displaying the prompt in interactive mode. The interactive mode requirement didn’t seem like it would be an issue as the PYTHONINSPECT environment variable can be used to enter interactive mode, the same as specifying -i on the command line. However, the documentation for the -i option explains that PYTHONSTARTUP will not be used when python is started with a script to execute. This means that PYTHONSTARTUP and PYTHONINSPECT cannot be combined and PYTHONSTARTUP only has an effect when the python REPL is immediately launched. This ultimately means that PYTHONSTARTUP is not viable as it has no effect when executing a regular Python script.

Other environment variables which looked promising were PYTHONHOME and PYTHONPATH. Both of these will let you gain arbitrary code execution but require you to also be able to create directories and files on the filesystem. It may be possible to loosen those requirements through the use of the proc filesystem and/or ZIP files.

The majority of the remaining environment variables are simply checked if they contain a non-empty string, and if so, toggle a generally benign setting. One of the rare exceptions to this is PYTHONWARNINGS.

Making progress with PYTHONWARNINGS

The documentation for PYTHONWARNINGS states it is equivalent to specifying the -W option. The -W option is used for warning control to specify which warnings and how often they are printed. The full form of argument is action:message:category:module:line. While warning control didn’t seem like a promising lead, that quickly changed after checking the implementation.

Figure-1: Python-3.8.2/Lib/warnings.py
[...]
def _getcategory(category):
    if not category:
        return Warning
    if '.' not in category:
        import builtins as m
        klass = category
    else:
        module, _, klass = category.rpartition('.')
        try:
            m = __import__(module, None, None, [klass])
        except ImportError:
            raise _OptionError("invalid module name: %r" % (module,)) from None
[...]

The above code shows that as long as our specified category contains a dot, we can trigger the import an arbitrary Python module.

The next problem is that the vast majority of modules from Python’s standard library run very little code when imported. They tend to just define classes to be used later, and even when they provide code to run, the code is typically guarded with a check of the __main__ variable (to detect if the file has been imported or run directly).

An unexpected exception to this is the antigravity module. The Python developers included an easter egg in 2008 which can be triggered by running import antigravity. This import will immediately open your browser to the xkcd comic that joked that import antigravity in Python would grant you the ability to fly.

As for how the antigravity module opens your browser, it uses another module from the standard library called webbrowser. This module checks your PATH for a large variety of browsers, including mosaic, opera, skipstone, konqueror, chrome, chromium, firefox, links, elinks and lynx. It also accepts an environment variable BROWSER that lets you specify which process should be executed. It is not possible to supply arguments to the process in the environment variable and the xkcd comic URL is the one hard-coded argument for the command.

The ability to turn this into arbitrary code execution depends on what other executables are available on the system.

Leveraging Perl for Arbitrary Code Execution

One approach is to leverage Perl which is commonly installed on systems and is even available in the standard Python docker image. However, the perl binary cannot itself be used. This is because the first and only argument is the xkcd comic URL. The comic URL argument will cause an error and the process to exit without the PERL5OPT environment variable being used.

Figure-2: PERL5OPT having no effect when a URL is passed to perl
$ docker run -e 'PERL5OPT=-Mbase;print(`id`);exit' perl:5.30.2 perl https://xkcd.com/353/
Can't open perl script "https://xkcd.com/353/": No such file or directory

Fortunately, when Perl is available it also common to have the default Perl scripts available, such as perldoc and perlthanks. These scripts will also error and exit with an invalid argument, but the error in this case happens later than the processing of the PERL5OPT environment variable. This means you can leverage the Perl environment variable payload detailed earlier in this blog post.

Figure-3: PERL5OPT working as intended with perldoc and perlthanks
$ docker run -e 'PERL5OPT=-Mbase;print(`id`);exit' perl:5.30.2 perldoc https://xkcd.com/353/
uid=0(root) gid=0(root) groups=0(root)
$ run -e 'PERL5OPT=-Mbase;print(`id`);exit' perl:5.30.2 perlthanks https://xkcd.com/353/
uid=0(root) gid=0(root) groups=0(root)

Proof of Concept

Figure-4: arbitrary code execution achieved using multiple environment variables against Python 2 and Python 3
$ docker run -e 'PYTHONWARNINGS=all:0:antigravity.x:0:0' -e 'BROWSER=perlthanks' -e 'PERL5OPT=-Mbase;print(`id`);exit;' python:2.7.18 python /dev/null
uid=0(root) gid=0(root) groups=0(root)
Invalid -W option ignored: unknown warning category: 'antigravity.x'

$ docker run -e 'PYTHONWARNINGS=all:0:antigravity.x:0:0' -e 'BROWSER=perlthanks' -e 'PERL5OPT=-Mbase;print(`id`);exit;' python:3.8.2 python /dev/null
uid=0(root) gid=0(root) groups=0(root)
Invalid -W option ignored: unknown warning category: 'antigravity.x'

NodeJS

A blog post by Michał Bentkowski provided a payload for exploiting Kibana (CVE-2019-7609). A prototype pollution vulnerability was used to set arbitrary environment variables which resulted in arbitrary command execution. Michał’s payload used the NODE_OPTIONS environment variable and the proc filesystem, specifically /proc/self/environ.

Although Michał’s technique was creative and worked perfectly for their vulnerability, the technique is not always guaranteed to work and has some constraints that would be nice to remove.

The first constraint is that it using /proc/self/environ is only viable if the contents can be made to be syntactically valid JavaScript. This requires being able to create an environment variable and have it appear first in the contents of /proc/self/environ, or knowing/bruteforcing the environment variable’s name that will appear first and overwriting it’s value.

Another constraint, as the first environment variable’s value finishes with a single line comment (//). Therefore, any newline character in other environment variables will likely cause a syntax error and prevent the payload from executing. The use of multi-line comments (/*) will not fix this issue as they must be closed to be syntactically valid. Therefore, in the rare case that an environment variable contains a newline character, it is required to know/bruteforce the environment variable’s name and overwrite it’s value to a new value that does not contain a newline.

Removing these contraints is an exercise left for the reader.

Proof of Concept

Figure-5: achieving arbitrary code execution with environment variables against NodeJS by Michał Bentkowski
$ docker run -e 'NODE_VERSION=console.log(require("child_process").execSync("id").toString());//' -e 'NODE_OPTIONS=--require /proc/self/environ' node:14.2.0 node /dev/null
uid=0(root) gid=0(root) groups=0(root)

PHP

If you run ltrace -e getenv php /dev/null you will find PHP uses the PHPRC environment variable. The environment variable is used when attempting to find and load the configuration file php.ini. An exploit by neex for CVE-2019-11043 used a series of PHP settings to achieve arbitrary code execution. Orange Tsai also has a great blog post on creating their own exploit for the same CVE, which uses a slightly different list of settings. Using this knowledge, plus the knowledge gained from the previous NodeJS technique, and some help from Brendan Scarvell, a two environment variable solution was found for PHP.

The same constraints exist for this technique as the NodeJS examples.

Proof of Concept

Figure-6: achieving arbitrary code execution with environment variables against PHP
$ docker run -e $'HOSTNAME=1;\nauto_prepend_file=/proc/self/environ\n;<?php die(`id`); ?>' -e 'PHPRC=/proc/self/environ' php:7.3 php /dev/null
HOSTNAME=1;
auto_prepend_file=/proc/self/environ
;uid=0(root) gid=0(root) groups=0(root)

Ruby

A generic solution for Ruby has not been found yet. Ruby does accept an environment variable RUBYOPT to specify command-line options. The man page states that RUBYOPT can contain only -d, -E, -I, -K, -r, -T, -U, -v, -w, -W, --debug, --disable-FEATURE and --enable-FEATURE. The most promising option is -r which causes Ruby to load the library using require. However, this is limited to files with an extension of .rb or .so.

An example of a somewhat useful .rb file that has been found is tools/server.rb from the json gem that is available after installing Ruby on Fedora systems. When this file is required, a web server is started as shown below:

Figure-7: Using the RUBYOPT environment variable to cause the ruby process to start a web server
$ docker run -it --env 'RUBYOPT=-r/usr/share/gems/gems/json-2.3.0/tools/server.rb' fedora:33 /bin/bash -c 'dnf install -y ruby 1>/dev/null; ruby /dev/null'
Surf to:
http://27dfc3850fbe:6666
[2020-06-17 05:43:47] INFO  WEBrick 1.6.0
[2020-06-17 05:43:47] INFO  ruby 2.7.1 (2020-03-31) [x86_64-linux]
[2020-06-17 05:43:47] INFO  WEBrick::HTTPServer#start: pid=28 port=6666

Sticking with Fedora, another approach is to leverage the fact that /usr/bin/ruby is actually a Bash script which starts /usr/bin/ruby-mri. The script calls Bash functions which can be overwritten with environment variables.

Proof of Concept

Figure-8: Using an exported Bash function to run an arbitrary command
$ docker run --env 'BASH_FUNC_declare%%=() { id; exit; }' fedora:33 /bin/bash -c 'dnf install ruby -y 1>/dev/null; ruby /dev/null'
uid=0(root) gid=0(root) groups=0(root)

Conclusion

This post has explored interesting use cases of environment variables which could assist in achieving arbitrary code execution against various scripting language interpreters, without writing files to disk. Hopefully you’ve enjoyed reading and are inspired to find and share improved payloads for these and other scripting languages. If you find a generic technique that works against Ruby, we would be very interested in hearing about how you achieved this.

Thanks for reading, ciao Bella!

Latest Posts

Blog A Monocle on Chronicles

By Matt October 02, 2024

This post provides an overview of Talkback Chronicles for viewing snapshots of trending infosec resources for points in time, and also how to subscribe to a new weekly Newsletter feature.

Blog DUCTF 2024 ESPecially Secure Boot Writeup

By daniel August 01, 2024

This blog post covers a DUCTF 2024 pwn challenge called "ESPecially Secure Boot", which required writing an exploit for CVE-2018-18558.

Blog plORMbing your Prisma ORM with Time-based Attacks

By Alex Brown July 08, 2024

Part two of our ORM Leak series about attacking the Prisma ORM and leaking sensitive data in a time-based attack.