Bash and Linux

Audit SSH Config

mediumUsers and permissions

Problem statement

Check an SSH server config for risky settings, the way sshd itself reads it, and check that the SSH key files have safe permissions. SSH is the front door of almost every Linux server, so "is root login on?", "are passwords allowed?" and "are the keys locked down?" come up in every hardening review.

sshd_config

Bash
# Example sshd_config
Port 22
#PermitRootLogin no
PermitRootLogin yes
PasswordAuthentication yes
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding yes
Match User backup
PermitEmptyPasswords yes

.ssh folder

TEXT
.ssh/id_ed25519 600 private key
.ssh/id_ed25519.pub 644 public key
.ssh/id_rsa 644 private key
.ssh/authorized_keys 664 allowed public keys

Do two checks:

  1. Work out the value sshd really uses for PermitRootLogin, PasswordAuthentication and PermitEmptyPasswords, and mark yes as RISKY. If a setting is not in the file, use the default and say so.
  2. Report each private key as OK if its mode is 600 or 400, and report authorized_keys as BAD if the group or others can write to it.

Expected output:

TEXT
== effective settings (first match wins) ==
permitrootlogin yes RISKY
passwordauthentication yes RISKY
permitemptypasswords no ok (default)
== key file checks ==
OK 600 .ssh/id_ed25519
BAD 644 .ssh/id_rsa (private key should be 600)
BAD 664 .ssh/authorized_keys (group or others can write)

Hints

Hint 1: sshd skips comment lines, treats keywords in any case, and keeps the first value it sees for each keyword. A later line does not override an earlier one.

Approach

Optimal: awk first match, stat checks

Covers: how sshd_config is read, awk with tolower, exit and END, stat -c '%a', find -perm /022, [[ ]] tests, glob loops.

How sshd reads its config. The rules are short, and they surprise many people:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB L["read the next line"]:::gray --> C{{"comment or empty?"}}:::gray C --> K(["match the keyword
in any case"]):::blue K --> F(["keep the first value,
ignore later ones"]):::green F --> M(["stop at Match:
per-user rules"]):::yellow classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
  1. Lines starting with # and empty lines are skipped. #PermitRootLogin no does nothing.
  2. Keywords are not case-sensitive: PermitRootLogin, permitrootlogin and PERMITROOTLOGIN are the same.
  3. For most settings, the first value wins. In the sample, PasswordAuthentication yes comes first, so the later no is ignored. Many people add a no at the bottom and think they are safe.
  4. A Match line starts a block that applies only to the users, groups or addresses it names. Settings above the first Match are the global ones.
  5. A setting that is missing uses its built-in default.

What the three settings mean.

Setting Risky value Why Default
PermitRootLogin yes anyone who guesses or steals the root password gets full control prohibit-password (root only with a key)
PasswordAuthentication yes passwords can be guessed by bots trying thousands per hour yes
PermitEmptyPasswords yes an account with no password can log in with nothing no

Notice that PasswordAuthentication defaults to yes. A config that says nothing about it still allows passwords.

Key file permissions. SSH protects you from yourself here:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart LR subgraph OK["Accepted"] direction TB K1["id_ed25519
600"]:::green --> O1(["ssh uses it"]):::green end subgraph NO["Rejected or unsafe"] direction TB K2["id_rsa
644"]:::red --> O2(["ssh refuses it"]):::red A1["authorized_keys
664"]:::red --> O3(["others could
add a key"]):::red end OK ~~~ NO classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style OK fill:transparent,stroke:#059669,stroke-width:2px style NO fill:transparent,stroke:#dc2626,stroke-width:2px
  • A private key (id_ed25519, id_rsa) must be readable only by you: 600 or 400. If the group or others can read it, ssh refuses to use it.
  • authorized_keys lists the public keys allowed to log in as you. If someone else can write to it, they can add their own key and log in as you. sshd checks this too, and can refuse logins when the file or the .ssh folder is writable by others.
  • Public keys (.pub) are meant to be shared, so 644 is fine.

Walking through the code. The # Setup: lines only create the sample config and key files, so skip past them.

  1. The awk program reads the config line by line.
    • /^[[:space:]]*#/ || NF == 0 { next } skips comments (even indented ones) and empty lines.
    • tolower($1) == "match" { exit } stops at the first Match block. In awk, exit jumps straight to the END block, so the report still prints.
    • if (!(k in seen)) seen[k] = ... stores a keyword's value only the first time, which copies sshd's "first value wins" rule.
    • In END, a short list holds each setting and its default. For each, the code prints the value it found, or the default with a (default) note, and marks yes as RISKY.
  2. The for f in .ssh/id_* loop visits every key file. [[ $f == *.pub ]] && continue skips public keys. stat -c '%a' reads the mode, and [[ ]] compares it with 600 and 400.
  3. For authorized_keys, find "$f" -perm /022 prints the file only if group write (020) or others write (002) is on. [[ -n ... ]] is true when that output is not empty.

Edge cases. sshd also accepts Keyword=value and tabs between the two parts, which this simple script does not handle; the real tool below does. An Include line pulls in more files, often from /etc/ssh/sshd_config.d/, and those are read in place, so a file there can set a value first.

# Setup: a sample sshd_config and a sample .ssh folder in a fresh temporary folder
cd "$(mktemp -d)"
cat > sshd_config << 'CONF'
# Example sshd_config
Port 22
#PermitRootLogin no
PermitRootLogin yes
PasswordAuthentication yes
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding yes

Match User backup
    PermitEmptyPasswords yes
CONF
mkdir .ssh
printf 'FAKE KEY\n'   > .ssh/id_ed25519;     chmod 600 .ssh/id_ed25519
printf 'FAKE PUB\n'   > .ssh/id_ed25519.pub; chmod 644 .ssh/id_ed25519.pub
printf 'FAKE KEY\n'   > .ssh/id_rsa;         chmod 644 .ssh/id_rsa
printf 'ssh-ed25519 AAAA test\n' > .ssh/authorized_keys; chmod 664 .ssh/authorized_keys

# 1. Work out the settings sshd really uses: comments skipped, keys in any case,
#    the FIRST value wins, and we stop at the first Match block (it only applies to some users).
echo "== effective settings (first match wins) =="
awk '
  /^[[:space:]]*#/ || NF == 0 { next }
  tolower($1) == "match"      { exit }
  { k = tolower($1); if (!(k in seen)) seen[k] = tolower($2) }
  END {
    split("permitrootlogin prohibit-password passwordauthentication yes permitemptypasswords no", d, " ")
    for (i = 1; i <= 6; i += 2) {
      key = d[i]; val = (key in seen) ? seen[key] : d[i+1]
      note = (key in seen) ? "" : " (default)"
      print key, val, (val == "yes" ? "RISKY" : "ok") note
    }
  }' sshd_config

# 2. Private keys must be readable only by you; authorized_keys must not be writable by others.
echo "== key file checks =="
for f in .ssh/id_*; do
  [[ $f == *.pub ]] && continue
  mode=$(stat -c '%a' "$f")
  if [[ $mode == 600 || $mode == 400 ]]; then
    echo "OK  $mode $f"
  else
    echo "BAD $mode $f (private key should be 600)"
  fi
done
f=.ssh/authorized_keys
mode=$(stat -c '%a' "$f")
if [[ -n $(find "$f" -perm /022) ]]; then
  echo "BAD $mode $f (group or others can write)"
else
  echo "OK  $mode $f"
fi

Interview follow-ups

  • Also flag a .ssh folder that is writable by group or others.

    sshd's StrictModes setting, which is on by default, can refuse key logins when the user's home folder, ~/.ssh or authorized_keys can be written by others. Add a check on the folder itself: find .ssh -maxdepth 0 -perm /022 prints .ssh only if the group or others can write to it. The safe modes are 700 for .ssh and 600 for authorized_keys. For a whole server, loop over each home folder from getent passwd and run the same checks for every user.

Frequently asked questions

Run sudo sshd -T. It reads the main file, every Include file and the defaults, and prints the final value of every setting, one per line, in lowercase. That is the source of truth, and sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication)' answers the audit in one line. To see what applies to one user, add the connection details: sudo sshd -T -C user=backup,host=x,addr=10.0.0.5. Parsing the file yourself, as on this page, is still useful when you only have a copy of the config.

First check the file for errors with sudo sshd -t; it prints nothing if the config is valid. Then reload the service: sudo systemctl reload ssh (the unit is sshd on RHEL-style systems). Reloading keeps your current session open. Before you close it, open a second terminal and test a fresh login. If the new login fails, you still have the old session to fix the mistake.

Because sshd keeps the first value it finds for most keywords, and ignores later ones. Cloud images and installers often add settings near the top or in an Include file, so your line at the bottom loses. The fix is to put your setting in a file that is read first, such as /etc/ssh/sshd_config.d/00-hardening.conf on systems that include that folder at the top, or to edit the existing line. Always confirm with sshd -T afterwards.