리눅스 시스템 기초 · 입문편 — 본문의 "N편"은 입문 과정 번호다. 번호별 글과 전체 250편 구성은 통합 로드맵에서 확인할 수 있다.

리눅스 시스템 기초 21 / 50 · Part 3. Linux 명령어 활용
실습 환경: Rocky Linux 9 (10.0.0.200) · 실습용 샘플 로그 (아래 생성 스크립트)
이전 글: 20. 최소 권한 원칙

1. 들어가며

Part 3 「Linux 명령어 활용」을 시작한다. Part 1·2에서 grep, awk, sort를 이미 여러 번 썼지만 설명 없이 지나갔다. Part 3에서는 이 명령어들을 로그 분석 도구 로서 제대로 정리한다.

첫 글은 grep이다. 보안관제에서 가장 많이 쓰는 명령어를 하나만 꼽으라면 grep이다. SIEM이 있어도 원본 서버에서 로그를 직접 확인해야 하는 순간이 반드시 오고, 그때 원하는 줄을 빠르고 정확하게 골라내는 능력 이 분석 속도를 결정한다.

이번 Part의 실습 데이터

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 글의 실행 결과는 모두 이 샘플 로그에 명령을 실행한 실제 출력 이다.


2. 핵심 개념

2-1. grep이란

grep(global regular expression print)은 파일이나 입력에서 패턴과 일치하는 줄 을 출력한다.

grep [옵션] '패턴' 파일...
명령 | grep [옵션] '패턴'

2-2. 주요 옵션

옵션의미관제 활용 예
-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 목록 일괄 검색

2-3. 정규표현식 기초

기호의미예
.아무 문자 하나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
`AB`A 또는 B (-E)
\.문자 그대로의 점IP 주소
( )묶음 (-E)([0-9]{1,3}\.){3}

3. 동작 원리

grep으로 인증 로그 읽기 — 패턴 설계

grep은 입력을 한 줄씩 읽어 패턴과 비교하고, 일치하는 줄만 출력한다. 이 "줄 단위" 동작이 로그 분석에 잘 맞는 이유는 syslog 계열 로그가 이벤트 하나 = 한 줄 구조이기 때문이다.

패턴을 설계할 때는 로그 한 줄을 구성 요소로 나눠 생각한다.

Sep 23 02:19:21  rocky  sshd[5224]:  Accepted password  for devuser  from 10.0.0.128  port 51514 ssh2
└ 시각 ─────────┘ └호스트┘ └프로그램[PID]┘ └ 이벤트 종류 ───┘ └ 계정 ──────┘ └ 출발지 IP ────┘
  • "무슨 일이 있었나" → 이벤트 종류 로 grep (Failed password, Accepted)
  • "누가" → 계정 부분을 -o로 추출
  • "어디서" → IP 부분을 -o로 추출
  • "언제" → 시각으로 범위를 좁힘 (^Sep 23 02:1)

작은따옴표로 패턴을 감싸는 습관 도 중요하다. 큰따옴표나 따옴표 없이 쓰면 쉘이 $, *, \를 먼저 해석해 의도와 다른 패턴이 grep에 전달될 수 있다(3편 확장 순서).


4. 실습

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

5. 결과 분석

아래는 샘플 로그에 실제로 실행한 결과다.

① 건수

$ 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 실패 라는 흐름이 보인다.


6. 보안 관점

주의점설명대응
한 가지 패턴만 검색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은 읽기 전용이지만, 출력 리다이렉트로 원본을 덮어쓰는 실수 주의결과는 별도 경로에 저장

7. SOC / 보안관제 활용

7-1. 인증 로그 기본 검색 세트

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

7-2. 분석 흐름 (이번 샘플)

[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 연계와 함께 다시 다룬다.


8. 핵심 정리

  • grep은 줄 단위 로 패턴과 일치하는 줄을 출력한다. syslog 계열 로그는 이벤트 하나가 한 줄이라 잘 맞는다.
  • 로그 한 줄을 시각·프로그램·이벤트·계정·IP 로 나눠 패턴을 설계한다.
  • 핵심 옵션: -c 건수, -v 제외, -o 추출, -E 확장 정규식, -A/-B 맥락, -F -f IOC 일괄 검색.
  • 패턴은 작은따옴표 로 감싼다.
  • 실패만 보지 말고 같은 출발지의 성공 여부와 그 이후 행위 를 반드시 확인한다.
  • 배포판·OpenSSH 버전별 형식 차이와 로테이션된 로그까지 고려한다.

9. 다음 글

다음 글 「22. find로 파일 검색」 에서는 로그가 아니라 파일 시스템 을 검색한다. 이름·크기·권한·소유자·시각 조건을 조합해 최근 변경된 파일, SUID 파일, 임시 경로의 실행 파일처럼 침해 흔적이 될 수 있는 파일을 찾는 헌팅 쿼리 를 정리한다.


참고 자료

profile
코드에 숨겨진 위협을 읽고 AI로 보안의 미래를 설계합니다. 프론트엔드 개발 경험을 자산 삼아 더 견고하고 지능적인 보안 운영 시스템을 구축해 나가는 과정을 기록합니다

0개의 댓글