Final Remarks#
We’ve covered a lot of topics in this course — cryptography mishaps, web exploitation, memory corruption, network attacks, and the unavoidable and fundamental challenge of humans. In this final lecture, we’ll talk about some of those things in greater depth and distill these topics into practical guidance for making a bit more secure applications.
There’s no magic checklist that makes software “secure”. As we established in the introduction, perfect security is unattainable given the complexity of modern systems. The goal is to build software that’s resilient — systems that raise the cost of attack, limit blast radius when something fails, and recover gracefully from incidents.
Weird Machines#
Although we have already touched a bit on this theoretical concept, we want to return to it more explicitly, because we find it useful and fascinating (and we, here at Matfyz, just love theory). Weird machines are theoretical framework for understanding exploitation. We are using the definitions from the Weird Machines, Exploitability, and Provable Unexploitability paper, which we recommend reading.
Let us for a moment consider all computer users as programmers, and all the programs or applications they are using as emulators (also could be called “interpreters” or “virtual machines”) executing the input as their “program code”. For example, an image viewer can be seen as an emulator executing the “instructions” from a PNG or JPG file, that tell it how it should draw the image part by part, resulting in the viewer displaying the image. Or a web browser interpreting a web page where the result of the program is the page being rendered. This way every file, or even every user input, is “code” for some emulator. What differs is the code’s strength. Some are weak, like a simple .txt file or a PNG. Some are strong, a HTML+CSS for a web browser (even without JavaScript), Python and other programming languages. This strength is determined by complexity of the emulator that the user’s “code” programs. All these emulators can be modeled as finite state machines. This is because computers are finite, so even Turing-complete programming language running on a finite computer can be theoretically modeled this way.
These state machines are the Intended Finite State Machines, the computing framework for the user the developer meant to built. Everything outside this machine is a “weird state”. Once in a weird state, the user’s “code” now executes on the weird (state) machine. It’s unclear what computational power it has — it might be trivially weak, or Turing-complete. With that, it’s also unclear how useful it is - it could be just an annoying bug, or a critical vulnerability.
Exploitation, then, is the process of 1) entering a weird state, and 2) programming the weird machine to whatever the attacker wants, mostly to violate security properties.
This abstraction maps nicely to some of the vulnerabilities we’ve studied, here is one (a bit simplified) example:
# query is user controlled
result = db.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")Here, entering a weird state is just a matter of a single ', then we can program the weird machine to do whatever, DROP TABLE, UNION SELECT from other tables, etc. The whole SQL is the weird machine
This weird machine is powerful, but also quite trivial. An example of a powerful nontrivial weird machine is Return Oriented Programming, where the attacker makes their own instruction set out of the gadgets available.
Another real-world example is the JBIG2 virtual machine used in the FORCEDENTRY iOS iMessage exploit, to quote the Project Zero article about it:
JBIG2 doesn’t have scripting capabilities, but when combined with a vulnerability, it does have the ability to emulate circuits of arbitrary logic gates operating on arbitrary memory. So why not just use that to build your own computer architecture and script that!? That’s exactly what this exploit does. Using over 70,000 segment commands defining logical bit operations, they define a small computer architecture with features such as registers and a full 64-bit adder and comparator which they use to search memory and perform arithmetic operations. It’s not as fast as Javascript, but it’s fundamentally computationally equivalent.
The bootstrapping operations for the sandbox escape exploit are written to run on this logic circuit and the whole thing runs in this weird, emulated environment created out of a single decompression pass through a JBIG2 stream. It’s pretty incredible, and at the same time, pretty terrifying.
Practical Tips#
Threat Model and Define Security Boundaries Well#
Before writing code, think about who might attack your system and what they’re after. This is threat modeling — considering what threats are most relevant to you, what assets you want to protect, and how attackers might approach. As we covered in fundamentals: we set our security barriers, tweak our focus, our preventive measures, and our monitoring based on this analysis.
You don’t need a formal process. Start with simple questions: Who are the likely attackers? What’s valuable in this system? What’s exposed to the internet? What happens if component X is compromised?
Security boundaries are the lines you draw around components that handle different trust levels. The browser’s Same Origin Policy is a good example — it prevents JavaScript on evil.com from accessing data on banking.com. Your application needs similar boundaries. If your web server gets hacked, can the attacker reach your database directly? If one user’s session is hijacked, can they access other users’ data?
Define these boundaries explicitly. Separate concerns into distinct services or processes where practical. Network segmentation, container isolation, and privilege separation are all mechanisms for enforcing these lines.
Now you might also consider any violation of a security boundary to be a security issue worth fixing, even though it might not have a direct impact (i.e., its exploitation is not possible because of some other security boundary).
The goal isn’t perfect isolation (which is often impractical), but limiting blast radius. When something fails, the damage should be contained.
Defense in Depth#
This principle extends to layering your defenses. Sometimes called the Swiss cheese model — stack enough slices and the holes stop lining up — or the onion model, the idea is that no single security measure should be your only protection.
For it to be effective, the layers must actually be independent. Multifactor authentication works because it combines different types of verification. If your “multiple factors” are just multiple passwords (or “security questions”), you haven’t added meaningful depth — you’ve just made the same layer thicker.
Try applying this thinking everywhere. Validate input on the frontend for user experience, but always validate on the backend too. Use Content Security Policy even if you’re confident you’ve eliminated all XSS — assume any layer might fail. Compile with all binary protections even when you believe there are no memory corruption bugs. Working with super sensitive data? Maybe encrypt the data before storing it in the database with a suitable secret. Hashing passwords is also a defense in depth.
If the attacker compromises an administrator account in your application, for example with phishing (like it happened to Twitter in 2020), do they really become unstoppable now or are there some measures you can take to prevent that? Maybe good monitoring and alerts on unusual activity might help a bit at least?
It’s always safer to consider any input or data as untrustworthy, and thus potentially coming from an attacker, even if that should not be possible because of some other security boundary.
Fail Closed#
When your program encounter errors or edge cases, they should default to a secure state rather than an open one. If authorization logic throws an exception, deny access rather than granting it. If a configuration value is missing, refuse to start rather than running with unsafe defaults.
Error handling requires care. Generic error messages to users prevent information leakage — stack traces and detailed database errors are valuable reconnaissance for attackers (although they are useful for debugging).
For example, authentication failures should ideally be indistinguishable whether the user doesn’t exist or the password is wrong; otherwise, you’ve enabled account enumeration (or, there should least be some rate limits in place to protect against it).
# Leaky - reveals which users exist
if not user_exists(username):
return "User not found"
if not check_password(username, password):
return "Wrong password"
# Better - reveals nothing
if not authenticate(username, password):
return "Invalid credentials"The same goes for default configurations. Application, libraries, user accounts, etc. should have secure (sometimes in the sense of “conservative”) configurations by default. For example, remember the PyYAML’s load().
Less Is Often More#
Every feature, endpoint, and line of code is attack surface.
Don’t need full XML parsing? Maybe don’t include a full XML library, or limit its features. The web server doesn’t need root privileges? Don’t give them to it, give it the least privilege needed. The internal admin interface will be used only by people inside the company? Maybe keep it off the public internet.
If you keep code simple and elegant1, it tends to have fewer bugs and vulnerabilities, even if just because of the fact it is easier to understand and audit.
Memory-Safe Languages Exist#
A substantial portion of critical vulnerabilities — buffer overflows, use-after-free, double-free — exist only because C and C++ require manual memory management. Languages like Rust, Go, Java, Python, and JavaScript eliminate these vulnerability classes by design2.
Don’t Ever Hardcode Secrets and Remember to Set Them to Random Values#
We think many of our dear readers would be surprised by how many printers, routers, web and mobile apps, etc., have some hardcoded credentials inside them. Whether it’s a password or an API key, it often is a security problem.
They’re very easy to forget, when sharing the project with others.
For example, environment variables in a .env that is ignored by Git will be an effective way of managing secrets for most projects.
change-me is a known secret someone will try. Always ensure these are changed for production.
Dependencies Are Other People’s Code#
As we covered in the introduction, modern software is built on substantial dependency trees. Each dependency is code you didn’t write but must implicitly trust.
Pay Attention to the Interactions Between Your Code and Dependencies#
What assumptions does the library have about its input and its output? Reading the documentation, or the source code, when necessary, will answer those.
Can You Trust It, Anyway?#
Before adding a dependency, consider:
- Who maintains this? Random person vs. well-funded team vs. Big Corp?
- Is it alive? When was the last commit? Do issues get addressed?
- Security practices? Is there a security policy? A way to report vulnerabilities?
- Dependency tree? That “simple” package might drag in 200 transitive dependencies
- Can you read it? Open source? Comprehensible?
Especially in the age of vibe coding, the AIs will try to coerce a lot of dependencies including some completely hallucinated ones. Installing some of those hallucinated packages might get you easily pwned.
Keep It Updated#
Especially when using whole frameworks or substantial libraries, it’s crucial to keep them up to date, to get all the vulnerability fixes as fast as possible, ideally even before they are publicly disclosed.
We All Will Make Mistakes#
If you think you, your genius colleague, or your boss, won’t make mistakes, you are wrong.
This includes developers, as well as users and administrators. Developers will introduce vulnerabilities, users will get phished.
The goal is to make it as hard as possible to make mistakes and if a mistake is made, to be able to recover from it in the most pleasant way possible.
The goal is to manage this risk.
It’s also advisable to save your face, when you eventually do make one. Writing a blameless post-mortem explaining what exactly what happened and why it happened to your stakeholders (users, customers, higher ups, …) is a good practice. Especially if their stake was somehow endangered.
Security Testing & Code Review#
Do security-focused code reviews of your own or your colleagues’ code. It’s a good exercise and it’s fun.
On bigger codebases, it might be a good idea to deploy static application security testing analyzers (SASTs). With the raise of LLMs, a lot of new tools will come up to autonomously scan for security issues.
Visibility: Logging and Monitoring#
If you cannot prevent it, it’s good to at least detect it. When something goes wrong, logs are often the only way to understand what happened.
What to log:
- Authentication attempts (successful and failed)
- Authorization failures and access to sensitive resources (user X tried accessing Y)
- Administrative actions (password changes)
- Unusual patterns (rate limit hits, malformed requests)
But do not log sensitive details, such as passwords or API keys. Keep in mind that even the logs can end up in the wrong hands.
Logs are only useful if someone’s watching them. Consider alerting on suspicious patterns: login failures from unusual locations, privilege escalation attempts, or access spikes.
External Security Testing#
Bringing external review to evaluate your security is invaluable — fresh eyes find things you miss.
Penetration testing involves hiring professionals to systematically attack your systems. It’s a focused engagement with a defined scope and timeline. Approaches vary by how much information testers receive upfront: black box testing simulates an external attacker with no prior knowledge, gray box provides partial information like user accounts or documentation, and white box gives full access to source code and architecture. This might even include social engineering and physical security (of your company’s office etc.).
Security audits provide deep and systematic analysis of the codebase. For critical systems — financial applications, healthcare, cryptocurrency — this formal review identifies issues that might not be found through pentesting alone (and vice versa). However, it’s usually done just for legal compliance reasons :) On top of real vulnerabilities, it can also result in architectural change suggestions that are not direct vulnerabilities, but are not best practice or forbidden by some legal documents.
Bug bounty programs offer a different model: incentivizing security researchers to find and report vulnerabilities continuously. Platforms like HackerOne, Bugcrowd, and Intigriti facilitate these programs. The advantages include payment tied to results, access to diverse skill sets, and ongoing testing as your application evolves. However, they require clear scope definition and generate triage overhead from invalid reports.
As we already discussed, even if you are not willing to pay for security issues, it’s a good idea to have a public vulnerability disclosure policy. Make it straightforward for the public (and private) reporters to file issues:
- Publish a
security.txtfile (securitytxt.org) on your website. - Maintain a clear
SECURITY.mdin your repository. - Provide a secure communication channel (encrypted email, bug bounty platforms, GitHub security issues form, etc.)
- Respond promptly and professionally and provide legal safe harbor for good-faith reporters.
Wrapping Up#
Thanks for attending this course. We hope you learnt something new and that it will help you avoid some of the pitfalls we talked about throughout the semester. Or maybe help other people find the pitfalls they have fallen into.
See you around!
TLDR#
- Weird machines are a theoretical framework for understanding exploitation: entering a weird state and programming the weird machine to violate security properties.
- Threat modeling helps you understand who might attack your system and what they’re after — define security boundaries explicitly to limit blast radius.
- Defense in depth — layer independent security measures to protect the valuable assets.
- Fail closed — when errors occur, default to a secure state (deny access). Opaque errors, while terrible for debugging, might lower some security risks.
- Less is more — minimize attack surface by reducing features, privileges, and complexity.
- Memory-safe languages eliminate entire vulnerability classes by design.
- Never hardcode secrets.
- Dependencies are other people’s code — evaluate trustworthiness, check for maintenance, and keep them updated.
- We all make mistakes — design systems to make mistakes hard and recovery easy.
- Security testing & code review — perform security-focused reviews.
- Logging and monitoring — if you can’t prevent it, at least detect it — log authentication, authorization, and unusual patterns (but never sensitive data).
- External security testing (pentests, audits, bug bounties) brings fresh eyes and finds what you miss. Before malicious attackers do.