Skip to content

fix(permission-hardener): prevent whitelist fail-open and hardlink abort#371

Open
maybebyte wants to merge 2 commits into
Kicksecure:masterfrom
maybebyte:fix/permission-hardener-whitelist-and-errexit
Open

fix(permission-hardener): prevent whitelist fail-open and hardlink abort#371
maybebyte wants to merge 2 commits into
Kicksecure:masterfrom
maybebyte:fix/permission-hardener-whitelist-and-errexit

Conversation

@maybebyte

Copy link
Copy Markdown
Contributor

Summary

Two independent defects in permission-hardener's SUID/SGID hardening
path each cause hardening to be silently skipped rather than applied —
the worst failure mode for a hardening tool, since both exit 0 with no
obvious signal. Both are one-spot fixes in check_nosuid_whitelist and
output_stat.

Changes

  • Empty matchwhitelist no longer whitelists every file. Root
    cause: check_nosuid_whitelist looped over
    "${policy_match_white_list[@]:-}"; under set -o nounset the :-
    expands an empty array to one empty-string element, and
    [[ "$target" == *""* ]] matches everything, so the function returned
    "whitelisted" for every file and both || continue callers skipped
    all hardening. Now expands with ${arr[@]+"${arr[@]}"} (no elements
    when empty, nounset-safe).
  • Hardlinked files are skipped instead of aborting the run. Root
    cause: output_stat returned 1 for a non-directory file with link
    count > 1; as a bare call under errexit that terminated the whole
    pass, and the following [ -z "${file_name_from_stat}" ] guard never
    caught it because the var was already set. Now blanks the output vars
    and returns 0 — matching the existing nonexistent-file skip path and
    the "cannot handle hardlinks" comment — so the caller continues.

Testing

  • bash -n on the modified script — no syntax errors.
  • Fix 1, real check_nosuid_whitelist extracted and exercised under
    set -u: empty matchwhitelist → hardens (return 0); matching entry →
    skips (return 1); non-matching entry → hardens. All three correct;
    the empty case fails against the pre-fix function.
  • Fix 2, real output_stat run against an actual 2-link hardlinked
    file: file is skipped, the loop continues to the next (single-link)
    file which is still hardened, and the run exits 0 instead of aborting.

Notes for reviewers

  • Decision (fix 2): only the hardlink branch was changed to skip;
    genuine error paths (stat failure, parse corruption) still return 1
    and fail loud under errexit. The alternative — output_stat … || continue at all four call sites — is simpler but would also swallow
    those genuine errors, so it was rejected in favor of the narrower fix.
  • Both defects are latent on a default install (the shipped
    permission-hardener.d/*.conf provide many matchwhitelist entries,
    and hardlinked SUID binaries are uncommon); they surface on custom
    effective configs and unusual filesystems respectively.

check_nosuid_whitelist iterated with
"${policy_match_white_list[@]:-}". Under `set -o nounset` the `:-`
default expands an empty array to a single empty-string element, so
the loop ran once with an empty entry. The body test
`[[ "${target_file}" == *""* ]]` matches every string, so the
function returned 1 (whitelisted) for every file. Both callers use
`|| continue`, so an effective config with no `matchwhitelist` line
silently skipped all SUID/SGID hardening with exit 0 -- a fail-open.

Expand with ${arr[@]+"${arr[@]}"} instead, which yields no elements
when the array is empty and does not trip nounset.
output_stat returned 1 for a non-directory SUID/SGID file with a
hardlink count > 1. The four call sites invoke output_stat as a bare
command under `set -o errexit`, so that return propagated and
terminated the whole run instead of skipping the file. The following
`if [ -z "${file_name_from_stat}" ]` guard never caught this case
because file_name_from_stat was already populated.

The surrounding comment states hardlinked files are meant to be
skipped (scanning a hardlink pool is too costly). Blank the output
vars and return 0, matching the existing nonexistent-file skip path,
so the caller's empty-name check continues past the file. Genuine
error paths (stat failure, parse corruption) still return 1 and fail
loud.
@maybebyte

Copy link
Copy Markdown
Contributor Author

AI assisted. Related: #308 (that is where the shell glob line was introduced, for performance reasons).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant