Source Code Vulnerability Management
Objectives: By the end of this topic, you will be able to…
- Detect common vulnerabilities in real source code
- Use static analysis tools to automate code review
- Apply critical thinking to interpret and validate findings
- Exploit confirmed vulnerabilities to demonstrate their real-world impact
- Propose secure improvements to the code and verify they eliminate the attack vector
What is a vulnerability in code?
A source code vulnerability is a weakness in the logic or implementation of software that can be exploited to compromise its confidentiality, integrity, or availability. These flaws may allow an attacker to execute malicious code, access sensitive information, or alter the system’s behavior.
Question
Before continuing: a bug lets an attacker read another user’s session token by requesting it directly. Which leg of the CIA triad does that violate? Would your answer change if the bug let them overwrite the token instead of just reading it?
Main causes of vulnerabilities
Logical errors occur when the program’s logic does not account for all scenarios, such as checking that a user is authenticated without also verifying their role before performing a sensitive action. These flaws require careful reading of intent and are often missed by automated tools.
Lack of input validation allows uncontrolled user data to reach critical functions, causing injections, logic failures, or leaks. The most common example is constructing SQL queries by concatenating strings:
# Vulnerable: attacker supplies username = "' OR '1'='1" to dump the whole table
query = "SELECT * FROM users WHERE username = '" + username + "'"
cursor.execute(query)
# Safe: parameterized query — the database never interprets user input as SQL
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))The same failure, untrusted input reaching a context that interprets it instead of treating it as inert data, is what makes cross-site scripting (XSS) possible when that interpreting context is a web page’s HTML rather than a SQL query: injected input becomes injected script. XSS’s variants and defenses are covered in full in class 14; the underlying cause is the one just described.
Command and code injection via dynamic execution happens when a program hands attacker-controlled input to a system shell or to the language’s own code evaluator, instead of treating it as inert data. Passing a hostname straight into a shell command lets an attacker chain a second command onto the first using a shell metacharacter such as ;, &&, or |:
# Vulnerable: the shell splits on ';' before the ping command ever runs
os.system(f"ping -c 1 {host}")
# host = "127.0.0.1; whoami" runs ping, then whoami, in the same shell
# Safe: no shell is invoked, so metacharacters are inert
subprocess.run(["ping", "-c", "1", host])The same pattern shows up whenever a language feature evaluates a string as code. Python’s eval() and exec(), and their equivalents in most scripting languages, execute their argument with the full privileges of the running program:
# Vulnerable: eval() runs whatever expression the user supplies
result = eval(user_expression)
# user_expression = "__import__('os').system('id')" runs a shell command
# Safe: ast.literal_eval only parses Python literals (numbers, strings, lists); it never calls anything
result = ast.literal_eval(user_expression)Both failures share one root cause: user input reached a function whose job is to execute, not to store or compare. The fix isn’t to sanitize the string harder: it’s to stop giving the shell or the interpreter a string to interpret at all.
Example
subprocess.run(["ping", "-c", "1", host])andos.system(f"ping -c 1 {host}")both ping a host. Only one of them is safe whenhostcomes from a user. Before continuing: which one, and what does the other one let an attacker do that a single ping command could not?
Use of insecure functions introduces risk because some standard library functions perform no bounds checking. The classic case in C is strcpy, which writes until it hits a null byte regardless of the destination buffer size:
/* Vulnerable: overwrites adjacent memory if src is longer than 63 chars */
char dest[64];
strcpy(dest, src);
/* Safe: limits the copy to n-1 bytes */
strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';Insecure deserialization occurs when a program reconstructs an object from untrusted bytes without restricting what that reconstruction is allowed to do. Python’s pickle module does not just restore data: it can call arbitrary code during that restoration, because a pickled object can define a __reduce__ method that returns a callable to invoke when the object is loaded:
# An attacker crafts an object whose __reduce__ tells pickle what to run on load
class Exploit:
def __reduce__(self):
return (os.system, ("id",))
payload = pickle.dumps(Exploit())
# Wherever this payload is later unpickled, os.system("id") executes:
# the victim process never chose to run a command; deserializing the bytes did
# Safe: use a data-only format. json represents the same dicts, lists, strings
# and numbers, but has no mechanism to invoke code while parsing them
data = json.loads(payload)The lesson generalizes past pickle: any deserialization format able to represent “call this function with these arguments” is unsafe to run against data you did not produce yourself, regardless of language. A format that can only represent inert data limits an attacker who controls its bytes to controlling the data your program sees, not the code it executes.
Question
pickle.loads()andjson.loads()both turn a byte string back into a Python object. Only one of them can be made to run a command as a side effect of parsing. Before continuing: which one, and is the difference about what data the format can represent, or about how it reconstructs that data?
Insecure credential management exposes secrets by embedding them directly in source code, where they end up in version control and are visible to anyone with repository access:
# Vulnerable: secret committed to the repository
DB_PASSWORD = "hunter2"
# Safe: read from the environment at runtime — never stored in code
import os
DB_PASSWORD = os.environ.get("DB_PASSWORD")Access control errors arise when code confuses authentication (confirming who the user is) with authorization (confirming what they are allowed to do). A common mistake is fetching a resource by a user-supplied ID without checking that the requester owns it. This exact failure, an attacker changing an identifier in a request to reach another user’s data, is common enough to have its own name in the OWASP taxonomy: an Insecure Direct Object Reference, or IDOR. For example:
# Vulnerable: any logged-in user can read any record by guessing its ID
def get_record(user_id, record_id):
return db.query("SELECT * FROM records WHERE id = ?", (record_id,))
# Safe: verify ownership before returning data
def get_record(user_id, record_id):
record = db.query("SELECT * FROM records WHERE id = ?", (record_id,))
if record.owner_id != user_id:
raise PermissionError("Access denied")
return recordRelevant OWASP Top 10 categories
The OWASP Top 10 lists the most critical web application vulnerabilities. Those most related to source code:
| Category | Description | Example |
|---|---|---|
| A01 — Broken Access Control | Unauthorized access to functions or data | IDOR (Insecure Direct Object Reference) |
| A03 — Injection | SQL, OS command, or other injection | Inadequate input validation (SQLi, XSS) |
| A07 — Auth Failures | Authentication or session management failures | Token reuse, weak passwords |
Example
A login form trusts a client-supplied
role=adminparameter instead of checking the session on the server, and a separate endpoint builds a SQL query by concatenating a user-supplied username directly into the query text. Before continuing: which category above covers each one, and which would you flag first if you only had time to inspect a single finding?
Static vs dynamic analysis
SAST (Static Application Security Testing)
SAST is performed without executing the program: it examines source or binary code directly, looking for risky patterns such as injections, information leaks, input validation errors, and insecure functions. Its main advantage is that it integrates naturally into CI/CD pipelines, catching flaws early when remediation is cheap. Its limitation is that it can generate false positives and cannot verify behavior that only emerges at runtime.
Dynamic analysis (DAST or IAST)
Dynamic analysis runs the application and observes its actual behavior, which makes it useful for detecting vulnerabilities that only manifest at runtime, such as race conditions or logic flaws that depend on application state. It complements static analysis rather than replacing it.
Question
A SAST tool scans a function that builds a SQL query by string concatenation and flags it. In this codebase, that function only ever receives values already validated and cast to integers earlier in the call chain. Is the finding a false positive from SAST’s perspective? From DAST’s perspective? Which analysis type would actually exercise this function against untrusted input if you ran it?
SAST tools
| Tool | Language | Description |
|---|---|---|
Bandit | Python | Reviews code for insecure practices |
Semgrep | Multi-language | Lightweight, customizable pattern detection |
Flawfinder | C/C++ | Classic tool for insecure function detection |
SonarQube | Multi-language | Supports custom rules, quality + security |
CodeQL | Multi-language | Complex pattern queries on code (GitHub) |
Brakeman | Ruby on Rails | Analysis for Rails applications |
ESLint + Security Plugins | JavaScript/TypeScript | Security-focused linting |
These tools are often integrated into CI/CD pipelines to automatically scan code on each commit or pull request.
Reading a scanner’s output
A static analyzer’s report is a starting hypothesis, not a verdict, and reading it correctly means recognizing what each field is telling you. Bandit labels every finding with a rule ID (B608 for a SQL query built by string concatenation, B307 for a call to eval, B301 for use of pickle, B311 for a non-cryptographic random generator used where security matters, among others) plus two independent ratings: severity, how bad the underlying issue would be if it is real, and confidence, how sure Bandit is that the pattern it matched is actually that issue and not a look-alike. A HIGH-severity, LOW-confidence finding is worth a quick look; a HIGH-severity, HIGH-confidence one is worth stopping for. Semgrep reports the same idea with different vocabulary: each match carries a rule ID describing the check that fired, a message explaining what it found, and a severity of ERROR, WARNING, or INFO. Neither tool executes your code to reach these conclusions, so both can flag a pattern that never actually receives untrusted input, which is exactly why the triage workflow in the next section exists.
Example
A scanner reports rule
B608, severity HIGH, confidence LOW, on a line that builds a query string with+. Before continuing: what does the LOW confidence tell you that the HIGH severity does not, and what would you check in the surrounding code before deciding whether this line ever reaches user input at all?
Interpreting and validating findings
Not all findings represent a real risk: the first step is to prioritize by severity and context, evaluating whether the vulnerable code is actually reachable by untrusted input. Next, assess reproducibility: can the finding be exploited, and under what conditions? Tracing the data flow from input to the vulnerable function helps confirm whether validation is truly absent. When a genuine flaw is confirmed, apply secure coding principles to fix it without breaking surrounding logic. Finally, document every finding thoroughly so it can be reviewed, corrected, and used as a learning reference by the team.
Question
Before continuing: put the section’s four checks, prioritize by severity/reachability, assess reproducibility, trace data flow, document, in the order you would actually run them against a fresh scanner report, and explain why reachability comes before you spend time trying to reproduce anything.
Hands-on lab
Requirements: Kali Linux,
bandit,semgrep, Node.js
This lab is done in pairs with two roles: the Assessor, who scans and exploits each vulnerability across both parts and records exactly what’s confirmed, and the Remediator, who patches the code using the Assessor’s record and verifies the fix. Agree on who takes which role before Part 1; the two of you switch tasks, not machines, at the boundary between each part’s Exploit and Patch steps. Part 0 and the closing reflection are joint work for both of you.
Part 0: Setting up the lab
# Install pipx
sudo apt install pipx
# Install scanner tools
pipx install bandit
pipx install semgrepCreate a Semgrep account at semgrep.dev using your GitHub account.
Part 1: Python CLI tool
You are given insecure_script.py, a small Python backend utility. Your workflow for this part is: scan → exploit → patch → verify.
Step 1 — Scan (Assessor). Run Bandit and save the full report:
bandit -r insecure_script.py 2>&1 | tee bandit_before.txtFor every HIGH and MEDIUM severity finding, record the line number, the Bandit rule ID, and what you think the risk is — before reading the code in depth.
Step 2 — Exploit (Assessor). Run the script and work through each prompt. Do not change any code yet. Your goal is to confirm which findings represent real, triggerable vulnerabilities.
python3 insecure_script.pyUser lookup — SQL injection:
At the username prompt, enter ' OR '1'='1' --. Count the records returned and compare them to what a legitimate user should see. What did the injected condition do to the WHERE clause?
Network ping — command injection:
Enter 127.0.0.1; whoami at the host prompt. The semicolon ends the ping command and starts a new shell command. Then try 127.0.0.1; cat /etc/passwd to read a system file. Screenshot the output.
Calculator — arbitrary code execution via eval:
Enter __import__('os').system('id'). The function receives your input as a string and executes it as Python code. Record what the output reveals about the process running the script.
Session loader — pickle deserialization RCE: Pickle can encode arbitrary Python objects, including ones that run a system command when deserialized. In a separate terminal, generate a malicious payload:
python3 -c "
import pickle, os, base64
class Exploit(object):
def __reduce__(self):
return (os.system, ('id',))
print(base64.b64encode(pickle.dumps(Exploit())).decode())
"Paste the output at the session loader prompt and observe the command execute before load_session returns.
One-time tokens — insecure randomness:
The script prints five tokens generated by random.randint. Note the approximate time. Then run the following to reproduce the same sequence:
python3 -c "
import random, time
random.seed(int(time.time()))
for _ in range(5):
print(random.randint(100000, 999999))
"random is seeded by the system clock, so an attacker who knows the generation time can predict every token issued during that second.
Hardcoded credentials — static finding: The remaining Bandit findings flag constants assigned at module level. No runtime interaction is needed — their presence in the source file means they will appear in every git commit, log, and deployment artefact. Document what each credential controls and what an attacker could do with it.
For every finding above, add one entry to your findings record: the line/rule ID, whether you confirmed it as a real, triggerable vulnerability, the exact input that triggers it, and the evidence you captured. Hand this record to the Remediator now: Step 3 works from it, not from a fresh reading of the Bandit report.
Step 3 — Patch (Remediator). Working from the Assessor’s findings record, fix every HIGH and MEDIUM finding it confirms real. Use the table below as a guide, then write the secure version yourself:
| Vulnerability | Secure pattern |
|---|---|
| SQL injection | Parameterized queries: cursor.execute(query, (param,)) |
| Command injection | Pass arguments as a list, no shell=True: subprocess.check_output(["ping", "-c", "1", host]) |
eval / exec | Remove or replace; use ast.literal_eval only for safe literal parsing |
| Pickle deserialization | Use json for untrusted data |
| Hardcoded credentials | os.environ.get("VAR_NAME") — never store secrets in source code |
| Insecure random | secrets.token_hex() or secrets.randbelow() for security-sensitive values |
Add a short comment to every line you change explaining what you fixed and why.
Step 4 — Verify (joint). Remediator: hand the patched file back to the Assessor. Assessor: working from your validated findings record, re-attempt every exploit against the patched code, using the same input you used the first time. For each one, tell the Remediator whether it now fails; if one still succeeds, describe exactly what got through so the Remediator can fix it without re-deriving your Step 2 work. Once every exploit on the record fails cleanly, run Bandit together and confirm the report is clear:
bandit -r insecure_script.py 2>&1 | tee bandit_after.txt
python3 insecure_script.pyQuestion
Which finding was hardest to exploit — and which was hardest to patch correctly? Did exploiting them change the order in which you prioritized the fixes?
Part 2: Node.js web application
You are given a small Express application in nodejs-app/. Your workflow is the same: scan → exploit → patch → verify.
Step 1 — Deploy and scan (Assessor).
cd nodejs-app
npm install
npm start & # runs on http://localhost:3000
semgrep --config=auto app.js 2>&1 | tee semgrep_before.txtFor each Semgrep finding, record the line, the rule ID, the affected endpoint, and your hypothesis about how it could be exploited.
Step 2 — Exploit (Assessor). With the server running, open http://localhost:3000/ — the app’s index page links to every endpoint below through a plain form, so you never need to hand-craft a request. Attack every endpoint. Screenshot or save the output of each successful exploit before changing any code.
Hardcoded secrets — static finding:
Locate the constants Semgrep flags near the top of app.js. Describe what an attacker who reads the source (or who obtains a leaked build artefact) could do with each value.
Reflected XSS — GET /hello:
On the index page, enter the following into the Greeting field and submit:
<script>alert(document.cookie)</script>
Observe the script execute. Now craft a payload that exfiltrates the page’s cookies to an external endpoint — use Webhook.site to receive the request and confirm the data arrived.
SQL injection — GET /user:
In the User lookup field, try each of the following in turn:
' OR '1'='1
admin'--
The first should return every row in the users table; the second should target the admin record specifically. Compare what you receive to what the endpoint is supposed to return for a normal request.
Path traversal — GET /file:
In the File viewer field, try:
../../../../../../../../../../../../../../../etc/passwd
../../../../../../../../../../../../../../../etc/hostname
(How many ../ you actually need depends on how deep nodejs-app/ sits on disk; a generous number is harmless; once you go above the filesystem root, extra ../ segments have no further effect.) How far outside the intended directory can you navigate? What limits you?
Command injection — GET /ping:
In the Ping field, try:
127.0.0.1; id
127.0.0.1; cat /etc/passwd
IDOR — GET /note:
In the Note viewer field, enter 1 and submit.
No authentication is required. What does this mean for a multi-user application where notes are supposed to be private?
Credentials in URL — GET /login:
In the Login form, enter admin / admin123 and submit. Look at the address bar of the tab that opens.
Switch to the terminal running npm start and find the request in the access log. Explain why GET parameters are a poor choice for credentials even when HTTPS is in use.
eval RCE — GET /calc:
In the Calculator field, try:
require('child_process').execSync('id').toString()
require('fs').readFileSync('/etc/passwd','utf8')
Missing security headers:
curl -I http://localhost:3000/helloNote which headers are absent. Look up what each missing header protects against and how the reflected XSS exploit from earlier could be partially mitigated by a correct Content-Security-Policy.
For each finding above, add one entry to your findings record: the line/rule ID or endpoint, whether you confirmed it as a real, triggerable vulnerability, the exact request that triggers it, and the evidence you captured. Hand this record to the Remediator now: Step 3 works from it, not from a fresh reading of the Semgrep report.
Step 3 — Patch (Remediator). Working from the Assessor’s findings record, fix every finding it confirms real in app.js:
| Vulnerability | Secure pattern |
|---|---|
| Hardcoded secrets | process.env.VAR_NAME; document required variables in a .env.example file |
| Reflected XSS | Escape user input before inserting it into HTML; add a Content-Security-Policy header |
| SQL injection | Parameterized queries using ? placeholders: db.get(query, [param], callback) |
| Path traversal | path.resolve() the final path and assert it starts with the allowed directory |
| Command injection | Use execFile with an argument array instead of exec with a shell string |
| IDOR | Require authentication; verify the authenticated user owns the requested resource |
| Credentials in URL | Accept credentials via POST body only; never read passwords from query parameters |
eval RCE | Remove the endpoint; if a calculator is needed, use a safe math parser library |
| Missing headers | app.use(require('helmet')()) as the first middleware |
Step 4 — Verify (joint). Remediator: stop the server, restart it with the patched app.js, and hand it back to the Assessor.
pkill -f "node app.js"
npm start &Assessor: working from your validated findings record, resubmit every form and browser request from Step 2 against the patched server, using the same input you used the first time. For each one, tell the Remediator whether it’s now rejected with an appropriate error or returns sanitized output; if one still succeeds, describe exactly what got through so the Remediator can fix it without re-deriving your Step 2 work. Capture a screenshot for each one. Once every exploit on the record is blocked, run Semgrep together and confirm the report is clear:
semgrep --config=auto app.js 2>&1 | tee semgrep_after.txtQuestion
Which vulnerability in the web application had the largest gap between how simple it was to exploit and how much damage it could cause in a real deployment? How did your answer change after you ran the exploit versus when you only read the Semgrep finding?
Cleanup
pkill -f "node app.js"Submission
Compressed folder including:
bandit_before.txtandbandit_after.txtsemgrep_before.txtandsemgrep_after.txt- Terminal output or screenshots for every successful exploit (before patching)
- Patched
insecure_script.pyandapp.jswith explanatory comments on every changed line - Written reflection (max. 1 page): which vulnerability surprised you most once you exploited it, and why
Key concepts
| Term | Definition |
|---|---|
| SAST | Source code analysis without executing the application |
| Vulnerability | Exploitable weakness in a system or application |
| SQLi | Injection of malicious SQL code into input fields |
| XSS | Injection of malicious scripts into web pages |
| Bandit | SAST tool for Python code |
| Semgrep | Multi-language and customizable static analyzer |
| OWASP Top 10 | List of the ten most critical web vulnerabilities |