리눅스 시스템 기초 · 입문편 — 본문의 "N편"은 입문 과정 번호다. 번호별 글과 전체 250편 구성은 통합 로드맵에서 확인할 수 있다.
리눅스 시스템 기초 21 / 50 · Part 3. Linux 명령어 활용
실습 환경: Rocky Linux 9 (10.0.0.200) · 실습용 샘플 로그 (아래 생성 스크립트)
이전 글: 20. 최소 권한 원칙
Part 3 「Linux 명령어 활용」을 시작한다. Part 1·2에서 grep, awk, sort를 이미 여러 번 썼지만 설명 없이 지나갔다. Part 3에서는 이 명령어들을 로그 분석 도구 로서 제대로 정리한다.
첫 글은 grep이다. 보안관제에서 가장 많이 쓰는 명령어를 하나만 꼽으라면 grep이다. SIEM이 있어도 원본 서버에서 로그를 직접 확인해야 하는 순간이 반드시 오고, 그때 원하는 줄을 빠르고 정확하게 골라내는 능력 이 분석 속도를 결정한다.
Part 3 전체에서 같은 샘플 로그를 사용한다. 실제 공격 로그가 아니라 명령어 실습을 위해 직접 만든 합성 데이터 이며, 다음 상황을 담고 있다.
| 상황 | 내용 |
|---|---|
| 정상 | 관리자 roror가 사내 IP(10.0.0.10)에서 키 인증으로 로그인, sudo 사용 |
| 무차별 대입 | 10.0.0.128이 02:10~02:19에 root, 없는 계정, devuser 비밀번호 대입 |
| 침해 의심 | 02:19 devuser 로그인 성공 → sudo 거부 → su 실패 |
| 인터넷 스캐너 | 203.0.113.x 에서 산발적인 로그인 실패 |
| 웹 공격 | 10.0.0.128이 02:30부터 디렉터리 스캔, 경로 탐색, SQL Injection 시도 |
#!/usr/bin/env python3
"""실습용 샘플 로그 생성기 — 리눅스 시스템 기초 Part 3
secure(RHEL 형식)와 access_log(Apache combined 형식)를 만든다.
실제 공격 로그가 아니라 grep/awk 실습을 위한 합성 데이터다."""
import random, datetime as dt
random.seed(2026)
HOST = "rocky"
base = dt.datetime(2026, 9, 23, 0, 0, 0)
sec, acc = [], []
pid = [4000]
def ts(t): return t.strftime("%b %e %H:%M:%S")
def ats(t): return t.strftime("%d/%b/%Y:%H:%M:%S +0900")
def nextpid():
pid[0] += random.randint(1, 7); return pid[0]
def S(t, msg): sec.append((t, f"{ts(t)} {HOST} {msg}"))
def A(t, ip, req, st, size, ua): acc.append((t, f'{ip} - - [{ats(t)}] "{req}" {st} {size} "-" "{ua}"'))
# 1) 정상 로그인 (관리자 roror, 사내 10.0.0.10) — 업무 시간
for h in (9, 13, 18):
t = base.replace(hour=h, minute=random.randint(0, 20), second=random.randint(0, 59))
p = nextpid(); port = random.randint(50000, 60000)
S(t, f"sshd[{p}]: Accepted publickey for roror from 10.0.0.10 port {port} ssh2: RSA SHA256:q1w2e3r4t5")
S(t, f"sshd[{p}]: pam_unix(sshd:session): session opened for user roror(uid=1000) by (uid=0)")
t2 = t + dt.timedelta(minutes=3)
S(t2, f"sudo[{nextpid()}]: roror : TTY=pts/0 ; PWD=/home/roror ; USER=root ; COMMAND=/usr/bin/systemctl status httpd")
S(t2 + dt.timedelta(minutes=40), f"sshd[{p}]: pam_unix(sshd:session): session closed for user roror")
# 2) 무차별 대입 (10.0.0.128) 02:10~02:18 — root, 없는 계정, devuser
t = base.replace(hour=2, minute=10)
users = ["root"] * 40 + ["admin", "test", "oracle", "guest", "ubuntu", "postgres"] * 5 + ["devuser"] * 25
for u in users:
t += dt.timedelta(seconds=random.randint(2, 9))
p = nextpid(); port = random.randint(40000, 65000)
if u in ("root", "devuser"):
S(t, f"sshd[{p}]: Failed password for {u} from 10.0.0.128 port {port} ssh2")
else:
S(t, f"sshd[{p}]: Invalid user {u} from 10.0.0.128 port {port}")
S(t, f"sshd[{p}]: Failed password for invalid user {u} from 10.0.0.128 port {port} ssh2")
# 성공 → 권한 상승 시도
t += dt.timedelta(seconds=6); p = nextpid()
S(t, f"sshd[{p}]: Accepted password for devuser from 10.0.0.128 port 51514 ssh2")
S(t, f"sshd[{p}]: pam_unix(sshd:session): session opened for user devuser(uid=1001) by (uid=0)")
t += dt.timedelta(minutes=2)
S(t, f"sudo[{nextpid()}]: devuser : user NOT in sudoers ; TTY=pts/1 ; PWD=/home/devuser ; USER=root ; COMMAND=/bin/bash")
t += dt.timedelta(minutes=1)
S(t, f"su[{nextpid()}]: pam_unix(su-l:auth): authentication failure; logname=devuser uid=1001 euid=0 tty=pts/1 ruser=devuser rhost= user=root")
S(t, f"su[{pid[0]}]: FAILED SU (to root) devuser on pts/1")
S(t + dt.timedelta(minutes=9), f"sshd[{p}]: pam_unix(sshd:session): session closed for user devuser")
# 3) 인터넷 스캐너 (203.0.113.0/24) — 소량 산발
for i in range(18):
t = base.replace(hour=random.randint(0, 23), minute=random.randint(0, 59), second=random.randint(0, 59))
ip = f"203.0.113.{random.choice([7, 45, 88, 150])}"
u = random.choice(["root", "admin", "user", "pi"]); p = nextpid(); port = random.randint(30000, 65000)
if u == "root":
S(t, f"sshd[{p}]: Failed password for root from {ip} port {port} ssh2")
else:
S(t, f"sshd[{p}]: Invalid user {u} from {ip} port {port}")
S(t, f"sshd[{p}]: Failed password for invalid user {u} from {ip} port {port} ssh2")
# 4) 웹 로그 — 정상 사용자
pages = [("/", 5120), ("/index.html", 5120), ("/about.html", 3380), ("/img/logo.png", 18420), ("/view.php?file=img/logo.png", 18420), ("/css/style.css", 2210)]
ua_b = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36"
for i in range(220):
t = base.replace(hour=random.randint(8, 22), minute=random.randint(0, 59), second=random.randint(0, 59))
ip = f"10.0.0.{random.choice([10, 21, 33, 34, 57])}"
pth, sz = random.choice(pages)
A(t, ip, f"GET {pth} HTTP/1.1", 200, sz, ua_b)
# 스캔 + 경로 탐색 + SQLi (10.0.0.128) 02:30~
t = base.replace(hour=2, minute=30)
probes = [("/admin/", 404, 196), ("/phpmyadmin/", 404, 196), ("/.git/config", 404, 196), ("/backup.zip", 404, 196), ("/wp-login.php", 404, 196)] * 6
for pth, st, sz in probes:
t += dt.timedelta(seconds=random.randint(1, 3))
A(t, "10.0.0.128", f"GET {pth} HTTP/1.1", st, sz, "curl/8.5.0")
for pth, st, sz in [("/view.php?file=../../../etc/passwd", 200, 1893), ("/view.php?file=..%2f..%2f..%2fetc%2fshadow", 200, 0),
("/view.php?file=....//....//etc/hosts", 200, 0), ("/board.php?id=1%27%20OR%20%271%27=%271", 500, 612),
("/board.php?id=1%20UNION%20SELECT%20user,pass%20FROM%20members--", 500, 612)]:
t += dt.timedelta(seconds=random.randint(2, 5))
A(t, "10.0.0.128", f"GET {pth} HTTP/1.1", st, sz, "curl/8.5.0")
sec.sort(key=lambda x: x[0]); acc.sort(key=lambda x: x[0])
# PID가 시간 순서대로 증가하도록 등장 순서에 맞춰 다시 부여
import re
remap, nxt = {}, [3100]
def rp(m):
k = m.group(1)
if k not in remap:
nxt[0] += random.randint(3, 40); remap[k] = str(nxt[0])
return "[" + remap[k] + "]"
sec = [(t, re.sub(r"\[(\d+)\]", rp, l, count=1)) for t, l in sec]
open("secure", "w").write("\n".join(l for _, l in sec) + "\n")
open("access_log", "w").write("\n".join(l for _, l in acc) + "\n")
print(f"secure: {len(sec)} lines, access_log: {len(acc)} lines")
mkdir -p ~/lab && cd ~/lab
python3 make_sample_logs.py # secure: 176 lines, access_log: 255 lines
이 글과 이후 Part 3 글의 실행 결과는 모두 이 샘플 로그에 명령을 실행한 실제 출력 이다.
grep(global regular expression print)은 파일이나 입력에서 패턴과 일치하는 줄 을 출력한다.
grep [옵션] '패턴' 파일...
명령 | grep [옵션] '패턴'
| 옵션 | 의미 | 관제 활용 예 |
|---|---|---|
-i | 대소문자 무시 | grep -i 'failed' |
-v | 일치하지 않는 줄 | 정상 IP 제외 |
-c | 일치한 줄 수 | 실패 건수 |
-n | 줄 번호 | 원본 위치 기록 |
-o | 일치한 부분만 | IP·계정 추출 |
-w | 단어 단위 일치 | root가 rootkit에 걸리지 않게 |
-E | 확장 정규식 | `A |
-F | 정규식이 아닌 고정 문자열 | 특수문자가 많은 IOC 검색 |
-A n / -B n / -C n | 뒤 / 앞 / 앞뒤 n줄 | 사건 전후 맥락 |
-r / -R | 디렉터리 재귀 | 설정 파일 전체 검색 |
-l | 일치한 파일 이름만 | 어떤 파일에 IOC가 있나 |
-h | 파일 이름 생략 | 여러 로그 합쳐서 집계 |
-f 파일 | 파일에 적힌 여러 패턴 | IOC 목록 일괄 검색 |
| 기호 | 의미 | 예 |
|---|---|---|
. | 아무 문자 하나 | r..t |
* | 앞 문자 0회 이상 | ab* |
+ | 앞 문자 1회 이상 (-E) | [0-9]+ |
? | 0 또는 1회 (-E) | (invalid user )? |
{n,m} | n~m회 (-E) | [0-9]{1,3} |
[abc] / [^abc] | 문자 집합 / 제외 | [0-9] |
^ / $ | 줄 시작 / 끝 | ^Sep 23 |
| `A | B` | A 또는 B (-E) |
\. | 문자 그대로의 점 | IP 주소 |
( ) | 묶음 (-E) | ([0-9]{1,3}\.){3} |

grep은 입력을 한 줄씩 읽어 패턴과 비교하고, 일치하는 줄만 출력한다. 이 "줄 단위" 동작이 로그 분석에 잘 맞는 이유는 syslog 계열 로그가 이벤트 하나 = 한 줄 구조이기 때문이다.
패턴을 설계할 때는 로그 한 줄을 구성 요소로 나눠 생각한다.
Sep 23 02:19:21 rocky sshd[5224]: Accepted password for devuser from 10.0.0.128 port 51514 ssh2
└ 시각 ─────────┘ └호스트┘ └프로그램[PID]┘ └ 이벤트 종류 ───┘ └ 계정 ──────┘ └ 출발지 IP ────┘
Failed password, Accepted)-o로 추출-o로 추출^Sep 23 02:1)작은따옴표로 패턴을 감싸는 습관 도 중요하다. 큰따옴표나 따옴표 없이 쓰면 쉘이 $, *, \를 먼저 해석해 의도와 다른 패턴이 grep에 전달될 수 있다(3편 확장 순서).
cd ~/lab
# 1) 이벤트 종류별 건수
grep -c "Failed password" secure
grep -c "Accepted" secure
# 2) 성공한 로그인 전체
grep "Accepted" secure
# 3) 실패 출발지 IP 집계 (sort/uniq는 24편에서 자세히)
grep "Failed password" secure \
| grep -oE "from ([0-9]{1,3}\.){3}[0-9]{1,3}" \
| sort | uniq -c | sort -rn
# 4) 공격 IP가 시도한 계정
grep "Failed password" secure | grep "10.0.0.128" \
| grep -oE "for (invalid user )?[a-z]+ " | awk '{print $NF}' \
| sort | uniq -c | sort -rn
# 5) 로그인 성공 전후 맥락
grep -n -B2 -A3 "Accepted password" secure
# 6) 권한 상승 실패
grep -E "NOT in sudoers|FAILED SU" secure
# 7) 웹 공격 패턴 (경로 탐색, SQL Injection)
grep -Ei '\.\./|%2e%2e|\.\.%2f|union.*select|%27' access_log
# 8) IOC 목록 일괄 검색
printf '10.0.0.128\n203.0.113.7\n' > ioc_ip.txt
grep -F -f ioc_ip.txt secure access_log | wc -l
# 실제 서버: 로테이션된 로그까지 (Rocky: secure-YYYYMMDD, 압축 시 zgrep)
sudo grep -h "Failed password" /var/log/secure* | wc -l
아래는 샘플 로그에 실제로 실행한 결과다.
① 건수
$ grep -c "Failed password" secure
113
$ grep -c "Accepted" secure
4
② 성공한 로그인
Sep 23 02:19:21 rocky sshd[5224]: Accepted password for devuser from 10.0.0.128 port 51514 ssh2
Sep 23 09:03:20 rocky sshd[5344]: Accepted publickey for roror from 10.0.0.10 port 58385 ssh2: RSA SHA256:q1w2e3r4t5
Sep 23 13:03:56 rocky sshd[5425]: Accepted publickey for roror from 10.0.0.10 port 59847 ssh2: RSA SHA256:q1w2e3r4t5
Sep 23 18:17:26 rocky sshd[5504]: Accepted publickey for roror from 10.0.0.10 port 59380 ssh2: RSA SHA256:q1w2e3r4t5
| 줄 | 판단 근거 |
|---|---|
| devuser (02:19) | 새벽 시간 · 비밀번호 인증 · 실패를 대량으로 낸 IP 에서 성공 → 침해 의심 |
| roror (09·13·18시) | 업무 시간 · 키 인증 · 사내 관리 IP → 정상 패턴 |
③ 실패 출발지 집계
95 from 10.0.0.128
9 from 203.0.113.7
4 from 203.0.113.88
4 from 203.0.113.150
1 from 203.0.113.45
④ 10.0.0.128이 시도한 계정
40 root
25 devuser
5 ubuntu
5 test
5 postgres
5 oracle
5 guest
5 admin
root와 흔한 기본 계정명(admin, test, oracle …)을 돌아가며 시도한 뒤 devuser에 집중 했다. 흔한 이름 목록이 아닌 실제 계정명을 알고 시도했다는 점에서, 계정 정보가 사전에 노출되었을 가능성도 검토 대상이다.
⑤ 성공 전후 맥락 (-n -B2 -A3)
125-Sep 23 02:19:10 rocky sshd[5155]: Failed password for devuser from 10.0.0.128 port 58415 ssh2
126-Sep 23 02:19:15 rocky sshd[5192]: Failed password for devuser from 10.0.0.128 port 60341 ssh2
127:Sep 23 02:19:21 rocky sshd[5224]: Accepted password for devuser from 10.0.0.128 port 51514 ssh2
128-Sep 23 02:19:21 rocky sshd[5224]: pam_unix(sshd:session): session opened for user devuser(uid=1001) by (uid=0)
129-Sep 23 02:21:21 rocky sudo[5233]: devuser : user NOT in sudoers ; TTY=pts/1 ; PWD=/home/devuser ; USER=root ; COMMAND=/bin/bash
130-Sep 23 02:22:21 rocky su[5270]: pam_unix(su-l:auth): authentication failure; logname=devuser uid=1001 euid=0 tty=pts/1 ruser=devuser rhost= user=root
-n의 :는 일치한 줄, -는 맥락 줄이다. 한 화면에서 실패 반복 → 성공 → 세션 시작 → sudo 거부 → su 실패 라는 흐름이 보인다.
| 주의점 | 설명 | 대응 |
|---|---|---|
| 한 가지 패턴만 검색 | Failed password만 보면 Invalid user, 키 인증 실패, Connection closed by authenticating user 등을 놓침 | 이벤트 종류별 패턴 목록 유지 |
| 배포판·버전별 형식 차이 | Ubuntu auth.log, 최신 OpenSSH의 sshd-session[PID] | sshd(-session)?\[ 처럼 유연하게 |
| 인코딩 우회 | 웹 공격은 %2e%2e, 이중 인코딩 %252e | 인코딩 변형 포함 |
| 부분 일치 오탐 | root가 rootkit, IP 10.0.0.1이 10.0.0.128에 일치 | -w, 정규식 경계 |
| 로테이션 | 오늘 로그만 보면 과거 흔적 누락 | secure*, zgrep |
| 원본 보호 | grep은 읽기 전용이지만, 출력 리다이렉트로 원본을 덮어쓰는 실수 주의 | 결과는 별도 경로에 저장 |
L=/var/log/secure # Ubuntu: /var/log/auth.log
grep -E "Failed password|Invalid user" $L | tail
grep "Accepted" $L
grep -E "NOT in sudoers|incorrect password attempts|FAILED SU" $L
grep -E "new user|delete user|add .* to group" $L
grep -E "COMMAND=.*(/bash|/sh)$" $L
[Event] 실패 113건 중 95건이 10.0.0.128 (02:10~02:19)
↓
[IOC] 10.0.0.128 / 시도 계정 root·devuser·기본 계정명
↓
[핵심 질문] 같은 IP에서 성공이 있는가? → grep Accepted | grep 10.0.0.128 → devuser 성공
↓
[후속 행위] sudo 거부, su 실패 → 권한 상승 시도
웹 로그: 같은 IP가 02:30부터 경로 탐색·SQLi
↓
[판단] 계정 탈취 + 권한 상승 시도 + 웹 공격 → 침해 사고로 대응
이 흐름은 42~50편에서 SIEM 연계와 함께 다시 다룬다.
-c 건수, -v 제외, -o 추출, -E 확장 정규식, -A/-B 맥락, -F -f IOC 일괄 검색.다음 글 「22. find로 파일 검색」 에서는 로그가 아니라 파일 시스템 을 검색한다. 이름·크기·권한·소유자·시각 조건을 조합해 최근 변경된 파일, SUID 파일, 임시 경로의 실행 파일처럼 침해 흔적이 될 수 있는 파일을 찾는 헌팅 쿼리 를 정리한다.