July 6, 2026
Learning DevSecOps the Hard Way: My First Hands-On Security Exercise
Breaking, fixing, and automating a vulnerable Azure infrastructure — and what surprised me along the way
By Marianoacostafc
5 min read
I've been studying DevSecOps for several months now. I've read about hardcoded credentials, privilege escalation, injection vulnerabilities, and broken access control. I could explain all of them. I could point to the relevant CWEs. I could describe the attack patterns in reasonable detail.
And then I deployed a deliberately vulnerable Azure infrastructure, tried to actually exploit those patterns, and realized there was a significant gap between being able to explain something and actually understanding it well enough to find it, fix it, and prevent it from coming back.
This is what that experience looked like — the parts that went as expected, and the parts that didn't.
The setup
I'm currently going through Hackademy Ekoparty's DevSecOps Engineering track. One of the core practices in the program is what we call a simulacro — a structured security exercise where you take a vulnerable-by-design lab, run it through a full cycle: attack it, understand why each vulnerability exists at the root cause level, fix it in the actual code, validate that the fix works against the live system, and then automate detection so the same class of problem can't silently reappear.
The methodology has a name: Romper → Entender → Proteger → Automatizar (Break → Understand → Defend → Automate).
Simulacro 1 was AzureGoat, a vulnerable Azure infrastructure from INE Labs that deploys 450+ resources via Terraform — a Linux VM, two Azure Function Apps, a CosmosDB database, and a Storage Account. The kind of architecture you'd see in a small real-world project.
I want to share three specific moments from this exercise where the practice taught me something I genuinely could not have learned any other way.
Moment 1: "Apply complete" doesn't mean what you think it means
About halfway through the exercise, I had finished remediating several code vulnerabilities in the Python backend — a broken authorization check, an unauthenticated database dump endpoint, a NoSQL injection. I packaged the code, ran terraform apply, watched it complete successfully, and went to test the API.
The endpoint I had just deleted was still there. The authorization bypass I had just fixed still worked. Nothing had changed.
I spent a long time confused. The Terraform output was clear: Apply complete! Resources: 2 added, 0 changed, 0 destroyed. No errors. No warnings about the specific thing I was trying to change.
What I eventually discovered: azurerm_storage_blob in Terraform tracks changes based on declared attributes — the resource name, the blob type, the source file path. Not the file content. Since the path to my zip file hadn't changed — only what was inside it — Terraform saw no difference and quietly skipped the upload. The old code was still running in production.
The fix was one line in main.tf:
content_md5 = filemd5("modules/module-1/resources/azure_function/data/data-api.zip")content_md5 = filemd5("modules/module-1/resources/azure_function/data/data-api.zip")This forces Terraform to recalculate the file hash on every plan. When content changes, the hash changes, and Terraform detects the actual difference.
I only found this by downloading the blob that Azure was actually serving — using the exact same signed URL that the Function App uses internally — and checking whether my deleted endpoint was still in the code. It was.
I've read about infrastructure as code. I've watched Terraform tutorials. None of them mentioned this. It's the kind of thing you only discover when you're sitting there genuinely confused, trying to figure out why a system that should be fixed is still broken.
Moment 2: The difference between knowing a vulnerability exists and watching it work
One of the findings in this exercise was a broken access control pattern in the backend API. The server had a function that validated JWT signatures, which is correct. But then, for every privileged operation, it did this:
authLevel = data['authLevel'] # taken from the request body
if authLevel != '0':
return "Requires Admin"authLevel = data['authLevel'] # taken from the request body
if authLevel != '0':
return "Requires Admin"When I read that code, I understood immediately that it was wrong. The server was asking the client "what privilege level do you have?" and trusting the answer. I could have written that down as a finding, assigned it a CWE, marked it as Critical, and moved on.
Instead I exploited it. I logged in with a legitimate low-privilege account, and then called the admin-only change-auth endpoint while including "authLevel": "0" in the request body — declaring myself an admin in the JSON I sent.
curl -X POST .../change-auth \
-H "JWT_TOKEN: [my editor-level token]" \
-d '{"authLevel":"0","email":"another-user@test.com","userAuthLevel":"Reassign as Admin"}'curl -X POST .../change-auth \
-H "JWT_TOKEN: [my editor-level token]" \
-d '{"authLevel":"0","email":"another-user@test.com","userAuthLevel":"Reassign as Admin"}'Response: "User another-user@test.com AuthLevel Updated".
That user now had admin privileges, permanently, in the database. I confirmed it with a follow-up query. I then went further — using the same low-privilege token, I locked out the real administrator account. Then I reversed it. But by that point, I understood the vulnerability at a completely different level than I had when I just read the code.
There's something about watching "User johndoe@gmail.com Banned" appear on your screen — knowing you used an editor account to do that — that makes the concept of "trust the server, not the client" stick in a way that no amount of reading about it ever did.
This same flawed pattern appeared in six different endpoints throughout the codebase. The fix required understanding not just what was wrong, but why a developer might write code this way — and then changing the authorization model across the entire backend, not just in one place.
Moment 3: The tool you built scanned itself
Near the end of the exercise, after completing all the remediations and validating them against the live system, I built a CI/CD pipeline using GitHub Actions. Four jobs running in parallel on every push: Gitleaks for secret scanning, Semgrep for Python SAST, Checkov for Terraform IaC scanning, and terraform validate for syntax checking.
The first run completed. I opened the GitHub Security tab to look at the Semgrep findings, expecting to see results about the application code.
Among the findings: multiple warnings in .github/workflows/devsecops.yml — the pipeline file itself. Semgrep had scanned the CI/CD configuration and flagged that I was using mutable action tags (@v4, @master) instead of pinned commit hashes. It was telling me that the security tool I had just built had a supply chain security issue of its own.
I hadn't planned for that. I hadn't thought to configure Semgrep to skip the workflows directory, so it just… scanned everything. Including its own environment.
That moment encapsulates something important about automated security tooling: you can't fully predict what a scanner will find until you run it against real code. You can read the documentation. You can study the ruleset. But the actual experience of seeing your pipeline audit itself, finding a real issue in the tooling you just wrote, teaches you something about how these systems work that you can't get from theory alone.
What the full cycle looked like
By the end of Simulacro 1, the exercise had covered:
- 9 vulnerabilities found and exploited with reproducible evidence across infrastructure (Terraform, Azure config) and application (Python backend, CosmosDB queries)
- All 9 remediated in actual code and infrastructure, validated against the live system — not just documented as recommendations
- DAST validation with OWASP ZAP before and after remediation, confirming that the scanner's output changed in response to the fixes
- A CI/CD pipeline that now runs on every push, automating detection of the same classes of problems
The technical writeup and all the code — including the remediations applied to main.tf and the backend — are in the GitHub repository.
The thing I keep coming back to
The vulnerabilities in AzureGoat aren't exotic. Hardcoded credentials. Overprivileged identities. Client-controlled authorization. Missing input validation. These are the same patterns that appear in real breach reports, year after year.
I knew about all of them before this exercise. What I didn't have was the experience of finding them in a running system, exploiting them far enough to understand their real impact, fixing the actual code under the actual constraints of the system, and then watching the fix fail because a tool reported success when the underlying thing hadn't actually changed.
That gap — between knowing about something and understanding it well enough to find it, fix it, and prevent it — is what this kind of hands-on practice helped me close. Courses gave me the vocabulary. The simulacro gave me the experience to actually use it.
Simulacro 2 is next: AWSGoat and GCPGoat, a comparative multi-cloud exercise. I'll write about that one too.
Mariano Acosta — DevSecOps Engineering Track, Hackademy Ekoparty GitHub · LinkedIn