Safe Penetration Testing Labs · beginner · ~12 min
What you will learn: - Model a single security finding as a C `struct` (`finding_t`) with the fields a real report needs. - Render that struct into a clean, skimmable Markdown section using **bounded** formatting (`snprintf`), never `sprintf`. - Detect and safely reject output that would overflow the destination buffer, always leaving the result NUL-terminated. - Apply the professional vulnerability-finding template (title, severity, affected component, evidence, impact, likelihood, remediation, retest) and understand that severity depends on exploitability and impact, not opinion. - Sort findings worst-first by mapping a severity label to a numeric rank. - Handle report output as attacker-influenced data: log safely, avoid leaking secrets into the report, and understand what a report can and cannot claim about security.
Security objective: the asset you are protecting here is the integrity and safety of your own report pipeline. A penetration-test report is the deliverable a client acts on. If the tool that builds it can be crashed or corrupted by long, hostile field values (an evidence string copied straight from a target response, for example), the report becomes unreliable — and a buffer overflow in your own tooling is exactly the kind of bug you are being paid to find in others. The threat is untrusted text flowing into a fixed-size buffer; the learner will detect and prevent that overflow, and will produce output that renders correctly in any Markdown viewer.
This lesson builds directly on C strings (you already know a C string is a char array ending in a hidden '\0' NUL byte, and that its length excludes that byte) and on The main function (you know how int main returns a status code and how a program's entry point drives the work). Here you combine both: a main that feeds structured data into a string formatter and checks its return value.
Where does this fit? After you run an authorized test — parsing an auth log, reading a pcap record, probing a lab web app — you have raw notes. A report formatter turns one note into one Markdown block. Many blocks, sorted worst-first, become a deliverable a human can read and a machine can parse. This is the reporting stage of the engagement, and it is where careless string handling most often bites.
In authorized, professional security work the report is the product. Clients rarely watch you test; they read what you deliver. Three things make a report trustworthy, and all three are touched by this lesson:
Every mature security team standardizes on a finding template and a machine-readable format precisely so reports are comparable across testers and across time. Learning to emit that format safely in C is a small, real slice of that discipline.
finding_t struct — one record, grouped fieldsDefinition: a struct groups related values under one name. finding_t holds the fields of a single finding.
Plain explanation: instead of juggling five loose strings, you pass one pointer to a record. The formatter reads f->title, f->severity, and so on.
How it works: each field is a const char * — a pointer to a read-only C string the formatter only reads, never modifies. const documents that promise and lets the compiler catch accidental writes.
When / when not: use a struct when fields travel together and have one lifetime. Don't over-nest; a flat record of strings is perfect for a report row.
Pitfall: the struct only stores pointers. The actual text must outlive the struct. A pointer to a local buffer that has gone out of scope is a dangling read.
snprintfDefinition: snprintf(out, cap, fmt, ...) formats into out, writing at most cap bytes including the NUL terminator.
Plain explanation: it is sprintf with a seatbelt. You tell it how big the buffer is, and it never writes past that.
How it works: it returns the number of characters it would have written had space been unlimited (excluding the NUL). So if the return value is >= cap, the text was truncated — that is your overflow signal. If it is < cap, everything fit and the buffer is NUL-terminated.
When / when not: always prefer snprintf for fixed buffers. Use it even for "obviously short" output — field values you don't control can be arbitrarily long.
Pitfall: the return value is intended length, not bytes actually written. Comparing it against cap is the whole point; ignoring it re-creates the sprintf overflow you were avoiding.
Definition: an overflow occurs when the text you want to write does not fit in cap bytes.
Plain explanation: on overflow you must not pretend it succeeded. Return an error, and make sure whatever is in out is still a valid (terminated) string so a caller that ignores the error at least doesn't read past the end.
How it works: capture int n = snprintf(...). If n < 0 (encoding error) or (size_t)n >= cap (truncation), fail. snprintf already writes a NUL within cap when cap > 0, but on the error path you should defensively set out[0] = '\0' if you built the string in pieces.
When / when not: every bounded write needs this check. There is no case where silently truncating a security finding is acceptable — a half-written remediation is dangerous.
Pitfall: off-by-one. cap includes the NUL. A buffer of size cap holds at most cap - 1 visible characters.
Definition: a standard set of fields every finding carries so reports are comparable.
Plain explanation: a good finding answers: what is it, how bad, where, how do I know, what happens if abused, how likely, how do I fix it, and did the fix hold on retest.
How it works: map struct fields to template sections. Full template: title, severity, affected component, preconditions, safe reproduction, evidence, impact, likelihood, remediation, retest. Severity is not a vibe — it is a function of exploitability × access required × impact. A theoretical issue needing local admin is not "critical" just because it sounds scary.
When / when not: use the full template for a client deliverable. A quick lab note can be shorter, but never drop remediation.
Pitfall: rating everything "High/Critical" destroys trust and buries the finding that actually matters. Justify severity from impact and exploitability.
Definition: a function severity_rank(const char *sev) maps a label to a number so findings can be ordered.
Plain explanation: readers want the worst finding first. Convert "Critical/High/Medium/Low/Info" to ranks, then sort descending.
Pitfall: unknown labels. Return -1 for anything unrecognized rather than silently ranking it as "Info" — a typo shouldn't hide a serious finding.
THREAT MODEL — the report generator
[Target system] --(authorized test)--> raw notes / captured bytes
(untrusted text: titles, evidence copied from responses)
|
==================|=================== trust boundary
fixed-size out[] v (attacker-influenced length & content)
+--------------------------------+
| render_finding(f, out, cap) | <-- ENTRY POINT for hostile data
| snprintf, bounded, checked |
+--------------------------------+
|
v
Markdown report file --> read by human client / parsed by tooling
Assets protected : integrity of the report; safety (no overflow) of the tool
Trust boundary : the fixed-size buffer 'out' vs. arbitrary-length field text
Entry points : f->evidence, f->title, etc. — strings you did not author
Insecure assumption to avoid: "field values are short and well-formed"
Knowledge check:
snprintf instead of sprintf here? (The integrity of the report and the memory safety of your own tool.)out buffer; f->evidence, since it is often raw captured bytes.)The one API that carries this lesson is snprintf, plus the struct that feeds it.
#include <stdio.h> // snprintf
typedef struct {
const char *title; // short label, e.g. "Missing HSTS header"
const char *severity; // "Critical"|"High"|"Medium"|"Low"|"Info"
const char *asset; // affected component, e.g. a lab URL
const char *evidence; // proof — may be long / attacker-influenced
const char *recommendation; // remediation guidance
} finding_t;
// n = number of chars snprintf WOULD write (excluding the NUL).
// cap = total buffer size, INCLUDING room for the NUL.
int n = snprintf(out, cap,
"## %s\n**Severity:** %s\n",
f->title, f->severity);
// Truncation / overflow test:
if (n < 0 || (size_t)n >= cap) {
// did NOT fit — treat as failure, do not use 'out'
}
Key rules encoded above:
cap includes the NUL byte; visible text is capped at cap - 1.\n\n — they separate blocks. Missing them collapses sections.%s with a NULL pointer is undefined behavior; guard fields that could be NULL before formatting.The hardest part of any security engagement is the writeup.
A clean Markdown template turns raw notes into a deliverable. A good template covers:
This exercise focuses on the formatter side. Given a struct, you produce Markdown that a human can read.
The data for one finding lives in a struct (a record that groups related fields). Each field is a string:
typedef struct {
const char *title; // "Missing HSTS header"
const char *severity; // "Low", "Medium", "High"
const char *asset; // "https://example.com/"
const char *evidence; // "Response missing 'Strict-Transport-Security'"
const char *recommendation;// "Add HSTS with max-age >= 31536000"
} finding_t;
Implement this function:
int render_finding(const finding_t *f, char *out, size_t cap);
It must:
out.cap - 1 bytes of text, plus one byte for the NUL terminator.-1 if the output would overflow the buffer.What is a NUL terminator? C strings end with a hidden zero byte ('\0'). Every string needs room for it, which is why the text is capped at cap - 1.
## Missing HSTS header
**Severity:** Medium
**Asset:** https://example.com/
### Evidence
Response missing 'Strict-Transport-Security'
### Recommendation
Add HSTS with max-age >= 31536000
sprintf without a length check. sprintf cannot know your buffer size, so it can overflow. Use snprintf, then compare its return value against cap.Below: first a clearly-labelled insecure version, then the secure fix, then a verify step that proves the fix rejects bad input and accepts good input.
/* insecure_report.c — DEMONSTRATION OF A BUG. Do not ship. */
#include <stdio.h>
#include <string.h>
typedef struct { const char *title, *severity, *asset, *evidence, *recommendation; } finding_t;
/* BUG: sprintf has no idea how big 'out' is. A long evidence string
copied straight from a target overruns the buffer and corrupts memory. */
int render_finding_bad(const finding_t *f, char *out) {
return sprintf(out,
"## %s\n**Severity:** %s\n**Asset:** %s\n\n### Evidence\n%s\n\n### Recommendation\n%s\n",
f->title, f->severity, f->asset, f->evidence, f->recommendation);
}
int main(void) {
char small[64]; /* deliberately too small */
finding_t f = { "XSS", "High", "http://127.0.0.1:8080/",
/* imagine this evidence was captured from a target response */
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"Encode output" };
render_finding_bad(&f, small); /* overflows 'small' — undefined behavior */
printf("%s\n", small);
return 0;
}
Compiled with sanitizers, this aborts with a stack-buffer-overflow — see the VERIFY step. That is the vulnerability, reproduced in a lab so you can see it fail safely.
/* report.c — bounded, overflow-safe finding renderer. C11. */
#include <stdio.h>
#include <string.h>
typedef struct {
const char *title, *severity, *asset, *evidence, *recommendation;
} finding_t;
/* Returns bytes written (excluding NUL) on success, or -1 on overflow /
error. 'out' is always left NUL-terminated when cap > 0. */
int render_finding(const finding_t *f, char *out, size_t cap) {
if (!f || !out || cap == 0) return -1;
out[0] = '\0'; /* safe default on every error path */
/* Guard NULL fields: %s on NULL is undefined behavior. */
const char *title = f->title ? f->title : "(untitled)";
const char *sev = f->severity ? f->severity : "Info";
const char *asset = f->asset ? f->asset : "(unknown)";
const char *evid = f->evidence ? f->evidence : "(none)";
const char *rec = f->recommendation ? f->recommendation : "(none)";
int n = snprintf(out, cap,
"## %s\n"
"**Severity:** %s\n"
"**Asset:** %s\n"
"\n"
"### Evidence\n%s\n"
"\n"
"### Recommendation\n%s\n",
title, sev, asset, evid, rec);
if (n < 0 || (size_t)n >= cap) { /* encoding error or truncation */
out[0] = '\0'; /* do not hand back a half-report */
return -1;
}
return n;
}
/* Map a severity label to a rank so findings sort worst-first.
Returns -1 for an unknown label instead of guessing. */
int severity_rank(const char *sev) {
if (!sev) return -1;
if (strcmp(sev, "Critical") == 0) return 4;
if (strcmp(sev, "High") == 0) return 3;
if (strcmp(sev, "Medium") == 0) return 2;
if (strcmp(sev, "Low") == 0) return 1;
if (strcmp(sev, "Info") == 0) return 0;
return -1;
}
int main(void) {
finding_t f = {
.title = "Missing HSTS header",
.severity = "Medium",
.asset = "http://127.0.0.1:8080/", /* local lab only */
.evidence = "Response missing 'Strict-Transport-Security'",
.recommendation = "Add HSTS with max-age >= 31536000"
};
char buf[512];
int n = render_finding(&f, buf, sizeof buf);
if (n < 0) {
fprintf(stderr, "render_finding: buffer too small\n");
return 1;
}
fputs(buf, stdout);
printf("[rank=%d, %d bytes]\n", severity_rank(f.severity), n);
return 0;
}
Expected output:
## Missing HSTS header
**Severity:** Medium
**Asset:** http://127.0.0.1:8080/
### Evidence
Response missing 'Strict-Transport-Security'
### Recommendation
Add HSTS with max-age >= 31536000
[rank=2, 189 bytes]
(The exact byte count depends on the field text; the point is n >= 0.)
/* verify.c — link with the report.c definitions (or #include them). */
#include <stdio.h>
#include <string.h>
#include <assert.h>
/* Same struct the renderer uses (in a real project this comes from a shared header). */
typedef struct { const char *title, *severity, *asset, *evidence, *recommendation; } finding_t;
int render_finding(const finding_t *f, char *out, size_t cap);
int severity_rank(const char *sev);
int main(void) {
finding_t f = { "Title", "High", "http://127.0.0.1/", "evid", "fix" };
/* ACCEPT: a large enough buffer succeeds and is NUL-terminated. */
char big[256];
int n = render_finding(&f, big, sizeof big);
assert(n > 0);
assert(big[n] == '\0'); /* terminator present */
assert(strstr(big, "## Title")); /* content correct */
/* REJECT: a tiny buffer returns -1 and leaves an empty, safe string. */
char tiny[8];
assert(render_finding(&f, tiny, sizeof tiny) == -1);
assert(tiny[0] == '\0'); /* no half-written garbage */
/* Severity ranking, including the unknown-label guard. */
assert(severity_rank("Critical") == 4);
assert(severity_rank("Low") == 1);
assert(severity_rank("bogus") == -1);
puts("all checks passed");
return 0;
}
Build both the insecure demo (with sanitizers, to see the overflow) and the secure version:
# See the bug fail safely under a sanitizer (lab only):
cc -std=c11 -g -fsanitize=address -o insecure_report insecure_report.c
./insecure_report # AddressSanitizer reports stack-buffer-overflow
# Build and self-test the secure version:
cc -std=c11 -Wall -Wextra -fsanitize=address,undefined -o report report.c
./report # prints the report cleanly, no sanitizer errors
Walkthrough of the secure render_finding on the sample finding with cap = 512.
| Step | Code | What happens |
|---|---|---|
| 1 | `if (!f | |
| 2 | out[0] = '\0'; |
Establish a safe default: if anything below fails, out is already a valid empty string. |
| 3 | title = f->title ? f->title : "(untitled)"; (and peers) |
Replace any NULL field with a placeholder. %s on NULL is undefined behavior, so this guard is mandatory. All five fields are non-NULL here, so the real values pass through. |
| 4 | int n = snprintf(out, cap, ...) |
Format the whole block. snprintf writes up to cap - 1 visible bytes plus a NUL, and returns the length it would need. For the sample that is ~189, well under 512. |
| 5 | `if (n < 0 | |
| 6 | error branch: out[0] = '\0'; return -1; |
Only on overflow: wipe to empty and report failure so a caller never emits a truncated finding. Skipped here. |
| 7 | return n; |
Success — return the byte count (189). |
How the key value changes: n is the single source of truth. It starts as the desired length and is immediately compared to cap. That one comparison is the entire safety mechanism — it converts "I hope it fit" into "I proved it fit."
For severity_rank("Medium"): strcmp("Medium","Critical")!=0, !="High", then =="Medium" → return 2. An unrecognized label falls through every branch to the final return -1.
1. Using sprintf (or strcpy/strcat) into a fixed buffer.
snprintf with the real cap and check the return value.grep -n 'sprintf\|strcpy\|strcat' *.c; compile with -Wall -Wextra and run under -fsanitize=address.2. Ignoring the snprintf return value.
snprintf won't overflow, but it will silently truncate. A truncated remediation section is dangerous and looks fine.if (n < 0 || (size_t)n >= cap) return -1;snprintf whose result you don't test is a smell.3. Off-by-one on cap.
cap as "visible characters" forgets the NUL; you lose one byte or write one too many.cap includes the NUL; visible text ≤ cap - 1.sizeof buf (not sizeof buf - 1) as cap; let snprintf reserve the NUL.4. Passing a NULL field to %s.
5. Rating every finding "Critical."
severity_rank consistently.6. Missing blank lines between sections.
\n\n) to break blocks; without it, headings and text merge.\n \n separators in the format string.Symptom: crash or AddressSanitizer "stack-buffer-overflow."
-fsanitize=address,undefined -g and read the report — it names the buffer and the write. Almost always a sprintf/strcpy or an undersized cap. Switch to snprintf and pass sizeof buf.Symptom: report is cut off mid-sentence.
n >= cap check; either enlarge the buffer or report -1 and let the caller grow it.Symptom: two sections rendered as one paragraph.
\n\n. Print the raw bytes (od -c out or printf("%s", buf)) and confirm the blank line is really there before blaming the renderer.Symptom: garbage or crash printing a field.
NULL (or dangling) const char *. Check that the text backing each field outlives the struct and is non-NULL; add the placeholder guards.Questions to ask when it fails:
cap cover the worst case, or do I correctly return -1?out NUL-terminated on every path, including the error path?sizeof buf (bytes) and not element count or sizeof buf - 1?-Wall -Wextra, is the build truly clean?snprintf is the guardrail, but you must honor its contract — pass the true cap and act on the return value. Never mix in sprintf/strcat "just for one field."snprintf terminates within cap when cap > 0; on your own error path set out[0] = '\0' so a caller that ignores -1 still reads a valid empty string.%s on NULL is UB; the struct stores only pointers, so the backing text must outlive the struct. Guard optional fields with placeholders.cap includes the NUL. Visible characters ≤ cap - 1.-Wall -Wextra -fsanitize=address,undefined and run the VERIFY self-tests in CI.When a tool generates reports from attacker-influenced input, log the event, not the payload.
-1 overflow / rejected input); the security-relevant decision ("evidence truncated and rejected"); and the finding id. Example line: 2026-07-08T10:14Z op=alice corr=ENG-42 asset=127.0.0.1:8080 finding=F-3 result=RENDER_OK bytes=189.token=<redacted> in the report.-1 overflow rejections (someone or something feeding pathologically long fields), findings referencing assets outside the authorized scope, or evidence containing what looks like live credentials (a redaction failure).127.0.0.1 asset in the example is deliberate — labs run on localhost / containers / intentionally-vulnerable VMs / CTF, never real third-party hosts.Authorized real-world use case: during an engagement you scope to a client-owned staging host, you accumulate raw notes. A small C (or scripted) generator turns each note-struct into a Markdown block, sorts them worst-first with severity_rank, and concatenates them into report.md. The client reads it; your tooling can also diff this run against the previous retest to show which findings were fixed.
Professional best-practice habits:
Info, default missing fields to explicit placeholders, and default to failing closed (-1) on overflow.snprintf's return; propagate -1 so nothing silently truncates.Beginner vs advanced:
| Beginner | Advanced | |
|---|---|---|
| Scope | render one finding safely | full pipeline: collect, dedupe, sort, emit |
| Format | fixed Markdown template | template + machine-readable (e.g. JSON/SARIF) side output |
| Severity | pick from a fixed set | justify via a scoring rubric (exploitability/impact) |
| Safety | snprintf + return check |
fuzz the generator, ASan/UBSan in CI, redaction pass |
| Retest | note fixed/not | diff against prior report, track remediation status |
All tasks are lab-only: any evidence comes from systems you own or are explicitly authorized to test (localhost, containers, intentionally-vulnerable VMs, CTF). Each finishes with remediation and verification.
Authorization checklist (before any lab that captures evidence): (1) I own or have written authorization for the target; (2) the target is isolated (localhost / private container network); (3) scope and time window are agreed; (4) I have a cleanup/reset plan. Cleanup/reset: delete generated report.md and any captured evidence files, stop and remove lab containers, and clear scratch buffers holding sensitive text.
Beginner 1 — Render one finding.
render_finding on a hard-coded finding_t and print the result.-1 print an error to stderr and exit non-zero.sprintf/strcpy; C11; -Wall -Wextra clean.sizeof buf as cap. Concepts: struct, snprintf, return-value check.Beginner 2 — Severity rank + guard.
severity_rank.-1 for anything else including NULL."High" -> 3, "xyz" -> -1, NULL -> -1.strcmp; no crashes on NULL.NULL first. Concepts: string compare, defensive defaults.Intermediate 1 — Prove the overflow is rejected.
render_finding.-1 and leaves out[0] == '\0'.-fsanitize=address,undefined; the run must be sanitizer-clean.assert(big[n] == '\0'). Concepts: mitigation verification, fail-closed. Defensive conclusion: a passing REJECT test is the proof your bound holds.Intermediate 2 — Multi-finding report, sorted worst-first.
severity_rank descending.-1 for the whole report (fail closed).strcat; bounded appends only.cap - offset to each snprintf. Concepts: sorting, cumulative bounds.Challenge — Redaction pass + safe logging.
token=..., a long hex/Base64 blob) with <redacted>, then log a render event (timestamp, correlation id, asset, result, byte count) that contains no secret.-1.<development-placeholder> in tests. Do not invent hashing/crypto APIs; a simple pattern match is enough.<redacted> and not the original secret, then run the cleanup/reset steps.Main concepts: a finding_t struct groups the fields of one security finding; render_finding turns it into a Markdown block using bounded, checked formatting; severity_rank orders findings worst-first. The asset you protect is the integrity and memory-safety of your own report pipeline against arbitrary-length, attacker-influenced field text.
Key syntax/commands:
int n = snprintf(out, cap, fmt, ...); — cap includes the NUL; n is the intended length.if (n < 0 || (size_t)n >= cap) { out[0] = '\0'; return -1; }cc -std=c11 -Wall -Wextra -fsanitize=address,undefined ...Common mistakes: sprintf/strcpy into fixed buffers; ignoring the snprintf return; off-by-one on cap; %s on NULL; missing \n\n between sections; rating everything Critical.
What to remember: bound every write and act on the result; keep out NUL-terminated on every path, including failure; justify severity from exploitability and impact; log the event but never the secret; a report describes what you tested in scope — it never proves a system is "completely secure," and a clean scanner run is not proof of security. Test only what you own or are authorized to test, in an isolated lab, and clean up afterward.