SEN2008 · Week 14 · Software Security (II): Secure software construction techniques, Security-related verification and validation

Secure coding: input validation, SQL injection, XSS, buffer overflow, CSRF

You learn the five most common coding vulnerabilities by seeing vulnerable and fixed code side by side. The exam questions 'what is wrong in this code, how do you fix it' and 'what does the query become with this input' come from here.

input validationallowlistdenylistSQL injectionparameterized queryprepared statementcross-site scripting (XSS)output encodingContent Security Policybuffer overflowbounds checkingCSRFanti-CSRF tokenSameSite cookie

Last week we talked about design decisions. This week we go down to individual lines of code. The question type that almost certainly appears on the exam: you are given a vulnerable piece of code and asked “what is the vulnerability, how do you fix it?” or “what query is executed for this input?”. All five vulnerabilities come from the same root: trusting data that comes from outside and mixing data with code.

Golden rules:

  1. All input is hostile. Form fields, URLs, cookies, HTTP headers, file names, API responses.
  2. Validate on the server. A JavaScript check is for the user; the attacker skips it.
  3. Separate data from code. Parameters for SQL, encoding for HTML, an argument array for commands.
  4. Know the limits. In C, the size of every buffer and the \0 byte must be in the calculation.
  5. Every state-changing request needs proof. No transfer without a CSRF token.

The code snippets on this page are for understanding and fixing the vulnerability. Trying them without permission on a real system is a crime.

Input validation

There are two approaches:

ApproachLogicProblem
Denylistreject known bad patternsthe attacker gets past with a new pattern, a different encoding, upper/lower case
Allowlistaccept only the expected formatthe format must be defined correctly, but anything unknown is rejected

An allowlist example: a username is only letters, digits, underscore and 3-16 characters.

const USERNAME = /^[A-Za-z0-9_]{3,16}$/;
for (const s of ["deniz_07", "ab", "ali veli", "x<script>", "AAAAAAAAAAAAAAAAA"]) {
  console.log(JSON.stringify(s), USERNAME.test(s));
}
"deniz_07" true
"ab" false
"ali veli" false
"x<script>" false
"AAAAAAAAAAAAAAAAA" false

The last input is 17 characters, one too many. The ^ and $ anchors are a must: without them the regex matches a part of the text and the rest is not checked.

PITFALL · validation is not the only defense. In a name field O'Brien is legitimate, and it contains a quote. That is why, besides validation, parameters for SQL and encoding for HTML are also needed. Validation answers the question “is the format right”, and encoding answers “is it safe in this context”.

SQL injection

The problem is building the query by string concatenation:

// VULNERABLE: the input is pasted into the SQL text
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
ResultSet rs = stmt.executeQuery(sql);

The classic textbook example: name = ' OR '1'='1. The text becomes:

SELECT * FROM users WHERE name = '' OR '1'='1'

'1'='1' is true in every row, and the query returns the whole table. A simulation on a table with three users:

rows matched (concat)   3
rows matched (param)    0

The fix is a parameterized query:

// SAFE: the query structure is fixed, the input is only a value
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM users WHERE name = ?");
ps.setString(1, name);
ResultSet rs = ps.executeQuery();

The database compiles the query first and never interprets the value that arrives in place of ? as SQL. With the same input it looks for a user whose name is exactly ' OR '1'='1, and 0 rows are returned.

Extra layers: give the database user only the privileges needed on only the tables needed (least privilege), don’t show SQL details in error messages, and if you use an ORM, avoid concatenating raw queries.

PITFALL · AND/OR precedence. In the query ... WHERE user = 'admin' AND pass = ' + input + ', if the input is x' OR 'a'='a, the text becomes user = 'admin' AND pass = 'x' OR 'a'='a'. AND binds first: (user='admin' AND pass='x') OR 'a'='a', and the result is true for every row. The fix is again a parameter.

Cross-site scripting (XSS)

In XSS the attacker runs their own script on another site’s page, in the browser of that site’s user. Stealing cookies, showing fake forms and acting on behalf of the user become possible.

TypeWhere the input sitsExample
Storedon the server, in the databasea script written into a comment field runs for everyone who opens the page
Reflectedin the URL, in a single responsethe value of search?q=... is printed into the page without encoding
DOM-basedonly in the browserJS writes the location.hash value with innerHTML

Vulnerable and fixed code:

// VULNERABLE: the user's text is interpreted as HTML
comment.innerHTML = userText;

// SAFE: inserted as text, creates no tags
comment.textContent = userText;

If you use templates on the server side, encode the output for the HTML context:

const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
  .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
console.log(esc('<b>"Hi"</b>'));
console.log(esc("<script>alert(1)</script>"));
console.log(esc("Tom & Jerry's"));
&lt;b&gt;&quot;Hi&quot;&lt;/b&gt;
&lt;script&gt;alert(1)&lt;/script&gt;
Tom &amp; Jerry&#x27;s

& is always replaced first; otherwise the & in the &lt; produced later would be encoded again. Context matters: the rules are different for the HTML body, an HTML attribute, JavaScript text and a URL. Modern template engines (React JSX, Thymeleaf) encode by default.

The second layer is Content Security Policy: the header Content-Security-Policy: script-src 'self' tells the browser to run only script files that come from its own origin, and inline scripts are blocked. The third layer: giving the session cookie HttpOnly, so JavaScript can’t read the cookie.

Buffer overflow (C)

C arrays don’t know their size. strcpy copies until it sees the end of the source (\0), and doesn’t check whether the destination is full.

/* VULNERABLE */
void greet(const char *name) {
    char buf[10];
    strcpy(buf, name);          /* no length check */
    printf("Hello %s\n", buf);
}

If name = "HelloWorld!!" (12 characters), 13 bytes are written, overflowing the 10-byte buffer by 3 bytes. On the stack, beyond the buffer, there are other variables and the function’s return address; corrupting them means a crash or hijacking of the control flow.

/* SAFE */
void greet(const char *name) {
    char buf[10];
    if (strlen(name) >= sizeof buf) {   /* leave room for \0 */
        fprintf(stderr, "name too long\n");
        return;
    }
    snprintf(buf, sizeof buf, "%s", name);
    printf("Hello %s\n", buf);
}
DangerousSaferNote
gets(buf)fgets(buf, sizeof buf, stdin)gets was removed in C11
strcpy(d, s)snprintf(d, sizeof d, "%s", s)strncpy doesn’t put a \0 for a long source
strcat(d, s)compute the remaining space and use snprintf
sprintf(d, ...)snprintf(d, sizeof d, ...)

Compiler and operating system layers (stack canary, ASLR, DEP/NX) make the attack harder but do not fix the bug. The lasting solution is bounds checking or a memory-safe language (Java, Rust, Go).

PITFALL · off-by-one. For char buf[16], the longest safe text is 15 characters. If you write if (len <= sizeof buf), 16 characters + \0 = 17 bytes are written. The right one is len < sizeof buf.

Cross-site request forgery (CSRF)

The user is logged in to the bank, and the session cookie is in the browser. On another site a hidden form automatically sends a POST to the bank. The browser adds the cookie by itself, and the server thinks the request came from the user. The difference from XSS: the attacker doesn’t run code on the bank’s page, they only make the user’s browser send a request.

<!-- VULNERABLE: the server looks only at the cookie -->
<form action="/transfer" method="POST">
  <input name="to"> <input name="amount">
</form>

<!-- SAFE: a session-bound, unpredictable token -->
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="(sunucunun ürettiği rastgele değer)">
  <input name="to"> <input name="amount">
</form>

On every state-changing request the server compares the token with the value in the session and rejects the request if they don’t match. The attacker’s page can’t read this value (same-origin policy). Additional defenses: giving the cookie SameSite=Lax or Strict (the cookie is not sent on a cross-site POST), asking again for a password or MFA on critical operations, and never using GET requests to change state.

Common mistakes

Wrong ideaCorrect
I validated with JavaScript, that’s enoughThe attacker skips the browser; validate on the server
Removing single quotes solves SQLiNumeric fields need no quotes; use a parameterized query
I can give a prepared statement a concatenated query tooThe moment you concatenate, the hole is back; values only with ?
Printing user text with innerHTML is safeUse textContent or encode for the context
HTTPS prevents XSS and CSRFHTTPS only encrypts the path; it doesn’t protect application logic
strncpy always puts a \0It doesn’t if the source is long; use snprintf
buf[16] holds 16 characters15 characters + \0
XSS protection solves CSRFA separate hole; a token and SameSite are needed
A denylist is enoughUse an allowlist; reject the unknown

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1medium

The login code below is called with name = ' OR '1'='1. What SQL string is sent to the database?

String sql = "SELECT * FROM users WHERE name = '" + name + "'";
ResultSet rs = stmt.executeQuery(sql);
Show answer
SELECT * FROM users WHERE name = '' OR '1'='1'

The input is pasted between the quotes as it is. The first ' closes the open string, then OR '1'='1 is added as a logical expression, and the final ' is the code's own closing quote:name = '' OR '1'='1'. Since '1'='1' is true for every row, the WHERE returns the whole table. The problem: data and code got mixed in the same string.

Question 2easy

What is the most reliable fix for SQL injection?

  1. Remove all single quotes from user input
  2. Use parameterized queries (prepared statements)
  3. Hide database error messages
  4. Encrypt the database
Show answer
B) Use parameterized queries (prepared statements)

In a parameterized query the SQL text is compiled first, and then the input is bound in place of ? as data. Whatever the input contains, it can't change the structure of the query. Removing quotes is easy to get around (in numeric fields you don't even need quotes), and hiding errors or encryption do not close the hole.

Question 3medium

The same login query is rewritten with a prepared statement, and the table has three users: ayse, mert, deniz. With input ' OR '1'='1, how many rows are returned?

PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM users WHERE name = ?");
ps.setString(1, name);
ResultSet rs = ps.executeQuery();
Show answer
0

Once the parameter is bound, the database looks for a user whose name is exactly ' OR '1'='1. There is no such user, so 0 rows are returned. The input did not become part of the query's logic, it was just the value being searched for.

Question 4medium

A page inserts a user comment into HTML. Using HTML entity encoding for & < > " ', what does the comment <b>"Hi"</b> become?

Show answer
&lt;b&gt;&quot;Hi&quot;&lt;/b&gt;

Every special character is replaced by its equivalent: < → &lt;, > → &gt;, " → &quot;. Result: &lt;b&gt;&quot;Hi&quot;&lt;/b&gt;. The browser shows this as plain text, not as a tag: the screen shows <b>"Hi"</b>. This output encoding is the basic defense against XSS.

Question 5easy

Why is an allowlist generally preferred over a denylist for input validation?

  1. Allowlists are faster to execute
  2. A denylist must anticipate every bad input, while an allowlist only accepts known-good input
  3. Denylists cannot be written as regular expressions
  4. Allowlists encrypt the input
Show answer
B) A denylist must anticipate every bad input, while an allowlist only accepts known-good input

A denylist says "block these bad patterns". The attacker gets past the list with upper/lower case, encoding or a new pattern. An allowlist says "accept only this format": everything unknown is rejected. This is the input validation counterpart of last week's fail-safe defaults principle.

Question 6medium

A username must fully match the pattern [A-Za-z0-9_]{3,16} (anchored at both start and end). Which input is ACCEPTED?

  1. ab
  2. ali veli
  3. deniz_07
  4. x<script>
Show answer
C) deniz_07

Rule: only letters, digits, underscore; length 3-16.ab is 2 characters, too short. ali veli contains a space. x<script> contains < and >. deniz_07 is 8 characters and all of them are allowed: accepted. Note: ömer would also be rejected because ö is not in the ASCII range; in a real system that is a design decision.

Question 7hard

In the C code below, input has 12 characters. How many bytes does strcpy write, and how many bytes past the end of buf does it go?

char buf[8];
strcpy(buf, input);   /* no length check */
Show answer
13 bytes written, 5 bytes past the end

strcpy copies including the terminating \0: 12+1=1312 + 1 = 13 bytes. The buffer is 8 bytes, so the overflow is 13−8=513 - 8 = 5 bytes. These bytes overwrite neighbouring variables or the return address on the stack. The fix: check the length, and use a bounded function such as snprintf(buf, sizeof buf, "%s", input).

Question 8easy

Which C function call copies a string into a fixed buffer while respecting the buffer size and always null-terminating the result?

  1. strcpy(buf, s)
  2. gets(buf)
  3. snprintf(buf, sizeof buf, "%s", s)
  4. strcat(buf, s)
Show answer
C) snprintf(buf, sizeof buf, "%s", s)

snprintf writes at most sizeof buf - 1 characters and puts a \0 at the end. strcpy and strcat don't know the length. gets is so dangerous that it was removed entirely from the C11 standard. (strncpy is also bounded, but it does not put a \0 if the source is long, so it isn't the right answer here.)

Question 9easy

An attacker's page silently submits a form to bank.example/transfer while the victim is logged in, and the victim's session cookie is sent automatically. Which defense directly stops this attack?

  1. Hashing passwords with bcrypt
  2. An unpredictable anti-CSRF token checked on every state-changing request
  3. Using HTTPS only
  4. Input length limits
Show answer
B) An unpredictable anti-CSRF token checked on every state-changing request

This is CSRF (cross-site request forgery): because the browser sends the cookie automatically, the server takes the request as legitimate. An anti-CSRF token is an unpredictable value tied to the session, and it comes as a hidden field in the form. The attacker's page can't know this value, so the request is rejected. An extra layer: giving the cookie SameSite=Lax or Strict. HTTPS and password hashing don't stop CSRF.

Question 10medium

A search page reads ?q= from the URL and writes it into the page without encoding; the payload is never stored on the server. What type of XSS is this?

  1. Stored XSS
  2. Reflected XSS
  3. DOM-based XSS only if a database is involved
  4. CSRF
Show answer
B) Reflected XSS

The input arrives with the request and is reflected in the same response, and it is not persistent: reflected XSS. In stored XSS the input is saved to the database and shown to everyone who opens the page. In DOM-based XSS the server isn't involved at all; JavaScript on the page writes the input into the DOM unsafely (e.g. innerHTML).

Question 11medium

A site sends the header Content-Security-Policy: script-src 'self'. What is its effect on an injected inline <script> tag?

  1. The inline script runs normally
  2. The browser blocks the inline script because only scripts from the site's own origin files are allowed
  3. The server deletes the script from the database
  4. It encrypts the script
Show answer
B) The browser blocks the inline script because only scripts from the site's own origin files are allowed

CSP tells the browser which sources it may run scripts from. script-src 'self' allows only files loaded from the same origin; unless 'unsafe-inline' is written, inline scripts do not run. This is a second layer (defense in depth) that reduces the impact of XSS even if a coding mistake slips through. The real fix is still output encoding.

Question 12hard

With char buf[16]; you copy len characters and then add a '\0'. What is the largest len that is safe, and which check is correct?

  1. 16, if (len <= sizeof buf)
  2. 15, if (len < sizeof buf)
  3. 17, if (len <= sizeof buf + 1)
  4. 8, if (len < sizeof buf / 2)
Show answer
B) 15, if (len < sizeof buf)

In a 16-byte buffer one byte must be reserved for the \0: at most 16−1=1516 - 1 = 15 characters. Writing len <= 16 means 16 characters + \0 = 17 bytes, so one byte overflows. This is a classic off-by-one error. The correct check is len < sizeof buf.

Verification: The text of the concatenated SQL queries, the number of rows matched by the parameterized query, HTML escape outputs, allowlist regex results and buffer byte counts were produced with node (verify/SEN2008/14.mjs).

Review card

RULE: don't trust input, encode output for its context, separate data from code Validation: ALLOWLIST (^[A-Za-z0-9_]{3,16}$), on the server side; client-side checks are only a convenience SQLi: "...name = '" + x + "'" with x = ' OR '1'='1 → name = '' OR '1'='1' (all rows) Fix: PreparedStatement + ? (input becomes DATA, 0 rows). Removing quotes is NOT a fix XSS: stored (in the DB) | reflected (from the URL into the same response) | DOM (JS innerHTML) Fix: output encoding < → &lt; > → &gt; " → &quot; & → &amp; ' → &#x27; + CSP script-src 'self' (inline script doesn't run) + HttpOnly cookie Buffer overflow: strcpy(buf[8], 12 characters) → 13 bytes, 5 overflow. Don't forget the terminating \0 Safe: snprintf, length check len < sizeof buf (<= is off-by-one). gets is FORBIDDEN CSRF: the browser sends the cookie automatically → anti-CSRF token + SameSite=Lax/Strict A GET request does NOT change state