CoursesLinux essentialsWorking with files & text

Working with files & text

view, find, grep, pipes, redirection.

Beginner14 min · lesson 2 of 25

Almost everything on a Linux server is text. The settings that decide whether a service starts, the record of who logged in and when, the output of nearly every command you run: all of it is plain, readable text. Think of the machine as a filing cabinet full of paper, except you reach the drawers through SSH (Secure Shell, the encrypted connection that gives you a terminal on a remote computer). Get comfortable reading, searching, and routing that text and you can make sense of a server you have never touched before, with no graphical editor required.

The whole toolkit here is small: a handful of commands to read files, two to search, and two ways to wire commands together. It is also the exact toolkit someone reaches for the moment they break into a machine, so the faster you are with it, the faster you can spot when something is wrong.

Reading A File Without Opening It

You have four ways to read a file, and picking the right one matters when the file is a log with two million lines. cat copies the entire file to your screen at once, the way you might drop a whole document flat on your desk. Perfect for something short.

~/secopslog — bash
$ cat /etc/hostname
web-01

For a long file, cat buries you. less is a viewer you scroll through instead, like paging down a long document one screen at a time. It does not load the whole file into memory, so it opens a multi-gigabyte log the instant you run it. Space moves down a page, /word searches forward, n jumps to the next match, and q quits. When you only care about the two ends of a file, head shows the first lines and tail shows the last.

~/secopslog — bash
$ head -n 3 /etc/passwd
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin

That is the user database. Each line is one account, seven fields split by colons. You are looking at the login name, then an x standing in for the password (the real password, stored as a hash, a scrambled one-way version you cannot reverse, lives in /etc/shadow, which only root, the all-powerful administrator account, can read), then the numeric user ID and group ID, a short description field, and finally the account's home directory and login shell (the program that reads and runs the commands you type). Accounts whose shell is /usr/sbin/nologin cannot log in interactively, which is exactly what you want for a service account.

~/secopslog — bash
$ tail -n 4 /var/log/auth.log
Jul 17 09:14:02 web-01 sshd[20476]: Failed password for invalid user admin from 203.0.113.44 port 55214 ssh2 Jul 17 09:14:04 web-01 sshd[20476]: Failed password for invalid user admin from 203.0.113.44 port 55214 ssh2 Jul 17 09:14:07 web-01 sshd[20476]: Connection closed by invalid user admin 203.0.113.44 port 55214 [preauth] Jul 17 09:15:31 web-01 sudo: deploy : TTY=pts/0 ; PWD=/home/deploy ; USER=root ; COMMAND=/usr/bin/systemctl restart nginx

/var/log/auth.log is where logins and privilege changes get recorded. Those first three lines are someone hammering the SSH service with password guesses for a user that does not exist, from a single IP (Internet Protocol) address. That is what a brute-force attempt looks like from the defender's seat. The last line is a real administrator using sudo (the command that runs a single command with administrator rights) to restart nginx, the web server, and it names who did it, when, and the exact command they ran. Reading this file is often the first thing you do after any incident.

Add -f and tail stops after the last line and follows the file, printing new lines as they are written, like watching a ticker. This is how you watch a log while you reproduce a problem in another window.

~/secopslog — bash
$ tail -f /var/log/nginx/access.log
203.0.113.44 - - [17/Jul/2026:09:22:10 +0000] "GET /admin HTTP/1.1" 404 162 "-" "curl/7.81.0" 198.51.100.7 - - [17/Jul/2026:09:22:11 +0000] "POST /wp-login.php HTTP/1.1" 200 1394 "-" "Mozilla/5.0"

Both requests there are worth a second look: something asked for /admin and got a 404 (Not Found), and something else posted to /wp-login.php, the login page for WordPress (a very common website platform). That is automated probing, and tail -f lets you watch it happen live and press Ctrl-C when you have seen enough.

Find: Walk Every Shelf

find is a librarian who walks every shelf in the building and hands you the items matching your description. You give it a place to start and some conditions (a name pattern, a type, an age, a permission), and it checks every file underneath. The most common use is finding a file by name.

~/secopslog — bash
$ find /etc -type f -name "*.conf" | head -n 5
/etc/ld.so.conf /etc/logrotate.conf /etc/systemd/logind.conf /etc/systemd/journald.conf /etc/ssh/ssh_config.d/50-cloud-init.conf

-type f limits results to regular files (not directories), and -name "*.conf" matches by filename, where * means any characters. The | head on the end is a pipe, which we get to shortly; it keeps only the first few lines so a huge result set does not scroll off the screen.

find can also search by permission, and this is where it turns into a security tool. Some programs carry a special bit called SUID (Set-User-ID), which means the program runs with the privileges of its owner instead of yours. sudo needs this: it is owned by root, so it can do root things even when you launch it as a normal user. But a SUID program with a bug is a direct path to root for an attacker, so you want to know exactly which ones exist on a machine.

~/secopslog — bash
$ find / -perm -4000 -type f 2>/dev/null
/usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/openssh/ssh-keysign /usr/lib/policykit-1/polkit-agent-helper-1 /usr/bin/su /usr/bin/sudo /usr/bin/passwd /usr/bin/chsh /usr/bin/chfn /usr/bin/newgrp /usr/bin/gpasswd /usr/bin/mount /usr/bin/umount /usr/bin/fusermount3

-perm -4000 matches files with the SUID bit set. The 2>/dev/null at the end throws away the permission-denied noise that find produces while walking directories you are not allowed to read (redirection we will cover in a moment). Compare this list against a known-good one. Anything unexpected, especially a copy of bash or find itself with SUID set, is a red flag that someone left themselves a quiet way back to root.

Time is another thing find searches on. After a suspected break-in, one of the first questions is what changed recently, and -mmin -60 means modified in the last sixty minutes.

~/secopslog — bash
$ find /var/www -type f -mmin -60
/var/www/html/uploads/.shell.php /var/www/html/index.html

A brand-new .shell.php sitting in an uploads folder, changed minutes ago, is the shape of a web shell (a script an attacker drops so they can run commands through the website). The leading dot hides it from a plain ls, but find does not care about hidden files.

Grep: Search Inside The Pages

find locates files by their outside: name, age, permissions. grep reads what is inside them. It is Ctrl-F, the find-in-page you already know, except it works across thousands of files at once and prints every line that matches. You hand it a pattern and a place to look.

Patterns can be plain words or a regex (regular expression, a small language for describing text shapes, like 'a digit followed by three letters'). Add -r to search a whole directory tree, -n to print line numbers, -i to ignore upper and lower case, and -c to count matches instead of showing them. Here is part of the SSH server's config file, which decides how people are allowed to log in.

/etc/ssh/sshd_config
#Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#PermitRootLogin prohibit-password
#PubkeyAuthentication yes
#PasswordAuthentication yes

Every line starts with a #, which makes it a comment, meaning it is switched off and the program ignores it. That matters more than it looks. Search for the root-login setting:

~/secopslog — bash
$ grep -n PermitRootLogin /etc/ssh/sshd_config
33:#PermitRootLogin prohibit-password

The only match is commented out. People read that and assume root login is disabled. It is not. A commented-out line means the built-in default applies (unless a drop-in file under /etc/ssh/sshd_config.d sets it, which is worth checking too), and OpenSSH's default here is prohibit-password, which still lets root log in with a key, just not with a password. To block root completely, the line has to be present and active, set to no. grep showed you the gap between what someone thinks the config says and what it actually does.

The same search is how you hunt for secrets that never should have been saved in code. Sweep a web application for passwords and keys:

~/secopslog — bash
$ grep -rniE "password|secret|api[_-]?key" /var/www/html --include=*.php
/var/www/html/config.php:12:$db_password = 'S3cr3t!Pass'; /var/www/html/config.php:18:$api_key = 'sk_live_4eC39HqLyjWDarjtT1zdp7dc';

-E turns on extended regex so the | means 'or', --include=*.php restricts the search to PHP files (PHP is the language a lot of websites are written in), and the pattern catches several spellings at once. Two hardcoded credentials fall out, including what looks like a live payment key. Attackers run exactly this kind of sweep the moment they can read your source, which is why finding it first is the whole game.

Pipes: An Assembly Line For Text

A pipe, written |, is a conveyor belt between commands. The output of the one on the left drops onto the belt and becomes the input of the one on the right. Each command does a single small job, then hands its result along, so you build a real tool out of tiny parts without writing a program. Here is a pipeline that turns a raw log into a ranked list of the IP addresses attacking your SSH server.

~/secopslog — bash
$ grep "Failed password" /var/log/auth.log | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head -n 3
1442 203.0.113.44 287 198.51.100.7 144 192.0.2.99

Read it left to right. grep keeps only the failed-login lines. awk '{print $(NF-3)}' prints one field from each line, counting from the end, which lands on the IP address. sort puts identical addresses next to each other, and it has to come first because the next step only collapses neighbors. uniq -c squashes each run of identical lines into a single line with a count in front. sort -rn sorts by that number, reversed and numeric, so the worst offender floats to the top. head -n 3 keeps the top three. One line of shell replaced what would have been a small script.

From a raw log to a ranked attacker list
1/var/log/auth.log
raw log lines
2grep
keep only 'Failed password'
3awk
pull out the IP field
4sort
group identical IPs together
5uniq -c
count each group
6sort -rn
biggest offender first
7head -n 3
keep the top three

The same idea counts who is connected to the machine right now. ss (socket statistics, the tool that lists network connections) prints the live connections, and the pipeline whittles them down to a tally per remote address.

~/secopslog — bash
$ ss -tn | grep ESTAB | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
18 10.0.1.12 4 203.0.113.44 2 10.0.1.5

cut -d: -f1 is the new piece: it splits each address:port on the colon and keeps the first field, the address alone. If one outside IP suddenly holds dozens of connections, this is how you notice.

Redirection: Point The Output Somewhere Else

By default a command's output sprays onto your screen. Redirection points that stream somewhere else, usually into a file, the way you would divert a hose from the floor into a bucket. Every command actually has three of these streams: stdin (standard input, where it reads from), stdout (standard output, its normal results), and stderr (standard error, where it sends its complaints). Keeping them straight is what makes redirection predictable.

Say an incident lands on your desk and you want the last few minutes of SSH activity saved to a file for the ticket. journalctl (the tool that reads the systemd journal, the central logbook a modern Linux keeps for every service) prints those logs, -u ssh narrows it to the ssh service, --since limits it to a time window, and the > hands the whole stream to a file instead of your screen.

~/secopslog — bash
$ journalctl -u ssh --since "10 min ago" > ssh-check.log tail -n 3 ssh-check.log
Jul 17 09:14:02 web-01 sshd[20476]: Failed password for invalid user admin from 203.0.113.44 port 55214 ssh2 Jul 17 09:15:31 web-01 sshd[20501]: Accepted publickey for deploy from 10.0.1.5 port 41022 ssh2: ED25519 SHA256:8Q0m2Kx9r7wZ4tD6yB0sN8fA5hG2jU3eI7oLpQvR1c Jul 17 09:15:31 web-01 sshd[20501]: pam_unix(sshd:session): session opened for user deploy(uid=1000) by (uid=0)

> sends stdout into a file, overwriting whatever was there. >> does the same but adds to the end instead of overwriting, which is what you want for a running log. 2> redirects stderr specifically (the 2 is that stream's number), so 2>/dev/null throws errors into the system's bottomless trash can, /dev/null. And 2>&1 means 'send stderr to the same place stdout is already going', which is how ./backup.sh > backup.log 2>&1 captures both normal output and errors in one file.

> empties the file before the command runs
A single > wipes its target to zero bytes the instant you press Enter, before the command has produced a thing. So sort urls.txt > urls.txt gives you an empty urls.txt: the shell truncates the file first, then sort reads nothing. Use >> when you mean to add, keep a backup before redirecting over anything you care about, and never send output into a file that the same pipeline is reading. This is one of the most common ways people destroy a config they meant to edit.

One detail trips up almost everyone: a pipe carries stdout only. Errors travel on stderr, a separate stream, so they skip the belt entirely and land straight on your screen even in the middle of a pipeline. That is why 2>/dev/null shows up so often right next to find.

Quick check
01You run: find / -name shadow | wc -l to count matching files. As it runs, find prints many 'Permission denied' messages to your screen. Are those error lines included in the number wc -l reports?
Incorrect — No: a pipe carries stdout only, and the errors are on stderr.
Correct — | connects stdout to the next command, while stderr still goes straight to the terminal.
Incorrect — No: wc cannot read the text, it only counts whatever lines arrive on its input.
Incorrect — No: -type f filters which files match, it has nothing to do with the error stream.
02The lesson lists SUID programs with find / -perm -4000 -type f. What does the SUID (Set-User-ID) bit actually do when such a program runs?
Correct — that is why sudo, owned by root, can do root things even when a normal user starts it.
Incorrect — a leading dot in the name hides a file, not the SUID permission bit.
Incorrect — SUID grants the owner's rights during execution, it does not restrict who may run it.
Incorrect — that delete-protection behaviour is the sticky bit on a directory, not SUID.
03You run grep -n PermitRootLogin /etc/ssh/sshd_config and the only line returned is 33:#PermitRootLogin prohibit-password. A teammate says 'good, root login is disabled.' Are they right?
Incorrect — the leading # makes it a comment, so the line is switched off and enforces nothing.
Correct — a commented line means the default takes over, and that default permits key-based root login.
Incorrect — comments apply no setting at all; the compiled-in default decides, and here it is not the strictest option.
Incorrect — the default prohibit-password blocks password logins for root but still allows a key, so it is not fully enabled.

Find Files, Then Search Inside Them

The real payoff is combining the two searches: use find to pick the files, then grep to read inside each one. It answers a genuinely useful security question: which config files tell a service to listen on every network interface (a network interface is a doorway the machine uses to reach a network, like its Ethernet port, or the loopback address it uses to talk to itself)?

~/secopslog — bash
$ find /etc -name "*.conf" -type f -exec grep -l "0.0.0.0" {} +
/etc/postgresql/14/main/postgresql.conf /etc/redis/redis.conf

-exec ... {} + runs grep on the files find collected, and grep -l prints only the filename of each file that contains a match. An address of 0.0.0.0 means 'accept connections on every network interface', so a database that was supposed to listen only on 127.0.0.1 (localhost, reachable only from the machine itself) showing up here means it is exposed far wider than intended. That is the difference between a database only your own app can reach and one that anything on the network, and possibly the whole internet, can knock on, and you found it with two commands you now know.

Try this

Work through “Find Files, Then Search Inside Them” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: > empties the file before the command runs. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related