Safe Penetration Testing Labs · beginner · ~12 min

Linux file-permission audit — the risky bits

## What you will learn - Read a Unix `mode` integer and separate the **special bits** (setuid, setgid, sticky) from the owner/group/other permission triads. - Test a single mode for three high-value audit findings using bit masking: **world-writable** (`0002`), **setuid** (`04000`), and **setgid** (`02000`). - Combine independent tests into one bitmask so a caller can see every risky bit set on a file at once. - Explain *why* each bit matters to a defender: what asset it exposes, what an attacker could do, and how to remediate it. - Log an audit finding responsibly (timestamp, path, mode, decision) without leaking sensitive data. - Understand where this per-file check sits inside real tooling (AIDE, Lynis, `auditd`, `find`) and why you only run scans on systems you are authorized to test.

Overview

Overview

Security objective. The asset is the integrity of files and the boundary between an ordinary user and root on a Linux host. The threat is local privilege escalation — a user who already has a normal account turning it into root (or another user's) power by abusing loose file permissions. In this lesson you build the smallest possible defensive tool: a function that takes one file's permission mode and flags the three permission bits every audit looks for — world-writable, setuid, and setgid. You are learning to detect a class of misconfiguration so you can remediate it.

On Unix every file carries a mode: an integer that stores who may read, write, or execute it, plus a few special bits. This lesson builds directly on your bitwise-operators prereq. A mode is not a number you do arithmetic on — it is a packed set of flags. To ask "is the setuid bit on?" you use mode & 04000, exactly the AND-with-a-mask pattern you practiced. To report several findings at once, you OR single-bit results together into a result mask.

The function you implement, audit_mode, is deliberately not a filesystem scanner. It inspects one mode integer that some caller already read (for example from stat()). That separation matters: the risky, privileged part (walking the filesystem, reading real files) stays in well-tested tools like find, stat, Lynis, or AIDE; your job is the pure, easily-tested logic that decides whether a given mode is worth flagging. Getting that decision right — and knowing what to do once a file is flagged — is the whole point.

Why it matters

Why it matters

In authorized security work, world-writable files and setuid binaries are among the very first things a reviewer checks, because they are cheap for an attacker to abuse and common in the wild:

  • A world-writable config file, script, or cron job means any local account can change what privileged code does next. If root later runs that script, the attacker's content runs as root.
  • A setuid-root binary runs as root no matter who launches it. If it has a bug (or is world-writable, or trusts an attacker-controlled PATH), it becomes a direct path from normal user to root.
  • Setgid binaries and directories do the same for a group, which can expose group-owned secrets or logs.

Professionally this shows up in host hardening reviews, CIS-benchmark compliance checks, incident response (an attacker who gained root often leaves a setuid shell behind as a backdoor), and continuous integrity monitoring. Being able to read these bits by hand — not just run a scanner — means you can triage a finding, judge its real severity, and write a credible remediation instead of blindly trusting a tool's output. A scanner tells you a bit is set; a professional explains whether it is actually dangerous and how to fix it safely.

Core concepts

Core concepts

1. The Unix mode is a packed bit field

Definition. A file's mode is an integer whose bits encode permissions. It is usually written in octal because each octal digit maps cleanly to three bits.

How it works. From most- to least-significant, the low 12 bits are three special bits followed by three permission triads:

 special   owner   group   other
 s g t     r w x   r w x   r w x
 4 2 1     4 2 1   4 2 1   4 2 1

As octal: setuid = 04000, setgid = 02000, sticky = 01000, then owner 0700, group 0070, other 0007. So a mode of 04755 means: setuid on, owner rwx, group r-x, other r-x.

When to use. Any time you must reason about "which permission bit is set", mask the specific bit rather than comparing the whole number. mode & 04000 isolates setuid regardless of the other bits.

Pitfall. Forgetting the leading 0. In C, 755 is decimal seven-hundred-fifty-five; 0755 is octal. Mixing them silently tests the wrong bits.

2. World-writable (0002) — the other-write bit

Definition. The write bit in the other triad. Set means every local user can modify the file.

Plain explanation. "Other" is everyone who is neither the owner nor in the file's group — effectively anyone with a shell on the box. If they can write the file and something trusted later reads or executes it, they control that trusted action.

When it's fine / not. A world-writable scratch file in /tmp (which is also sticky, 01000) is normal. A world-writable script, systemd unit, cron entry, /etc config, or binary is a finding.

Pitfall. Confusing world-writable with world-readable (0004). Readable leaks data; writable lets an attacker change behavior. Both matter, but writable is usually worse for privilege escalation.

3. Setuid (04000) and setgid (02000)

Definition. Setuid means an executable runs with the owner's effective user id, not the caller's; setgid uses the file's group. On a directory, setgid instead makes new files inherit the directory's group.

How it works. When the kernel executes a setuid-root binary, the process's effective UID becomes 0 for its whole run. Legitimate examples exist (/usr/bin/passwd must edit /etc/shadow). The danger is any bug, unsafe system() call, or writable-ness in such a binary.

When not. A custom program should almost never be setuid-root. Prefer dropping privileges, using capabilities (setcap), or a small privileged helper with a tiny, audited interface.

Pitfall. Assuming setuid only matters if the owner is root. Setuid to any other user is still a boundary crossing worth reviewing.

Threat model

  Trust boundary: normal user  |  root / privileged code
                               |
  Entry point (attacker,       |   Asset protected:
  a local unprivileged shell)  |   integrity of privileged
         |                     |   execution + root boundary
         v                     |
  [ world-writable file ]------+--> trusted process reads/execs it
         |                     |         => attacker content runs privileged
  [ setuid-root binary ]-------+--> any user runs it AS root
         |                     |         => bug/writable = user -> root
         v                     |
  Detection: audit_mode() flags the bit  --> remediate + re-verify

Knowledge check.

  1. What asset is protected by auditing these bits? (The integrity of privileged execution and the user-to-root trust boundary.)
  2. Where is the trust boundary in the diagram, and which bit lets an attacker cross it fastest? (Between normal user and root; a setuid-root binary that is also world-writable.)
  3. What insecure assumption creates a world-writable-file risk? ("Only trusted users will ever modify this file" — false on a multi-user host.)

Syntax notes

Syntax notes

The whole technique is AND with a single-bit mask, then combine with OR. Define the masks in octal so they read like ls output.

#include <stdint.h>

/* Permission masks, written in octal (leading 0). */
#define S_ISUID_BIT  04000u   /* setuid  */
#define S_ISGID_BIT  02000u   /* setgid  */
#define O_WRITE_BIT  00002u   /* other-write == world-writable */

/* Result bitmask positions this lesson returns. */
#define FIND_WORLD_W  0x1  /* bit 0 */
#define FIND_SETUID   0x2  /* bit 1 */
#define FIND_SETGID   0x4  /* bit 2 */

/* Isolate one bit: nonzero if set, 0 if clear. */
/*   mode & S_ISUID_BIT  */
/* Normalize to 0/1 before shifting into a result: */
/*   (mode & S_ISUID_BIT) ? FIND_SETUID : 0        */

Note: <sys/stat.h> already defines S_ISUID, S_ISGID, and S_IWOTH with these exact values. The lesson names above are spelled out so the mapping is explicit, but in production code prefer the standard macros.

Lesson

Why this matters

Two of the first checks in any Linux audit or privilege-escalation review are:

  • Find world-writable files (files anyone on the system can modify).
  • Find setuid and setgid binaries (programs that run with elevated privileges).

Both of these are simply bits in the file's mode (the integer that stores its permissions). Reading them is pure bit masking.

The mode bits

A Unix mode packs special bits and the owner/group/other permissions together:

       setuid setgid sticky   owner   group   other
        4000   2000   1000     rwx     rwx     rwx

The three bits this lesson cares about:

  • World-writable (0002): the other write bit. Anyone can change the file.
  • Setuid (04000): the program runs as its owner (often root). A classic privilege-escalation target if misused.
  • Setgid (02000): the program runs with the file's group.

A setuid-root binary that is also world-writable is a five-alarm finding: any user could overwrite it, and it would then run as root.

Your job

Implement:

int audit_mode(unsigned int mode);

Return a bitmask where:

  • bit 0 = world-writable
  • bit 1 = setuid
  • bit 2 = setgid

This is three independent mode & MASK tests, OR'd together.

What this is NOT

  • It is not a filesystem scanner. You inspect a single mode integer, not real files on disk.
  • Tools like AIDE, Lynis, and auditd do the scanning. This lesson is the per-file check those tools run.

Code examples

Code

Three parts: an intentionally-unsafe pattern to recognize, the safe detector, and checks that prove the detector rejects bad modes and accepts good ones.

1. WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

This shows the misconfiguration you are auditing for — a program that gets installed setuid-root and world-writable. Never ship this; it exists so you can recognize the finding.

/* vuln_demo.c — the BAD state audit_mode() must catch.
 * In a lab you might create it with:  chmod 4757 ./vuln_demo
 * mode 04757 = setuid + owner rwx + group r-x + OTHER rwx (world-writable!)
 * Any local user could overwrite this file, and it runs as its owner. */
#include <stdio.h>
int main(void) {
    puts("I run with my owner's privileges, and anyone can rewrite me.");
    return 0;
}

2. SECURE: the detector

/* audit.c — pure, testable per-file permission check. C11. */
#include <stdio.h>
#include <stdint.h>

#define S_ISUID_BIT  04000u
#define S_ISGID_BIT  02000u
#define O_WRITE_BIT  00002u

#define FIND_WORLD_W  0x1
#define FIND_SETUID   0x2
#define FIND_SETGID   0x4

/* Return a bitmask of risky bits found in `mode`. */
int audit_mode(unsigned int mode) {
    int flags = 0;
    if (mode & O_WRITE_BIT) flags |= FIND_WORLD_W;
    if (mode & S_ISUID_BIT) flags |= FIND_SETUID;
    if (mode & S_ISGID_BIT) flags |= FIND_SETGID;
    return flags;
}

/* Print a finding without leaking anything sensitive.
 * Guarded so the same audit.c links cleanly into the test below
 * (which supplies its own main) when built with -DAUDIT_UNIT_TEST. */
#ifndef AUDIT_UNIT_TEST
static void report(const char *path, unsigned int mode) {
    int f = audit_mode(mode);
    if (f == 0) return;                 /* nothing to report */
    printf("FINDING path=%s mode=%04o", path, mode & 07777u);
    if (f & FIND_WORLD_W) printf(" world-writable");
    if (f & FIND_SETUID)  printf(" setuid");
    if (f & FIND_SETGID)  printf(" setgid");
    if ((f & FIND_WORLD_W) && (f & FIND_SETUID))
        printf(" [CRITICAL: writable setuid]");
    putchar('\n');
}

int main(void) {
    /* Simulated modes; a real scanner would fill these from stat(). */
    report("/lab/vuln_demo", 04757u);  /* setuid + world-writable */
    report("/lab/passwd",    04755u);  /* setuid, not writable    */
    report("/lab/report.txt",00644u);  /* clean                   */
    return 0;
}
#endif

Expected output

FINDING path=/lab/vuln_demo mode=4757 world-writable setuid [CRITICAL: writable setuid]
FINDING path=/lab/passwd mode=4755 setuid

The clean report.txt prints nothing — no finding.

3. VERIFY: prove it rejects bad and accepts good

/* test_audit.c — compile with audit.c's audit_mode(). */
#include <assert.h>

int audit_mode(unsigned int mode);
#define FIND_WORLD_W 0x1
#define FIND_SETUID  0x2
#define FIND_SETGID  0x4

int main(void) {
    /* GOOD input: safe modes must NOT be flagged (accept). */
    assert(audit_mode(00644u) == 0);            /* rw-r--r-- */
    assert(audit_mode(00755u) == 0);            /* rwxr-xr-x */
    assert(audit_mode(01755u) == 0);            /* sticky bit only, none of our 3 */

    /* BAD input: risky modes must be flagged (reject). */
    assert(audit_mode(00002u) == FIND_WORLD_W);
    /* /tmp (01777) IS world-writable — the flag fires; the sticky bit makes it
     * an ACCEPTED case to triage, not a false 'clean'. See the notes on false positives. */
    assert(audit_mode(01777u) == FIND_WORLD_W);
    assert(audit_mode(04000u) == FIND_SETUID);
    assert(audit_mode(02000u) == FIND_SETGID);
    assert(audit_mode(04757u) == (FIND_WORLD_W | FIND_SETUID));
    assert(audit_mode(06002u) ==
           (FIND_WORLD_W | FIND_SETUID | FIND_SETGID));
    return 0;   /* exit 0 == all assertions held */
}

Build and run:

cc -std=c11 -Wall -Wextra audit.c -o audit && ./audit
cc -std=c11 -Wall -Wextra -DAUDIT_UNIT_TEST test_audit.c audit.c -o t && ./t && echo PASS

If ./t prints PASS (exit 0), every safe mode was accepted and every risky mode rejected.

Line by line

Line by line

Walking the core detector and one test case (audit_mode(04757u)):

  1. int flags = 0; — start with no findings.
  2. mode & O_WRITE_BIT04757 & 00002 = 00002, nonzero → true. flags |= FIND_WORLD_W sets bit 0. flags is now 0x1.
  3. mode & S_ISUID_BIT04757 & 04000 = 04000, nonzero → true. flags |= FIND_SETUID sets bit 1. flags is now 0x3.
  4. mode & S_ISGID_BIT04757 & 02000 = 0, false → setgid bit stays clear.
  5. return flags; returns 0x3 = FIND_WORLD_W | FIND_SETUID.

Each test is independent: masking one bit never disturbs the others, which is exactly why OR-combining is safe.

mode (octal) & 0002 & 04000 & 02000 returned flags
0644 0 0 0 0 (clean)
0002 set 0 0 0x1
04000 0 set 0 0x2
02000 0 0 set 0x4
04757 set set 0 0x3
06002 set set set 0x7

In report, mode & 07777u keeps only the low 12 permission bits before printing with %04o, so the file-type bits (which stat also stores in st_mode) never clutter the output.

Common mistakes

Common mistakes

Dropping the octal 0. WRONG: if (mode & 2000). WHY: 2000 is decimal (0x7D0), so you test the wrong bits and setgid detection silently breaks. CORRECTED: if (mode & 02000) or the standard macro S_ISGID. RECOGNIZE: your tests for a known setgid mode fail; print the mask in octal (%o) and compare.

Comparing with == instead of masking. WRONG: if (mode == 04000). WHY: real modes have permission bits too (e.g. 04755), so an exact-equality test misses almost every real setuid file. CORRECTED: if (mode & 04000). RECOGNIZE: only bare 04000 is flagged, nothing realistic is.

Confusing world-writable with world-readable. WRONG: flagging 0004. WHY: readable leaks data but does not let an attacker change privileged behavior; the classic escalation finding is writable (0002). CORRECTED: mask 0002 for the write-based finding (audit readability separately if needed).

Treating a scanner pass as "secure". WRONG: "Lynis found no setuid issues, so the host is secure." WHY: a clean scan only means those checks passed; it does not prove the system is secure and nothing ever is "completely secure". CORRECTED: report scan scope and limits, and combine with manual review.

Signed-shift confusion. WRONG: extracting a triad with (mode >> 6) & 7 on a signed value that could be negative. WHY: right-shifting negatives is implementation-defined. CORRECTED: use unsigned int for modes (as this lesson does).

Debugging tips

Debugging tips

  • A known-risky mode isn't flagged. Print the mode and each mask in octal: printf("mode=%o uid=%o\n", mode, mode & 04000);. If the mask prints as a big decimal-looking number, you forgot a leading 0.
  • Everything is flagged. You probably used | where you meant &, or compared against the wrong constant. mode & mask isolates; mode | mask sets bits and is almost never what you want in a test.
  • Output shows huge mode numbers. You are printing the full st_mode including file-type bits. Mask with & 07777 (or & 0777 if you don't want the special bits) before formatting.
  • Confirm against the system. In a lab, stat -c '%a %n' file prints the octal mode; ls -l shows s in the owner-exec column for setuid, S if setuid without execute. Cross-check your function's verdict against these.
  • Questions to ask when it fails: Is mode unsigned? Are my masks octal? Am I masking (&) rather than comparing (==)? Did I strip file-type bits before printing? Does a manual stat agree with my flag?

Memory safety

Security & safety

Detection & logging — what to record for each finding.

  • A UTC timestamp (when the audit ran).
  • The resource: absolute file path and the octal mode (%04o).
  • The security decision: which bits were flagged, and a severity note (a writable setuid file is critical; a lone setgid directory may be expected).
  • The source/context: which host and which audit run produced it — a correlation id so a finding can be tied back to its scan.
  • The result: new finding vs. known/accepted exception.

What to NEVER log. File contents (a world-writable file may hold secrets), passwords, tokens, session cookies, private keys, full PANs, or unneeded PII. You are logging metadata about permissions, not the data itself. Logging the risky file's contents would spread the exposure you are trying to fix.

Which events signal abuse. A setuid binary appearing in a user-writable directory (/tmp, /dev/shm, a home dir), a new setuid-root binary that wasn't in yesterday's baseline, or a config/cron file flipping to world-writable — these often indicate an attacker planting a backdoor or staging privilege escalation. Integrity baselines (AIDE) exist to catch exactly these deltas.

How false positives arise. Some setuid binaries are legitimate (passwd, sudo, mount). A setgid directory is a normal way to share group-owned files and is not a vulnerability by itself. /tmp being world-writable is expected because it is also sticky (01000), which stops users deleting each other's files. Treat the flag as "investigate", not "exploit": confirm ownership, expected purpose, and whether the file is also writable before rating severity.

C safety for this code. Use unsigned int for modes to avoid implementation-defined right shifts; the detector allocates nothing, so there is no leak or overflow surface — the risk lives in whatever code reads real files, which must check every stat() return and never follow untrusted symlinks blindly.

Real-world uses

Real-world uses

Authorized use case. During a host-hardening engagement on a server you are contracted to review, you enumerate setuid/setgid binaries and world-writable files, compare them to a known-good baseline, and hand the owner a remediation list (remove setuid, tighten to 0644/0755, or justify and document each exception). The same logic runs continuously as integrity monitoring so a newly introduced setuid backdoor triggers an alert.

The underlying enumeration commands, for a lab you own:

find / -xdev -perm -4000 -type f 2>/dev/null      # setuid files
find / -xdev -perm -2000 -type f 2>/dev/null      # setgid files
find / -xdev -perm -0002 ! -type l 2>/dev/null    # world-writable

Best-practice habits.

  • Least privilege: prefer file capabilities (setcap) or a tiny privileged helper over setuid-root; drop privileges as early as possible.
  • Secure defaults: new files 0644, new dirs 0755; never 0777.
  • Validation: baseline the expected setuid set and alert on deltas, don't just count.
  • Logging & error handling: record findings with metadata (above), check every syscall, and fail closed.
Level Focus
Beginner Read a mode, flag the 3 bits, cross-check with stat/ls -l, write a clean finding line.
Advanced Baseline diffing, capability-based redesign, symlink-safe scanning, feeding findings into SIEM with correlation ids and severity scoring.

Authorization checklist (before any scan): written scope naming the hosts, permission from the owner, a lab/isolated environment (localhost, container, or an intentionally-vulnerable VM/CTF), and a defined stop/cleanup plan. Only ever scan systems you own or are explicitly authorized to test.

Practice tasks

Practice tasks

All tasks are lab-only: run against files you create on localhost or in a container. Each ends by remediating and re-verifying.

Beginner 1 — Single-bit check. Objective: write int is_world_writable(unsigned int mode) returning 1 if 0002 is set, else 0. Input: an octal mode. Output: 0 or 1. Constraints: mode unsigned; use masking, no ==. Hints: mode & 0002. Concepts: bit mask. Conclude by fixing a lab file with chmod o-w and confirming the function now returns 0.

Beginner 2 — Owner triad. Objective: write int owner_perms(unsigned int mode) returning the owner rwx bits ((mode >> 6) & 7). Input: mode. Output: 0–7. Constraints: unsigned only. Hints: shift then mask. Concepts: shift + mask. Verify against stat -c '%a' on a lab file.

Intermediate 1 — Full audit mask. Objective: implement audit_mode returning bits for world-writable, setuid, setgid as in the lesson. Requirements: three independent masks OR'd together; add one assert per bit. Constraints: no branches longer than needed; unsigned. Hints: reuse the VERIFY block's asserts. Concepts: mask + OR-combine. Conclude: create a chmod 4757 lab file, confirm it flags CRITICAL, then chmod 0755 and confirm the flag clears.

Intermediate 2 — Finding line + logging. Objective: given a path and mode, print a one-line finding with timestamp, path, octal mode, and flagged bits — and NOT the file contents. Requirements: strip file-type bits with & 07777; skip clean modes silently. Constraints: never open or read the file. Hints: mirror report(); add a correlation id argument. Concepts: safe logging metadata. Conclude by checking your log contains no secrets.

Challenge — Baseline diff (lab only). Objective: read two lists of path mode pairs (yesterday vs today) and report every file whose risky-bit mask newly appears or changes. Requirements: use audit_mode for each; output added/removed findings; treat a new writable-setuid as CRITICAL. Constraints: files you created in an isolated lab only; no scanning of systems you don't own; include a cleanup step (rm the lab files, reset modes). Hints: store path→mask in a simple array or map; compare masks. Concepts: integrity baselining, delta detection. Defensive conclusion: for each new finding, write the remediation (chmod, remove setuid, or document as an accepted exception) and re-run the diff to prove it is resolved.

Summary

Summary

  • A Unix mode is a packed bit field. Read it with AND masks in octal, never with arithmetic or == on the whole number.
  • Three high-value audit findings: world-writable (0002), setuid (04000), setgid (02000). A file that is both world-writable and setuid-root is critical.
  • Key pattern: if (mode & MASK) flags |= FIND_BIT; then return flags; — independent tests OR'd into one bitmask.
  • Common mistakes: forgetting the leading 0 (octal), using == instead of &, confusing writable with readable, and treating a clean scanner run as proof of security — nothing is ever "completely secure".
  • Defensive workflow: detect → rate severity → remediate → re-verify, log finding metadata (timestamp, path, mode, decision, correlation id) but never the file's contents or secrets, and only scan systems you own or are authorized to test.
  • This function is the per-file check inside tools like find, Lynis, AIDE, and auditd; keep the privileged filesystem walking in trusted tools and the pure decision logic testable.

Practice with these exercises