리눅스 시스템 기초 · 입문편 — 본문의 "N편"은 입문 과정 번호다. 번호별 글과 전체 250편 구성은 통합 로드맵에서 확인할 수 있다.
리눅스 시스템 기초 28 / 50 · Part 3. Linux 명령어 활용
실습 환경: Rocky Linux 9 (10.0.0.200) · 21편 실습용 샘플 로그
이전 글: 27. 파이프라인과 리다이렉션
21~27편에서 만든 명령어 조합은 매번 손으로 입력하기엔 길다. 같은 분석을 반복하거나 여러 서버에서 똑같이 실행하려면 스크립트 로 묶어야 한다. 이번 글은 Bash 스크립트의 기본 문법을 정리하고, Part 3의 grep·awk·sort를 모아 SSH 인증 로그 요약 리포트 스크립트를 만든다.
관제 업무에서 스크립트를 쓸 때 가장 중요한 것은 문법보다 안전성 이다.
그래서 이 글은 "조용히 틀리지 않는 스크립트" 를 목표로 한다.
| 요소 | 문법 | 예 |
|---|---|---|
| shebang | #!/bin/bash | 첫 줄, 인터프리터 지정 |
| 변수 | NAME=값 (공백 없이), 사용 "$NAME" | LOG=/var/log/secure |
| 인자 | $1, $2, $#(개수), $@(전체) | LOG="${1:-/var/log/secure}" |
| 명령 치환 | $(명령) | N=$(wc -l < "$LOG") |
| 산술 | $(( )), 비교 (( a >= b )) | |
| 조건 | if [[ 조건 ]]; then … elif … else … fi | |
| 반복 | for x in …; do … done, while read -r line; do … done | |
| 함수 | name() { … }, 인자는 함수 안의 $1 | |
| 종료 코드 | exit 0 성공, exit 1 실패, 직전 결과 $? |
| 조건 | 의미 |
|---|---|
-f 파일 / -d 디렉터리 | 존재하는 일반 파일 / 디렉터리 |
-r / -w / -x | 읽기 / 쓰기 / 실행 가능 |
-z "$s" / -n "$s" | 빈 문자열 / 비어 있지 않음 |
"$a" == "$b" | 문자열 같음 |
"$s" =~ 정규식 | 정규식 일치 ([[ ]] 안에서) |
(( n > 10 )) | 숫자 비교 |
| 옵션 | 효과 |
|---|---|
set -e | 명령이 실패하면 즉시 종료 |
set -u | 정의되지 않은 변수를 쓰면 종료 (오타 방지) |
set -o pipefail | 파이프 중간 명령 실패도 실패로 처리 (27편) |
set -e와 grep을 함께 쓸 때 주의할 점이 있다. grep은 일치하는 줄이 없으면 종료 코드 1 을 반환하므로, "0건"이 정상인 경우에도 스크립트가 멈춘다. 그래서 grep -c … || true처럼 결과 없음을 허용하는 처리를 넣는다.

스크립트를 실행하면(./script.sh) 커널이 첫 줄 #!/bin/bash를 보고 bash를 실행해 파일 내용을 넘긴다. 새 bash 프로세스(자식)에서 실행되므로, 스크립트 안의 cd나 변수는 실행한 쉘에 영향을 주지 않는다(9편 환경 상속).
실행 방법에 따른 차이:
| 방법 | 동작 |
|---|---|
./script.sh | 실행 권한(x) 필요, shebang의 인터프리터로 새 프로세스 |
bash script.sh | 실행 권한 불필요, bash로 새 프로세스 |
source script.sh / . script.sh | 현재 쉘 에서 실행 — 변수·cd가 현재 쉘에 남음 |
#!/bin/bash
# auth_report.sh — SSH 인증 로그 요약 리포트
# 사용법: auth_report.sh [로그파일] [실패 임계치]
# 예) sudo ./auth_report.sh /var/log/secure 10
set -euo pipefail
LOG="${1:-/var/log/secure}" # 인자가 없으면 기본값
THRESHOLD="${2:-10}"
OUT="report_$(date +%Y%m%d_%H%M%S).txt"
# ── 함수 ─────────────────────────────────────────
die() { echo "[오류] $*" >&2; exit 1; }
line() { printf '%.0s-' {1..60}; echo; }
count() { # count <패턴> → 로그에서 패턴 줄 수 (없으면 0)
grep -cE -- "$1" "$LOG" || true
}
# ── 입력 검증 ─────────────────────────────────────
[[ -r "$LOG" ]] || die "로그 파일을 읽을 수 없음: $LOG"
[[ "$THRESHOLD" =~ ^[0-9]+$ ]] || die "임계치는 숫자여야 함: $THRESHOLD"
{
echo "SSH 인증 로그 요약 — $(hostname -s) — $(date '+%F %T %Z')"
echo "대상: $LOG ($(wc -l < "$LOG") lines) / 임계치: ${THRESHOLD}회"
line
echo "로그인 실패 : $(count 'Failed password')"
echo "없는 계정 시도 : $(count 'Invalid user')"
echo "로그인 성공 : $(count 'Accepted')"
echo "sudo 거부 : $(count 'NOT in sudoers')"
echo "su 실패 : $(count 'FAILED SU')"
line
echo "[실패 임계치 초과 출발지]"
grep 'Failed password' "$LOG" | grep -oE 'from ([0-9]{1,3}\.){3}[0-9]{1,3}' \
| awk '{print $2}' | sort | uniq -c | sort -rn \
| while read -r cnt ip; do
if (( cnt >= THRESHOLD )); then
printf ' %-16s %5d회\n' "$ip" "$cnt"
fi
done
line
echo "[실패 후 성공한 출발지 — 우선 확인 대상]"
for ip in $(grep 'Accepted' "$LOG" | grep -oE 'from ([0-9]{1,3}\.){3}[0-9]{1,3}' | awk '{print $2}' | sort -u); do
fails=$(grep 'Failed password' "$LOG" | grep -c "from $ip " || true)
if (( fails > 0 )); then
grep 'Accepted' "$LOG" | grep "from $ip " | awk -v f="$fails" '{printf " %s %s %s(%s) ← 실패 %d회 후 성공\n", $1" "$2, $3, $9, $7, f}'
fi
done
line
} | tee "$OUT"
sha256sum "$OUT" | tee -a "$OUT.sha256" > /dev/null
echo "[저장] $OUT"
cd ~/lab
chmod 700 auth_report.sh
./auth_report.sh secure 10 # 샘플 로그, 임계치 10회
./auth_report.sh nofile # 입력 검증 확인
./auth_report.sh secure abc
# 실제 서버: sudo ./auth_report.sh /var/log/secure 20
샘플 로그에 실행한 실제 결과다 (호스트명만 가림).
SSH 인증 로그 요약 — (호스트명) — 2026-09-23 19:12:01 KST
대상: secure (176 lines) / 임계치: 10회
------------------------------------------------------------
로그인 실패 : 113
없는 계정 시도 : 45
로그인 성공 : 4
sudo 거부 : 1
su 실패 : 1
------------------------------------------------------------
[실패 임계치 초과 출발지]
10.0.0.128 95회
------------------------------------------------------------
[실패 후 성공한 출발지 — 우선 확인 대상]
Sep 23 02:19:21 devuser(password) ← 실패 95회 후 성공
------------------------------------------------------------
[저장] report_20260923_191201.txt
$ ./auth_report.sh nofile
[오류] 로그 파일을 읽을 수 없음: nofile (exit 1)
$ ./auth_report.sh secure abc
[오류] 임계치는 숫자여야 함: abc (exit 1)
| 결과 항목 | 의미 |
|---|---|
| 임계치 초과 출발지 | 인터넷 스캐너(203.0.113.x, 최대 9회)는 걸러지고 집중 공격 IP만 남음 |
| 실패 후 성공 | 21편에서 손으로 찾던 핵심 질문을 자동으로 답함 |
인증 방식 (password) | 관리자는 키 인증(publickey), 침해 계정은 비밀번호 인증 |
| 입력 오류 시 종료 | 잘못된 경로를 "0건"으로 보고하지 않음 |
| 원칙 | 이유 | 적용 |
|---|---|---|
| 변수는 큰따옴표로 | 공백·와일드카드가 든 값이 여러 인자로 쪼개지거나 확장됨 | "$LOG" |
| 입력 검증 | 사용자 입력(인자)이 명령에 그대로 들어감 | 정규식으로 형식 확인 |
-- 사용 | 패턴이 -로 시작하면 옵션으로 해석됨 | grep -- "$1" |
절대경로·cd 실패 처리 | 12편 상대경로 문제 | `cd /dir |
임시 파일은 mktemp | 예측 가능한 /tmp/파일명은 링크 공격 대상 (12편) | tmp=$(mktemp) |
| 스크립트 권한 | 다른 사용자가 수정 가능하면 권한 상승 경로 (15편) | root 실행 스크립트는 700 root:root |
| 비밀번호 하드코딩 금지 | 스크립트 파일에서 노출 | 별도 권한 파일, 환경변수 |
| 방식 | 예 | 주의 |
|---|---|---|
| 수동 Triage | 경보 발생 시 서버에서 실행 (10편) | 결과 해시 기록 |
| 정기 실행 | cron으로 매일 리포트 (38편) | 실행 계정·권한 최소화 |
| SIEM 보조 | SIEM이 없는 서버, SIEM 결과 교차 검증 | 결과를 중앙 서버로 전송 |
관제용 스크립트는 보통 root로 실행된다. 그래서 공격자에게는 권한 상승 경로 가 될 수 있다.
# 점검: root cron·systemd가 실행하는 스크립트 중 다른 사용자가 쓸 수 있는 것
sudo find /usr/local/bin /opt /etc/cron.* -type f -perm /022 -ls 2>/dev/null
[경보] SIEM: SSH 실패 급증
↓
[스크립트] sudo ./auth_report.sh /var/log/secure 20
↓
[결과] 임계치 초과 IP, 실패 후 성공 계정·시각·인증 방식
↓
[판단] "실패 후 성공"이 있으면 침해 의심 → 해당 계정의 이후 행위 조사(29편)
없으면 차단·모니터링
↓
[기록] 리포트 파일 + sha256 → 티켓에 첨부
#!/bin/bash, 안전 옵션은 set -euo pipefail 이다.${1:-기본값}으로 받고, 변수는 항상 큰따옴표 로 감싼다.stderr 메시지와 함께 exit 1.set -e 환경에서는 || true로 처리한다../, bash는 새 프로세스, source는 현재 쉘에서 실행된다.다음 글 「29. 명령어 조합을 이용한 시스템 분석」 에서는 로그를 넘어 살아 있는 시스템 을 분석한다. 이번 스크립트가 찾아낸 "실패 후 성공한 계정"을 출발점으로 ps, ss, lsof, find, /proc를 조합해 그 계정이 서버에서 무엇을 실행하고 어디와 통신했는지 추적한다.