All posts
VibeZero Team7 min read

Default credentials, explained

Default passwords built a botnet that took down half the internet. Why they survive in AI-generated stacks, and the compose and seed patterns that fail closed.

  • security
  • vibe coding
  • engineering

A default credential is a username and password that ships with the software, is documented publicly, and works until somebody changes it. It is the least interesting vulnerability in this entire blog and one of the most consequential, because exploiting it requires no skill, no tooling, and no bug: the attacker logs in.

The identifier is CWE-1392, use of default credentials. CISA lists default passwords in its Bad Practices catalog as an unacceptable practice for software supporting critical infrastructure, and eliminating them is one of the named principles in the international Secure by Design guidance. That level of attention is unusual for something this simple, and it is deserved.

Its close relative is CWE-798, hardcoded credentials, which covers the credential your own code carries to reach a service it depends on. The two overlap in the middle (a default password compiled into a product is both) and separate at the edges: a default is one an operator is expected to change, while a hardcoded one is a constant nobody was ever offered a way to change.

The proof: Mirai

In September 2016 the Mirai botnet took KrebsOnSecurity offline with a 620 Gbps flood, and in October it attacked the DNS provider Dyn, which is why Twitter, Reddit, Spotify, and GitHub were unreachable for much of a day for users in the eastern United States. The US Department of Justice case documents record the scale.

The mechanism, once the source code was published, turned out to be anticlimactic. Mirai scanned the internet for open telnet, then tried a hardcoded table of about sixty username and password pairs: root:xc3511, root:vizxv, admin:admin, support:support, root:1234. That was the exploit. Every pair was a vendor default printed in a manual.

The lesson is not about IoT. It is that a default credential is a precomputed vulnerability. There is no window between disclosure and exploitation, because there is no disclosure: the credential was published as documentation on the day the product shipped.

That precomputed quality is what it shares with credential stuffing, and the two are worth reading together because the difference between them is only where the table came from. Mirai’s sixty pairs were printed in vendor manuals. The lists being replayed against consumer logins today were printed by somebody else’s breach. In both cases the attacker arrives holding the answer, so every control that assumes guessing takes time is measuring the wrong thing.

Where it actually lives in a modern stack

Almost nobody deploys a telnet-enabled camera. The same class arrives through three other doors, and AI-generated projects walk through all three, because a model asked for “a docker compose with Postgres and Redis” produces the file that appears most often in its training data, and the file that appears most often is a tutorial.

Infrastructure defaults in compose files. This is the common one:

# Vulnerable, and it will run perfectly, which is the problem.
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
  redis:
    image: redis:7
    ports:
      - "6379:6379"

Two findings. POSTGRES_PASSWORD: postgres is a published default. And the Redis service has no password at all, because Redis has never required one: protected-mode refuses external connections only while no bind address and no password are configured, and publishing the port disarms that safety. Both services are bound to the host on all interfaces by ports:, so on a VPS without a firewall they are on the public internet.

That last combination is what produced the January 2017 MongoDB ransom wave, when tens of thousands of internet-exposed databases that required no authentication were wiped and held for ransom. MongoDB changed its defaults afterward, which fixed MongoDB and did nothing about the habit.

Admin panel defaults. Grafana ships admin:admin, MinIO ships minioadmin:minioadmin, Keycloak, Airflow, Portainer, and Jenkins all have a first-boot identity. Each is a documented default and each one is scanned for continuously.

Seeded application accounts. The one that is genuinely native to AI-assisted development. Ask a model for a working admin area and you often get a seed script:

// Vulnerable. Ships to production the first time you run migrations there.
await db.user.upsert({
  where: { email: "admin@example.com" },
  create: { email: "admin@example.com", password: await hash("admin123"), role: "ADMIN" },
  update: {},
});

This is worse than a Grafana default, because there is no vendor advisory telling you it exists, no login banner nagging you to change it, and the email address is guessable in about three attempts. It also survives a password rotation policy, because nobody knows the account is there.

The fix is to fail closed

The reason defaults survive is that they make things work. Every remediation below is the same idea applied in a different place: make the missing secret an error rather than a fallback.

Compose: require the variable. Docker Compose has syntax for this, and almost nobody uses it. ${VAR:?message} aborts the whole command when VAR is unset or empty:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    # No `ports:`. Only services on this network reach the database.
    expose:
      - "5432"
  redis:
    image: redis:7
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"]
    expose:
      - "6379"

Two changes, and the second matters as much as the first. Dropping ports: for expose: means the database is reachable from the application container and from nothing else, so a credential mistake stops being remotely exploitable. Publishing a database port to the host is the decision that turns every other item in this post from a local problem into an internet problem.

Application config: no default in code. In Node:

function required(name) {
  const value = process.env[name];
  if (!value) throw new Error(`missing required env var: ${name}`);
  return value;
}

const sessionSecret = required("SESSION_SECRET"); // not `?? "dev-secret"`

The ?? "dev-secret" fallback is the same bug as admin:admin, wearing a hoodie. It is worse in one respect: a hardcoded signing secret lets an attacker mint valid sessions rather than merely log in as one account.

Seeds: gate them, and make the gate default to off. The rule we hold ourselves to in the control plane is that the demo seeding flag (DEMO_SEED) is off by default everywhere, development included, and the demo workspace it creates is read-only at the middleware layer rather than by convention, so a write route added later is refused for the demo without anyone remembering to handle it. A seed that only runs when someone deliberately turns it on cannot be shipped by accident.

If you do need a first-run administrator, generate the password at first boot, print it once to stdout, and store only its hash. That is the pattern most security-conscious images have converged on, and it costs one extra line compared to a constant.

Rotate what has already leaked. If a default was ever live in production, changing it is not enough on its own: anything created while it was live (sessions, API keys, tokens signed with a default secret) has to be revoked too. A credential that was public is public forever, and that includes anything it minted.

What a scanner can and cannot see

Our worker catches parts of this and not others, and the split is worth knowing because it tells you which checks stay manual.

  • Trivy (app/engines/trivy.py), in the config layer, reads Dockerfiles, compose files, and Kubernetes manifests, which is where the infrastructure defaults live.
  • Gitleaks and TruffleHog (gitleaks.py, trufflehog.py) find credentials committed to the repository, including the seed scripts above when the password is a recognizable pattern. Two limits are worth knowing: they read the checked-out tree rather than the full history, which secrets in git history covers, and we run TruffleHog with --no-verification on purpose, so a finding says a credential is present and not that we went and tried it against the provider.
  • OpenGrep (opengrep.py) catches the ?? "dev-secret" shape, where a configuration lookup has a hardcoded fallback.

What none of them can see is whether a documented vendor default is still live on the running service, because that requires authenticating to it, and authenticating to a host we have not proven you own is not something a scanner should ever do unasked. That check belongs to you, and the curl in the callout above is how you run it.

The uncomfortable part

Default credentials are the vulnerability class with the widest gap between how seriously people take it and how often it is the actual root cause of an incident. It has no interesting exploit to read about, so it does not get written up, so it stays under-rated, so it stays present. Mirai did not need a zero-day to knock a third of the consumer internet offline. It needed a list.

ShareXLinkedIn