Securing the Industrial AWS IoT Stack, Part 2: One Stolen Device, the Whole Fleet

In Part 1 a single copied certificate let an attacker walk straight into AWS IoT Core, because the certificate proved identity while the policy behind it decided the blast radius. We fixed that by scoping the IoT policy down to one thing. This post picks up where that left off, and moves one layer deeper into the cloud. The device no longer just publishes MQTT. It now holds real AWS credentials. And the thing that decides how far those credentials reach is not the certificate and not the IoT policy. It is an IAM role.

I will build the exact setup many fleets run in production, wire it up so a device can pull temporary AWS credentials with its certificate, and then take over one station and see how far a single compromised device gets. Spoiler for anyone who read Part 1: the mechanism is fine again. The scope is the problem again.

Where Part 1 left off

We have a Raspberry Pi acting as an industrial edge gateway for a gas pressure reduction station. It runs as the thing pi4-edge-01, it carries an X.509 certificate, and it publishes process telemetry to AWS IoT Core on scada/station-07/process. In this lab the values are real. They come from a Siemens S7-1500 over OPC UA, and the Pi forwards them into the cloud. A second station, station-12, sits next to it on the same pipeline.

That is enough for a dashboard. It is not enough for a real fleet. Real edge devices do more than publish messages.

Siemens TIA Portal live view of the values

Lab setup and theory: when a device needs real AWS credentials

Sooner or later an edge device has to talk to the rest of AWS. It has to push a batch of readings into an S3 data lake, pull a shared configuration file, or download a firmware image. MQTT does not do that. For those actions the device needs actual AWS credentials.

The clean way to hand a device credentials is the AWS IoT Credentials Provider. The device presents the certificate it already has, and in return it receives short lived IAM credentials. No static access keys are ever written to the device. This is the recommended pattern, and I want to be clear that using it is correct. The mechanism is not the vulnerability.

Three pieces make it work. An IAM role that the credentials will belong to, with a trust policy that only lets the IoT Credentials Provider assume it. A role alias, which is the name the device uses so it never has to know the underlying role. And an IoT policy on the certificate that grants the single action iot:AssumeRoleWithCertificate. That grant is intentional and part of normal operation.

The vulnerability is none of that. The vulnerability is what the role is allowed to do once assumed.

Here is the role permission this lab uses on purpose. It is the IAM equivalent of the iot:* on * policy from Part 1.

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": "s3:*", "Resource": "*" }
  ]
}
IAM Role-Policy

Every station in the fleet assumes this same role. So every station can do anything to any S3 object in the account. Worth saying plainly, because it is the whole point of this post: a role with s3:GetObject on the entire bucket would already be the same class of mistake. The star is not what makes it dangerous. The missing restriction to the device's own station is what makes it dangerous.

The data lake looks like what you would expect. One prefix per station, plus shared configuration and a firmware image.

s3://iot-lab-data-<account-id>/station-07/latest.json
s3://iot-lab-data-<account-id>/station-12/latest.json
s3://iot-lab-data-<account-id>/configs/global.conf
s3://iot-lab-data-<account-id>/firmware/firmware.bin

station-07 legitimately writes into station-07/. Everything else belongs to someone else.

The legitimate path

In normal operation the device pulls credentials with its certificate and writes into its own prefix. The request goes to the credential endpoint, which is a different endpoint from the data endpoint used for MQTT.

CREDS=$(curl -s \
  --cert ~/certs/device.pem.crt \
  --key ~/certs/private.pem.key \
  --cacert ~/certs/AmazonRootCA1.pem \
  https://<cred-endpoint>/role-aliases/iot-lab-role-alias/credentials)

Feed the result into the standard AWS environment variables and check who you have become.

aws sts get-caller-identity
"Arn": "arn:aws:sts::<account-id>:assumed-role/iot-lab-device-role/<cert-id>"

That assumed role identity is the important part. The session name is the certificate ID, which means every action this device takes can later be traced back to exactly this certificate. Hold that thought, it matters at the end.

Pulling temporary credentials

The legitimate upload works, and it stays inside the station's own prefix.

aws s3 cp reading.txt s3://iot-lab-data-<account-id>/station-07/reading.txt

This is the intended behavior. station-07 writes to station-07. Now let us break it in part 2.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert