Safe Penetration Testing Labs · intermediate · ~12 min
Parse /etc/passwd and flag UID-0 accounts whose name isn't root — a classic Linux-persistence check.
On a Unix system, authority comes from the numeric user ID, not the name. UID 0 is root — so an account called backup or www-data with UID 0 has full administrative power while looking unremarkable in a user list. That makes scanning /etc/passwd for unexpected UID-0 entries a standard host-audit check. The parsing is the lesson: colon-separated fields, the UID in the third position, and a strict digits-only comparison so a malformed or padded field cannot slip past. All work here is on a fixed text sample supplied by the exercise.
Adding a second UID-0 account is one of the oldest and simplest persistence techniques, and it survives password changes to the real root account. Checking for it is quick, high-signal, and appears in essentially every host-hardening baseline (CIS benchmarks include it explicitly). The parsing discipline transfers to every other colon- or comma-delimited system file you will ever audit.
The /etc/passwd format. Seven colon-separated fields: name:password:uid:gid:gecos:home:shell. The UID is field index 2 (the third). Fields may be empty, so you cannot assume every colon is followed by text.
Numeric identity is what matters. The kernel checks UID 0, not the string root. Two names can share UID 0, and both have full authority.
Parse strictly. The UID field must be all digits. Accepting 00, 0, or 0x0 would let a crafted or malformed entry evade a naive comparison — and stopping at the first non-digit (as atoi does) silently accepts 0abc.
Line handling. Iterate line by line, tolerating a missing trailing newline and skipping blank lines and comments rather than misparsing them.
Compare the name exactly. root itself is a legitimate UID-0 account; the finding is any other name with UID 0. An exact comparison avoids matching rootkit or root2.
Report, do not act. The output of an audit check is a finding for a human. Automatically modifying account files based on a parser is how you lock yourself out of a system.
#include <string.h>
/* is the UID field of this line exactly 0, parsed strictly? */
static int uid_is_zero(const char *line, size_t len) {
/* find the 2nd colon: fields are name:passwd:uid:... */
size_t i = 0; int colons = 0;
while (i < len && colons < 2) { if (line[i] == ':') colons++; i++; }
if (colons < 2 || i >= len) return 0; /* malformed line */
size_t start = i;
while (i < len && line[i] != ':') i++;
if (i == start) return 0; /* empty UID field */
for (size_t k = start; k < i; k++)
if (line[k] < '0' || line[k] > '9') return 0; /* STRICT: digits only */
/* all digits - now check the value is zero (allowing only "0") */
return (i - start == 1) && line[start] == '0';
}
Key points:
atoi("0abc") returns 0 and would accept a malformed field.On Linux, UID 0 is root — full power, regardless of the account name. A common persistence trick is to add a second UID-0 account, so a compromise check should assert that only root has UID 0.
The run below parses a mock /etc/passwd, isolating the UID field of each colon-separated line and counting the non-root UID-0 accounts.
#include <stdio.h>
#include <string.h>
static int count_backdoor_root(const char*text){int count=0;const char*line=text;while(*line){const char*eol=line;while(*eol&&*eol!='\n')eol++;const char*c1=line;while(c1<eol&&*c1!=':')c1++;int nr=((c1-line)==4&&strncmp(line,"root",4)==0);const char*p=line;int f=0;while(p<eol&&f<2){if(*p==':')f++;p++;}int u0=0;if(f>=2&&p<eol&&*p>='0'&&*p<='9'){long uid=0;const char*q=p;int ok=1;while(q<eol&&*q!=':'){if(*q<'0'||*q>'9'){ok=0;break;}uid=uid*10+(*q-'0');q++;}if(ok&&uid==0)u0=1;}if(u0&&!nr)count++;line=(*eol=='\n')?eol+1:eol;}return count;}
int main(void){
const char *passwd =
"root:x:0:0:root:/root:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n"
"backup:x:0:0:pwned:/root:/bin/bash\n" /* backdoor: UID 0, not root */
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n";
printf("backdoor root accounts (UID 0, not \"root\") : %d\n", count_backdoor_root(passwd));
return 0;
}
| Step | Line | What happens |
|---|---|---|
| 1 | count to the 2nd colon | Skips the name and password fields to reach the UID, which is the third field. |
| 2 | colons < 2 || i >= len |
A line with fewer than two colons is malformed — reject rather than misread it. |
| 3 | scan to the next colon | Delimits the UID field precisely, so trailing fields cannot bleed into it. |
| 4 | i == start |
An empty UID field (name:: with nothing between) is rejected. |
| 5 | digits-only loop | Rejects 0x0, 0 and 0abc. This is what atoi would silently accept. |
| 6 | exactly one '0' |
Only the literal 0 counts, so padded forms cannot masquerade as root. |
Using atoi on the whole line; matching root as a prefix (so rooter slips through).
Compiler errors and warnings:
-Wchar-subscripts if you pass a plain char to <ctype.h> functions; cast to unsigned char first.-Wsign-compare mixing int counters with size_t lengths.Runtime symptoms:
atoi, which stops at the first non-digit and returns 0 for 0abc. Validate the characters first.root account is reported. You did not exclude the exact name root, so every scan produces a false positive.rootkit is treated as root. You used strncmp with the length of root instead of an exact comparison.# and blank lines explicitly.Technique: build a sample containing the genuine root:x:0:0:..., a backdoor bd:x:0:0:..., a normal user, a comment, a blank line and a malformed line. That one fixture exercises every branch.
len rather than trusting a terminator.atoi for validation. It has no error reporting: atoi("0abc") is 0 and atoi("99999999999") is undefined on overflow. Validate the characters, then convert.const char * keeps the audited text unmodified — an audit tool must not alter the evidence it examines./etc/passwd programmatically risks locking every account out of the machine; the correct output is a finding for a human to act on.Concrete uses: Host-audit tooling and CIS benchmark checks flag any UID-0 account other than root. Incident responders check it early because adding a UID-0 user is a classic persistence step. Configuration-management systems assert the invariant continuously. The same field-parsing discipline applies to /etc/group, /etc/shadow and to auditing sudoers entries.
Professional best practices:
Beginner:
atoi alone.Intermediate:
1. (Beginner) Single-line check. Implement int is_uid0_line(const char *passwd_line) returning 1 when the third field is exactly 0. Example: bd:x:0:0::/root:/bin/sh -> 1; u:x:1000:... -> 0. Concepts: field parsing.
2. (Beginner) Strict digits. Extend it to reject 0x0, 0 and 0abc. Concepts: validate before converting.
3. (Intermediate) Whole file. Implement int count_backdoor_root(const char *passwd_text) counting UID-0 lines whose name is not exactly root, skipping blanks and comments. Concepts: line iteration, exact name comparison.
4. (Intermediate) Actionable output. Report the line number and full text of each finding rather than a bare count. Concepts: useful audit output.
Authority on Unix is the numeric UID, so any account with UID 0 — whatever it is called — is root, which makes a second UID-0 entry a classic persistence trick. Detecting it is careful field parsing: reach the third colon-separated field, validate it as digits only before interpreting it, and compare the account name exactly so the genuine root is excluded and rootkit is not matched. Avoid atoi, which silently accepts 0abc and gives a malformed entry a way past the check. Carry the line length so parsing cannot overread, treat the file as untrusted data, and report findings for a human rather than editing account files automatically.