Security Fundamentals · lesson 2 of 8
Beginner
4Intermediate
4Also part of Security Fundamentals
12- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Database Security from SQL
- + ACL: a User Restricted to One Key Pattern and One Command from Redis
- + Kafka Security: SASL Authentication and a Real ACL Denial from Kafka
- + IAM and Least Privilege from Cloud Interviews
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + Docker Security from Docker
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + Hashes and HMACs: What Each One Actually Proves from Cryptography
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
The CIA Triad, Demonstrated on One Machine
See the CIA triad fail and then hold: a sha256sum mismatch, Permission denied on a 0600 secret, and a capped server refusing a third client.
By SkillByExample · Published
Confidentiality, integrity and availability, the CIA triad, are usually taught as a slide. Here each one
fails on a machine you control, and then a small control makes it hold. The shell examples run as root in a
throwaway container, because they create users. Start one with
docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash and run each script inside it. They
were run with Python 3.12.15 on Debian 13.7, coreutils 9.7 and Docker Engine 29.8.2.
Integrity: a one-byte change breaks the checksum
Integrity means data isn’t changed without someone noticing. A SHA-256 checksum records what the file
looked like, and sha256sum -c checks it later.
# integrity.sh
echo "pay alice amount=100" > payment.txt
sha256sum payment.txt > payment.sha256
sha256sum -c payment.sha256
sed -i 's/100/900/' payment.txt # change one byte: 1 -> 9
cat payment.txt
sha256sum -c payment.sha256
echo "exit code: $?"
$ sh integrity.sh
payment.txt: OK
pay alice amount=900
payment.txt: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
exit code: 1
Changing a single byte produces a completely different digest, so the check fails, and it fails with a
non-zero exit code that a deploy script or cron job can act on. Two limits matter, though. The checksum
only detects the change; it doesn’t prevent it or restore the original. And anyone who can rewrite
payment.txt and payment.sha256 together can make the check pass again. The first practice task fixes
that with a key.
Confidentiality: the default permissions leak the secret
Confidentiality means only the right people can read the data. Here alice owns an application secret and
bob is another account on the same machine.
# confidentiality.sh
useradd alice && useradd bob
echo "DB_PASSWORD=FAKE-example-not-real" > /srv/app.env
chown alice /srv/app.env
chmod 644 /srv/app.env # the default for a new file
su bob -c 'cat /srv/app.env'
chmod 600 /srv/app.env # owner only
su bob -c 'cat /srv/app.env'
su alice -c 'cat /srv/app.env'
ls -l /srv/app.env
$ sh confidentiality.sh
DB_PASSWORD=FAKE-example-not-real
cat: /srv/app.env: Permission denied
DB_PASSWORD=FAKE-example-not-real
-rw------- 1 alice root 34 Oct 13 18:13 /srv/app.env
The first line is the failure: that’s bob reading the secret. With mode 644, the usual result of a
default umask of 022, he reads the password without any special privilege. Every other account on the
host can too, including any service account an attacker might compromise. After chmod 600, the second
line shows the kernel refusing bob, and the third shows it still letting alice read the file. Nothing was encrypted: the operating system’s access control did all the work. Least
privilege at the file level is taught in depth in
Linux Users and Permissions.
Availability: one idle client stalls a server
Availability means the service keeps answering the people entitled to use it. This server handles one client at a time and waits as long as it takes for a request.
# availability_bad.py
import socket, threading, time
srv = socket.create_server(("127.0.0.1", 9000))
def serve(): # one client at a time, no timeout
while True:
conn, _ = srv.accept()
conn.recv(100) # waits for the client to send a request
conn.sendall(b"OK\n")
conn.close()
threading.Thread(target=serve, daemon=True).start()
idle = socket.create_connection(("127.0.0.1", 9000)) # connects, never sends
time.sleep(0.2)
c = socket.create_connection(("127.0.0.1", 9000), timeout=2)
c.sendall(b"GET\n")
try:
print("second client got:", c.recv(100))
except TimeoutError as e:
print("second client:", e)
$ python availability_bad.py
second client: timed out
The second client did nothing wrong. Its connection was accepted by the kernel, its request was sent, and
then it waited two seconds for an answer that never came, because the only handler is stuck in
recv waiting on the idle client. No data was stolen or changed, yet the service failed: that’s an
availability loss. One connection that never speaks was enough.
Availability: a connection cap and a read timeout
The fix makes three changes. Each connection gets its own handler thread, so one slow client no longer blocks the accept loop. A cap allows at most two connections at a time and refuses the next one fast. A one-second read timeout frees the slot of a client that goes quiet. The cap only matters because of the threads: a single handler already serves one client at a time.
# availability_cap.py
import socket, threading, time
srv = socket.create_server(("127.0.0.1", 9000))
slots = threading.BoundedSemaphore(2) # at most 2 clients at once
def handle(conn):
conn.sendall(b"OK\n")
conn.settimeout(1) # idle clients are dropped after 1 s
try: conn.recv(100)
except TimeoutError: pass
finally: conn.close(); slots.release()
def serve():
while True:
conn, _ = srv.accept()
if not slots.acquire(blocking=False):
conn.sendall(b"BUSY\n"); conn.close(); continue
threading.Thread(target=handle, args=(conn,)).start()
threading.Thread(target=serve, daemon=True).start()
start, clients = time.monotonic(), []
for i in range(1, 5):
if i == 4: time.sleep(1.5) # let the idle clients time out
clients.append(socket.create_connection(("127.0.0.1", 9000), timeout=2))
print(f"t={time.monotonic() - start:.1f}s client {i}:", clients[-1].recv(100))
$ python availability_cap.py
t=0.0s client 1: b'OK\n'
t=0.0s client 2: b'OK\n'
t=0.0s client 3: b'BUSY\n'
t=1.5s client 4: b'OK\n'
Clients 1 and 2 take both slots and then sit idle, just like the stalling client before. Client 3 is
refused at once with BUSY instead of hanging. A fast “no” is better for availability than a slow
nothing, because the caller can retry or fail over. After a second, the read timeout drops the two idle
clients and releases their slots, so client 4 is served normally. Real servers apply the same two bounds:
nginx has worker_connections and client_body_timeout, and PostgreSQL has max_connections and
idle_session_timeout.
Practice
- Make the integrity check survive an attacker. Replace the plain checksum with an HMAC-SHA256 tag computed with a key the attacker doesn’t have. Show that a tampered message is rejected even when the attacker supplies a freshly computed SHA-256 of their own.
Solution
# hmac_check.py
import hashlib, hmac
KEY = b"FAKE-demo-key-keep-real-keys-out-of-code"
def tag(data: bytes) -> str:
return hmac.new(KEY, data, hashlib.sha256).hexdigest()
original = b"pay alice amount=100"
stored_tag = tag(original)
tampered = b"pay alice amount=900"
forged_tag = hashlib.sha256(tampered).hexdigest() # attacker has no key
for data, t in [(original, stored_tag), (tampered, stored_tag), (tampered, forged_tag)]:
ok = hmac.compare_digest(tag(data), t)
print(data.decode(), "->", "OK" if ok else "REJECTED")
$ python hmac_check.py
pay alice amount=100 -> OK
pay alice amount=900 -> REJECTED
pay alice amount=900 -> REJECTED
The attacker can compute a SHA-256 of anything, but not the HMAC, because that needs KEY.
hmac.compare_digest compares in constant time, so response timing doesn’t reveal how much of a guessed
tag was right. In a real system the key comes from a secret store, not from source code.
- Share the secret with one group, not everyone. Let members of a
deploygroup readapp.env, keep everyone else out, and prove it with one member (bob) and one non-member (carol).
Solution
# group_read.sh
useradd alice && useradd bob && useradd carol
groupadd deploy && usermod -aG deploy bob
echo "DB_PASSWORD=FAKE-example-not-real" > /srv/app.env
chown alice:deploy /srv/app.env
chmod 640 /srv/app.env
su bob -c 'cat /srv/app.env'
su carol -c 'cat /srv/app.env'
ls -l /srv/app.env
$ sh group_read.sh
DB_PASSWORD=FAKE-example-not-real
cat: /srv/app.env: Permission denied
-rw-r----- 1 alice deploy 34 Oct 13 17:54 /srv/app.env
Mode 640 is owner read-write, group read, others nothing. Granting access through a group, not by
widening “others”, keeps the list of readers explicit: the members of deploy.
- Find what a checksum manifest misses. Record checksums for every
.conffile in a directory, then add a new.conffile instead of editing one. Doessha256sum -cnotice? Add a check that does.
Solution
# manifest.sh
mkdir -p conf && cd conf
echo "port=8080" > app.conf
echo "level=info" > log.conf
sha256sum *.conf > ../manifest.sha256
echo "debug=true" > backdoor.conf # a new file, not an edit
sha256sum -c ../manifest.sha256
ls *.conf | sort > /tmp/now
cut -d' ' -f3 ../manifest.sha256 | sort > /tmp/known
comm -13 /tmp/known /tmp/now | sed 's/^/NOT IN MANIFEST: /'
$ sh manifest.sh
app.conf: OK
log.conf: OK
NOT IN MANIFEST: backdoor.conf
sha256sum -c only checks the files listed in the manifest, so it reports a clean result while a new file
sits next to them. An integrity check has to cover the full set of files as well as their contents. File
integrity monitoring tools such as AIDE track both.
- Predict, then raise the cap. Change the semaphore in
availability_cap.pytoBoundedSemaphore(3). Before running it, predict what client 3 receives.
Solution
slots = threading.BoundedSemaphore(3) # at most 3 clients at once
$ python availability_cap3.py
t=0.0s client 1: b'OK\n'
t=0.0s client 2: b'OK\n'
t=0.0s client 3: b'OK\n'
t=1.5s client 4: b'OK\n'
All four clients are served: three fit under the cap, and the fourth arrives after the timeouts have freed the slots. The cap is a capacity decision. Set it from what the server can really handle, or it either refuses legitimate users or fails to protect the server.