Audit User Accounts
Problem statement
Read a copy of /etc/passwd and /etc/group to answer three security questions: which accounts have root power, which accounts can log in, and who is in the docker group. This is one of the first checks in a security review or after a suspected break-in, and it is a common interview task for awk.
passwd
root:x:0:0:root:/root:/bin/bashdaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologinwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologinbackup:x:0:0:backup:/var/backups:/bin/shftp:x:110:118:ftp daemon:/srv/ftp:/bin/falseasha:x:1001:1001:Asha Rao:/home/asha:/bin/bashravi:x:1002:1002:Ravi Kumar:/home/ravi:/bin/zshdeploy:x:1003:1003::/home/deploy:/bin/bashnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologingroup
root:x:0:sudo:x:27:ashadocker:x:998:asha,deploydeploy:x:1003:Print, in this order:
- Every account whose UID is 0.
- Every account that can log in (its shell is not
nologinorfalse), with its shell. - Every member of the
dockergroup, one per line.
Expected output:
== UID 0 accounts ==rootbackup== can log in ==root /bin/bashbackup /bin/shasha /bin/bashravi /bin/zshdeploy /bin/bash== docker group members ==ashadeployHints
: between fields, so tell awk that with -F:. Then $1 is the name, $3 is the UID and $7 is the shell.Approach
Optimal: awk on colon fields
Covers: the seven fields of /etc/passwd, the four fields of /etc/group, awk -F:, $3 == 0, !~ with a pattern, split().
One line per account, seven fields. /etc/passwd is a plain text file with one account per line. The fields are joined by :. Here is the line for asha:
asha"]:::blue L --> F2["2 password
x"]:::gray L --> F3["3 UID
1001"]:::red L --> F4["4 GID
1001"]:::yellow F1 ~~~ F5["5 info
Asha Rao"]:::gray F2 ~~~ F6["6 home
/home/asha"]:::green F3 ~~~ F7["7 shell
/bin/bash"]:::purple 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
| Field | Name | What it means |
|---|---|---|
| 1 | name | the login name |
| 2 | password | always x now; the real hash lives in /etc/shadow, which only root can read |
| 3 | UID | the user's number; this is what the system really checks |
| 4 | GID | the number of the user's main group |
| 5 | info | full name or a comment, often empty |
| 6 | home | the home folder |
| 7 | shell | the program that runs at login |
UID 0 is root, whatever the name. Linux does not care about the name root. It checks the number. Any account with UID 0 has full control of the machine. A second UID 0 account, like backup in the sample, is a classic sign that someone planted a back door. That is why "list every UID 0 account" is the first line of many audits.
Who can log in. Service accounts like www-data or daemon exist so programs can run as them, but no person should log in as them. They get a shell that refuses logins: /usr/sbin/nologin or /bin/false. Any account with a real shell like /bin/bash, /bin/sh or /bin/zsh can log in, if it also has a password or an SSH key.
/etc/group has four fields. docker:x:998:asha,deploy means: group name docker, password x (not used), GID 998, and the extra members asha and deploy. A user's main group is in their passwd line (field 4), so it is not repeated here. Being in docker is close to being root, because the Docker daemon runs as root and its members can start a container that mounts the whole disk.
How awk reads these lines. awk reads a file one line at a time and splits each line into fields. -F: says "split on colons". A program like '$3 == 0 { print $1 }' means "if field 3 is 0, print field 1". The part before { } is a test, and the part inside is what to do when the test is true.
| Part | Means |
|---|---|
NF >= 7 |
the line has at least 7 fields (skips broken or blank lines) |
$3 == 0 |
field 3 is the number 0 |
$7 !~ /(nologin|false)$/ |
field 7 does not end in nologin or false |
split($4, m, ",") |
cut field 4 at each comma into a list m, and return how many parts |
Walking through the code. The # Setup: lines only create the two sample files, so skip past them.
NF >= 7 && $3 == 0keeps lines with all seven fields and UID 0, and prints the name.$7 !~ /(nologin|false)$/keeps lines whose shell does not end in either word.!~means "does not match", and$means "at the end".$1 == "docker" && $4 != ""finds the docker line only if it has members.splitcuts the members at commas, and the loop prints each one on its own line.
Edge cases. A blank or broken line has fewer than seven fields, so NF >= 7 skips it instead of printing junk. A group with no extra members has an empty field 4, and the $4 != "" test prints nothing for it. On a real server, run the same commands on /etc/passwd and /etc/group directly; both are readable by every user.
# Setup: sample copies of /etc/passwd and /etc/group in a fresh temporary folder
cd "$(mktemp -d)"
cat > passwd << 'DATA'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:0:0:backup:/var/backups:/bin/sh
ftp:x:110:118:ftp daemon:/srv/ftp:/bin/false
asha:x:1001:1001:Asha Rao:/home/asha:/bin/bash
ravi:x:1002:1002:Ravi Kumar:/home/ravi:/bin/zsh
deploy:x:1003:1003::/home/deploy:/bin/bash
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
DATA
cat > group << 'DATA'
root:x:0:
sudo:x:27:asha
docker:x:998:asha,deploy
deploy:x:1003:
DATA
# 1. UID is field 3. Any account with UID 0 has full root power.
echo "== UID 0 accounts =="
awk -F: 'NF >= 7 && $3 == 0 { print $1 }' passwd
# 2. The shell is field 7. nologin and false mean "this account cannot log in".
echo "== can log in =="
awk -F: 'NF >= 7 && $7 !~ /(nologin|false)$/ { print $1, $7 }' passwd
# 3. In /etc/group, field 4 is a comma-separated member list.
echo "== docker group members =="
awk -F: '$1 == "docker" && $4 != "" { n = split($4, m, ","); for (i = 1; i <= n; i++) print m[i] }' groupInterview follow-ups
List every user who is in more than one group, counting their main group.
Combine both files. From passwd, print each user with their main GID; from group, print each extra member with that group's GID. Then count how many different groups each user appears with:
sort -uonuser gidpairs, thenawk '{c[$1]++} END {for (u in c) if (c[u] > 1) print u}', thensort. Usinggetentinstead of the files makes the same pipeline work with directory accounts too.
Frequently asked questions
On many company servers, accounts come from a central directory such as LDAP or Active Directory, not only from /etc/passwd. Those users are real and can log in, but they are not in the file. getent passwd asks the system's account lookup, so it lists local and directory users together, in the same seven-field format. getent group docker does the same for one group. For an audit, run your awk on getent passwd output, and use the file only when you know all accounts are local.
Every user has one main (primary) group, set by the GID in field 4 of their passwd line. New files they create get that group. They can also belong to any number of extra (supplementary) groups, which are listed in /etc/group field 4. To see both for one user, run id asha or groups asha. If you only read /etc/group, you will miss users whose main group is the one you are checking.
No, they also need a way to prove who they are: a password in /etc/shadow, or a public key in their ~/.ssh/authorized_keys. An account with /bin/bash but a locked password and no keys cannot log in over SSH. The reverse matters for audits: an account you thought was a service account, but which has a real shell and an SSH key, is a real way in. A full check looks at the shell, /etc/shadow and every authorized_keys file.