Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 

Repository files navigation

Web Fundamentals & Web Security Lab

This repository is my personal lab notebook while I learn how web applications actually work, before jumping into web exploitation. It is not a production application, and it was never meant to be secure or polished. It exists so I can break things safely and understand why they break.

I'm a cybersecurity student focused on Red Teaming, and most of the concepts here follow the same path as HTB Academy's Web Requests / SQL Injection / Session Security modules. The difference is that I wanted to build the vulnerable app myself, from scratch, instead of only reading about it. Building it forces me to understand the backend logic PHP, MySQL/MariaDB, sessions, cookies instead of just memorizing payloads.

The core idea behind this repo is simple: you can't exploit what you don't understand. A SQL injection payload like ' OR '1'='1 looks like magic until you understand how PHP builds a string and how a SQL parser reads that string. Once that clicks, exploitation stops feeling like guesswork and starts feeling like logic.


Learning Objectives

This repo was built to actually understand not just recognize the following concepts:

  • HTTP Request & Response how the browser and server talk to each other, and what actually travels over the wire.
  • Apache the web server that receives the HTTP request and hands it to PHP.
  • PHP the language that turns a request into logic (checking a login, querying a database, etc).
  • PHP Superglobals ($_SERVER, $_GET, $_POST) how PHP exposes request data to your script.
  • GET sending data through the URL/query string.
  • POST sending data through the request body (used for login forms here).
  • Forms the HTML side of sending GET/POST data.
  • MariaDB the database engine storing users, passwords, and roles.
  • SQL the query language used to talk to the database.
  • Authentication proving who you are (logging in).
  • Authorization proving what you're allowed to do once you're logged in.
  • SQL Injection what happens when user input is trusted inside a SQL query.
  • Prepared Statements the actual fix for SQL Injection, and why it works.
  • Password Hashing why plaintext passwords in a database are a disaster waiting to happen.
  • Sessions how the server remembers you're logged in.
  • Cookies how the browser proves it's the same client on every request.
  • Session Hijacking concepts what happens if someone steals your session ID.
  • Session Fixation a subtler session attack, and how session_regenerate_id() stops it.
  • Logout why logging out has to happen on both the server and the browser.

Project Flow

This is the order I actually learned things in, and it's intentional each phase only makes sense once you understand the phase before it:

HTTP
  ↓
PHP
  ↓
Database
  ↓
Login
  ↓
SQL Injection
  ↓
Prepared Statements
  ↓
Password Hashing
  ↓
Sessions
  ↓
Authorization
  ↓
Logout

Why this order matters:

  • I started with HTTP because everything on the web is a request and a response. If you don't understand that, nothing else makes sense.
  • PHP comes next because it's the layer that reads the HTTP request and decides what to do with it.
  • Database comes before Login because a login system is just PHP asking a database "does this user exist?"
  • SQL Injection comes right after a working login, because the vulnerable version of the login (form.php) is what makes the injection possible in the first place you have to see the broken version before you can appreciate the fixed one.
  • Prepared Statements come right after, as the direct fix for the injection.
  • Password Hashing comes next, because even a "safe" login is pointless if the database stores passwords in plaintext.
  • Sessions come after login works properly, because a login system needs a way to remember you're logged in across multiple requests.
  • Authorization comes after sessions, because being logged in and being allowed to do something (like access an admin panel) are two different problems.
  • Logout is last, because you can't understand why logout should destroy a session server-side until you understand how sessions are created in the first place.

Folder Structure

WebApp/
├── app
├── asset
├── config
│   └── database.php
├── database
├── public
│   ├── admin.php
│   ├── dashboard.php
│   ├── form.php
│   ├── index.php
│   ├── login.php
│   └── logout.php
└── README.md

Phase 1 HTTP & PHP Superglobals

Concept

Every time a browser talks to a web server, it sends an HTTP request and gets an HTTP response back. PHP doesn't see the raw HTTP text instead, Apache hands PHP a set of ready-made arrays called superglobals.

  • $_SERVER holds information about the request itself: which HTTP method was used (REQUEST_METHOD), the client's IP (REMOTE_ADDR), the browser/tool making the request (HTTP_USER_AGENT), the requested path (REQUEST_URI), and more.
  • $_GET holds everything sent through the URL's query string, e.g. /profile?id=5&name=Wakamiya gives you $_GET['id'] and $_GET['name'].
  • $_POST holds data sent through the body of a POST request this is how login forms send usernames and passwords.

Why it matters

Attackers look at superglobals as "attacker-controlled input." Anything coming from $_GET, $_POST, or even parts of $_SERVER (like HTTP_USER_AGENT) is data the user typed or crafted which means it can never be trusted blindly. Developers need to know exactly which parts of a request are user-controlled so they know what to validate.

Practice

Explored how $_SERVER["REQUEST_METHOD"] is used to check whether a request is GET or POST, and how form data submitted via POST shows up inside $_POST.

Output

if ($_SERVER["REQUEST_METHOD"] == "POST")
{
    $username = $_POST["username"];
    $password = $_POST["password"];
}

Browser Request :

Request Method: POST

Array
(
    [username] => admin
    [password] => admin123
)

Diagram

Browser Form
      │
      ▼
HTTP POST
      │
      ▼
Apache
      │
      ▼
PHP

$_SERVER["REQUEST_METHOD"] = POST
$_POST["username"] = admin
$_POST["password"] = admin123

Phase 2 Database & SQL Basics

Concept

A database is where the application's data actually lives permanently user accounts, passwords (which should be hashed, not plaintext), posts, comments, and so on. MariaDB organizes data in a hierarchy:

MariaDB Server
    │
    ├── Database
    │      ├── Table
    │      │      ├── Row (Record)
    │      │      └── Column (Field)

For example, this repo uses a webapp database with a users table, where each row is one account and each column is one attribute of that account (id, username, password, role).

Basic SQL used throughout this project:

Statement Purpose
CREATE DATABASE Create a new database
USE Select which database to work in
CREATE TABLE Create a new table
SHOW TABLES List tables in the current database
DESCRIBE Show a table's structure/columns
INSERT Add a new row
SELECT Read rows from a table

Why it matters

Almost every web attack eventually touches the database, directly or indirectly. Understanding how data is structured and queried is what makes SQL Injection make sense later you can't understand a broken query if you don't understand what a normal query looks like.

Practice

Created the webapp database and users table, inserted test accounts, and practiced basic SELECT / INSERT / UPDATE queries directly from the MariaDB CLI.

Output

MariaDB [webapp]> SELECT * FROM users;
+----+----------+----------+-------+---------------------+
| id | username | password | role  | created_at          |
+----+----------+----------+-------+---------------------+
|  1 | admin    | admin123 | admin | 2026-07-11 13:51:58 |
|  2 | wakamiya | 123456   | user  | 2026-07-12 03:15:38 |
+----+----------+----------+-------+---------------------+
2 rows in set (0.001 sec)

MariaDB [(none)]> use webapp
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
MariaDB [webapp]> INSERT INTO users (username,password,role)
    -> VALUES
    -> ('guest','guest123','user');
Query OK, 1 row affected (0.035 sec)

MariaDB [webapp]> SELECT * FROM users;
+----+----------+----------+-------+---------------------+
| id | username | password | role  | created_at          |
+----+----------+----------+-------+---------------------+
|  1 | admin    | admin123 | admin | 2026-07-11 13:51:58 |
|  2 | wakamiya | 123456   | user  | 2026-07-12 03:15:38 |
|  3 | guest    | guest123 | user  | 2026-07-12 07:16:43 |
+----+----------+----------+-------+---------------------+
3 rows in set (0.000 sec)

Phase 3 The Full Stack, End to End

Concept

Before touching security, I mapped out the whole request path, from browser to database and back:

Browser → HTTP Request → Apache → PHP → ($_POST / mysqli_query) → MariaDB → Result → PHP → HTTP Response → Browser

Why it matters

Every vulnerability in this repo lives somewhere on this path. SQL Injection happens at the $_POST → mysqli_query step. Session hijacking happens at the HTTP Response → Browser step (the cookie). Knowing the full path means knowing exactly where to look when something breaks or when something needs to be exploited.

Practice

Built a minimal login flow that touches every layer of this stack: an HTML form, a PHP script that reads $_POST, a MariaDB query, and an HTML response.

Output

POST /login.php HTTP/1.1
Host: 192.168.xxx.xx
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate
Referer: http://192.168.xxx.xx/login.php
Content-Type: application/x-www-form-urlencoded
Content-Length: 36
Origin: http://192.168.xxx.xx
Connection: keep-alive
Cookie: PHPSESSID=mk62jkjmkek6v6eqm011j7ihp7
Upgrade-Insecure-Requests: 1
Priority: u=0, i

Screenshots

Screenshot


Phase 4 PHP ↔ Database Interaction

Concept

Before getting into SQL Injection, I needed to understand something that felt obvious once I saw it, but wasn't obvious at all when I started: PHP does not keep asking the database for data one row at a time. It sends one query, MariaDB hands back the entire result at once as a Result Object, and PHP reads through that object in memory.

The flow looks like this:

PHP
  ↓
SQL Query
  ↓
MariaDB
  ↓
Result Object
  ↓
mysqli_num_rows()
  ↓
mysqli_fetch_assoc()
  ↓
$row
  ↓
Browser

Breaking that down function by function:

  • mysqli_query() sends the SQL query to MariaDB and gets back a Result Object not the actual rows yet, just a reference to the result set sitting in memory.
  • mysqli_num_rows() asks that Result Object how many rows it contains, without reading any of them. This is how index.php and login.php in this repo check "did we get a match?" before doing anything else.
  • mysqli_fetch_assoc() pulls one row at a time out of the Result Object and returns it as an associative array, e.g. $row['username'], $row['role'].
  • $row is just that one row, re-assigned on every call to mysqli_fetch_assoc().

This is why while ($row = mysqli_fetch_assoc($result)) shows up everywhere. mysqli_fetch_assoc() returns false once there are no rows left, so the while loop naturally stops on its own. Each pass through the loop grabs the next row already sitting in the Result Object PHP isn't going back to MariaDB for every single row, it's just walking through data it already has.

Why it matters

This distinction matters for SQL Injection too: the entire vulnerability happens at the mysqli_query() step, before any row is ever fetched. Whatever gets returned in the Result Object is a direct reflection of the query MariaDB actually executed if the query was manipulated, the Result Object is already compromised before mysqli_fetch_assoc() is even called. Understanding this data flow is what makes it possible to reason about where an injection actually lands in the code, instead of just knowing that it "works."

Practice

Traced index.php's while '($user = mysqli_fetch_assoc($result)) loop, printed mysqli_num_rows() before the loop to see the row count ahead of time, and compared that against login.php, which only ever expects zero or one row back.

Output

$result = mysqli_query($conn,$query);

echo mysqli_num_rows($result);

while($row = mysqli_fetch_assoc($result))
{
    echo $row["username"];
}

Screenshots

Screenshot


Burp Suite Watching HTTP Instead of Guessing It

I used Burp Suite throughout this lab, but not as a "hacking tool" I used it as an HTTP inspection tool. Normally the browser talks straight to Apache and I never see what's actually being sent:

Browser
  ↓
Burp Suite
  ↓
Apache
  ↓
PHP
  ↓
MariaDB

Burp sits in the middle as a proxy, so instead of assuming what the browser sends, I can actually read it. That includes:

  • GET requests
  • POST requests
  • Cookies
  • Session IDs
  • HTTP Headers
  • Status Codes
  • Redirects

This turned out to matter a lot more than I expected. Before using Burp, I understood login forms as "you type stuff and it either works or it doesn't." After using Burp, I could see exactly what happens. which fields get URL-encoded, what the Set-Cookie header looks like right after a successful login, why a 302 Found with a Location header is what a redirect actually is on the wire, and what a failed login (200 OK with the login form re-rendered) looks like compared to a successful one.

That habit of reading the raw request/response instead of guessing is what made the later phases click faster. For SQL Injection, Burp is how I sent the raw, unencoded payload and saw exactly how the server responded to it. For Authentication, comparing a successful 302 Found / Set-Cookie pair against a failed 200 OK made the difference between "logged in" and "not logged in" concrete instead of theoretical. For Sessions, watching the PHPSESSID cookie change after login (and stay the same across requests afterward) is what made session fixation and hijacking make sense as real mechanics instead of abstract warnings. For Authorization, being able to replay a request with a different session cookie is what makes access control actually testable.


Phase 5 SQL Injection

Concept

SQL Injection is not magic it's a parsing problem. Here's the chain of events:

User input
   ↓
Goes into the query
   ↓
SQL parser reads the query
   ↓
Parser hits a ' character
   ↓
The string is considered closed
   ↓
Everything after that is read as SQL syntax, not data

What's actually being exploited is how the SQL parser reads the query text, not some special trick.

The vulnerable code in this repo (form.php) builds the query like this:

$query = "SELECT * FROM users
          WHERE username='$username'
          AND password='$password'";

If an attacker fills the password field with:

' OR '1'='1

PHP builds this string:

SELECT * FROM users
WHERE username='admin'
AND password='' OR '1'='1'

Walking through it the way the SQL parser does:

username='admin'
AND password=''
OR '1'='1'

Since '1'='1' is always TRUE, the database effectively evaluates:

(username='admin' AND password='') OR TRUE

And anything OR TRUE is always TRUE so the query returns a row and the login "succeeds," regardless of the actual password.

Why it matters

For an attacker, this is a way to bypass authentication without knowing any real credentials. For a developer, this is the entire reason string concatenation should never be used to build SQL queries with user input. This single character ' is the whole vulnerability.

Practice

Sent the payload ' OR '1'='1 as the password field against the vulnerable form.php login and observed the query bypass the password check entirely, confirmed against index.php, which dumps the raw users table (including plaintext passwords) for comparison.

Vulnerable Code

$query = "SELECT * FROM users
          WHERE username='$username'
          AND password='$password'";

Burp Request & Response (Vulnerable)

BurpSuite

Burp Request & Response (Hardened)

Burp Response


Phase 6 Prepared Statements

Concept

The fix for SQL Injection isn't "sanitize the input better" it's changing how the query and the data are sent to the database in the first place.

With a prepared statement, PHP sends the query structure first, with placeholders instead of real values:

SELECT * FROM users WHERE username=? AND password=?

At this point, MariaDB already parses the query and builds its execution plan before any actual value exists. Only after that does PHP send the parameters separately:

Parameter 1 = admin
Parameter 2 = ' OR '1'='1

MariaDB stores these strictly as string values, not as SQL code. So even if the parameter contains OR, UNION, SELECT, --, #, or ', none of it is interpreted as SQL syntax it's just the literal contents of a string being compared.

In this repo, the hardened login (login.php) uses mysqli_prepare() and mysqli_stmt_bind_param() instead of building a raw query string.

Why it matters

This is why prepared statements are considered the real fix, not a workaround. The query structure and the user data are never mixed together in the same string, so there's nothing left for an attacker to "escape out of."

Practice

Rebuilt the vulnerable login using mysqli_prepare() / mysqli_stmt_bind_param(), then re-tested the same ' OR '1'='1 payload against it and confirmed the login now fails as expected.

Using Prepared Statements

if ($_SERVER["REQUEST_METHOD"] == "POST")
if ($_SERVER["REQUEST_METHOD"] == "POST")
{
    $username = $_POST["username"];
    $password = $_POST["password"];

    $stmt = mysqli_prepare(
    $conn,
    "SELECT * FROM users WHERE username=?"
);

mysqli_stmt_bind_param(
    $stmt,
    "s",
    $username
);
}

First, two normal logins against the hardened login.php, just to confirm it still works for real credentials:

POST /login.php HTTP/1.1
Host: 192.168.100.22
Cookie: PHPSESSID=697o5qtm1l9hkiv295qqjv3cj4

username=admin&password=admin123

HTTP/1.1 302 Found
Set-Cookie: PHPSESSID=7uvh2aj1ns1um56udgbncven2s; path=/
Location: dashboard.php
POST /login.php HTTP/1.1
Host: 192.168.100.22
Cookie: PHPSESSID=mmphd6mq19es7bnhrssurrb5a0

username=wakamiya&password=123456

HTTP/1.1 302 Found
Set-Cookie: PHPSESSID=de5kraq7n1ivkdtisbpjl1t6ck; path=/
Location: dashboard.php

Then the same injection payload that broke form.php earlier, sent against login.php instead:

POST /login.php HTTP/1.1
Host: 192.168.100.22
Cookie: PHPSESSID=mmphd6mq19es7bnhrssurrb5a0

username=wakamiya&password=' OR '1'='1

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

Login Failed

No 302 Found, no new session, no redirect to dashboard.php just Login Failed. The ' OR '1'='1 string got bound as parameter data instead of being parsed as SQL, so it never had a chance to alter the query logic. Same payload, completely different outcome from the form.php test.


Phase 7 Password Hashing

Concept

Even with prepared statements, storing passwords as plaintext (like admin123 sitting directly in the users table) is still a serious problem anyone who reads the database (or dumps it through another bug) instantly has every password.

PHP's built-in functions solve this:

  • password_hash() turns a plaintext password into a one-way hash (this repo uses bcrypt via PASSWORD_DEFAULT).
  • password_verify() checks a plaintext password against a stored hash, without ever needing to "un-hash" it.

Two important properties of bcrypt hashing:

  • Identical passwords produce different hashes. Every hash includes a random salt, so hashing "123456" twice gives two completely different results. This stops attackers from spotting two users with the same password just by comparing hashes.
  • Hashes cannot simply be reversed. Bcrypt is a one-way function by design there's no mathematical inverse to go from hash back to password. The only practical way in is guessing (brute-force / dictionary attacks), which is exactly why bcrypt is deliberately slow.

Why it matters

For a developer, this is the difference between a data breach exposing "some hashes an attacker still has to crack" versus exposing every plaintext password directly. For an attacker, it changes the whole strategy from "read the password" to "try to guess or crack it."

Practice

Generated bcrypt hashes with password_hash() for existing plaintext passwords in the users table, updated the table with the new hashes, and rewrote the login logic to use password_verify() instead of comparing passwords directly in SQL.

Database

MariaDB [webapp]> SELECT * FROM users;
+----+----------+--------------------------------------------------------------+-------+---------------------+
| id | username | password                                                     | role  | created_at          |
+----+----------+--------------------------------------------------------------+-------+---------------------+
|  1 | admin    | $2y$10$2uMdlMAAf1xNqd/wkp0sa.r3TJhsMyQttJBpOblqeh4/CXQJ24ena | admin | 2026-07-11 13:51:58 |
|  2 | wakamiya | $2y$10$UwL/4s.9GhC/ihDPL/14sODTJuHONP/r0OEegs/lDCVYbxCxhr02m | user  | 2026-07-12 03:15:38 |
|  3 | guest    | $2y$10$BfoDB.kopWAyLyLmuKZlCOx1y9NXpl32rEsZ1ECVeBd/RBTqpATia | user  | 2026-07-12 07:16:43 |
+----+----------+--------------------------------------------------------------+-------+---------------------+
3 rows in set (0.000 sec)

Hash Examples

[analyst@secOps config]$ php -a
Interactive shell

php > echo password_hash("guest123", PASSWORD_DEFAULT);
$2y$10$BfoDB.kopWAyLyLmuKZlCOx1y9NXpl32rEsZ1ECVeBd/RBTqpATia
php > exit

Screenshots

Screenshot


Phase 8 Sessions & Cookies

Concept

Once a login succeeds, the server needs a way to remember "this browser is logged in" on every future request HTTP itself doesn't remember anything between requests.

This is what sessions solve:

  • On successful login, PHP's session_start() creates a session and sends the browser a cookie called PHPSESSID.
  • The browser only ever stores this session ID (e.g. PHPSESSID=abc123) nothing else.
  • The server stores the actual session data (username, role, user ID, etc.), tied to that session ID.
  • On every following request, the browser automatically attaches the PHPSESSID cookie, and the server uses it to look up who's making the request.

Why copying a PHPSESSID may hijack a session: the server doesn't check who sends a session ID only whether it's a valid one. If an attacker steals a valid PHPSESSID (through XSS, network sniffing, etc.) and sends it themselves, the server will treat them as the original logged-in user.

Session Fixation is a related but different attack: instead of stealing an existing session ID, an attacker tricks a victim into using a session ID the attacker already knows before the victim logs in. This repo's login calls session_regenerate_id(true) right after a successful login, which issues a brand-new session ID at that moment invalidating any session ID that existed beforehand, and closing that window.

Logout, done correctly (see logout.php), calls session_destroy() on the server. Once the session is destroyed server-side, the old PHPSESSID cookie in the browser is worthless the server no longer has any data attached to it.

Why it matters

Sessions are the backbone of "being logged in" on the web, and stealing a session ID is often just as powerful as stealing a password sometimes more so, since it skips authentication entirely. Understanding this is core to grasping session hijacking, XSS-driven cookie theft, and why secure cookie flags (HttpOnly, Secure) exist.

Practice

Inspected the Set-Cookie: PHPSESSID=... header after login using Burp Suite, observed the same cookie being sent automatically on the next request, and tested session_regenerate_id(true) behavior by comparing the session ID before and after login.

Output

{
        $user = mysqli_fetch_assoc($result);

        session_regenerate_id(true);

        $_SESSION["username"] = $user["username"];
        $_SESSION["role"] = $user["role"];

        header("Location: dashboard.php");
        exit;
    }

Cookie Screenshots

Screenshot

PHP creates a new session and sends its identifier back to the browser through the Set-Cookie response header.


Phase 9 Authorization (Admin Panel)

Concept

Authentication answers "who are you?" it's the login step. Authorization answers a completely different question: "what are you allowed to do, now that we know who you are?"

Being logged in (isset($_SESSION["username"])) is not the same as being an admin. In admin.php, access requires two separate checks:

if(!isset($_SESSION["username"])) {
    header("Location: login.php");
    exit;
}
if($_SESSION["role"] != "admin") {
    die("Access Denied");
}

The first check is authentication (are you logged in at all). The second check is authorization (does your role allow this specific page).

Why it matters

A huge category of real-world vulnerabilities broken access control comes from developers checking authentication but forgetting authorization, or checking it in the wrong place (like only in the frontend). Understanding that these are two separate checks is the first step toward finding (or fixing) privilege escalation bugs.

Practice

Logged in as a normal user role account and confirmed admin.php blocks access with "Access Denied," then compared behavior against the admin role account.

Screenshots

Screenshot Screenshot

Admin Privilege Screenshot Screenshot

What I Learned

Before building this lab, SQL Injection felt like memorizing payloads I knew ' OR '1'='1 "worked" without really knowing why. After writing the vulnerable login myself and watching the exact string PHP builds, that changed. It's not a trick, it's just a string closing early. Same with prepared statements I used to treat them as a checkbox best practice. Now I actually get why sending the query structure and the data separately is the only thing that closes the gap, not just a safer habit.

Sessions were the same story. PHPSESSID used to be "that cookie thing" in the background. Once I traced it from session_start() on login to session_destroy() on logout, and watched it change in Burp after session_regenerate_id(), it stopped being abstract.

The main thing that changed is the question I ask now. Instead of "what payload should I use?" I find myself asking "how is the application actually processing my input?" That question doesn't come from a wordlist it comes from having built the backend logic myself and knowing what it's doing with what I send it.


Repository Status

This repository represents my Web Fundamentals learning journey understanding HTTP, PHP, databases, authentication, sessions, and authorization from the ground up before moving into more advanced web vulnerabilities.

About

Building and breaking a vulnerable PHP web application to understand Web Fundamentals and Web Security concepts.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages