리눅스 서버의 CPU 사용률이 갑자기 늘어나면 가장 먼저 top 명령어로 어떤 프로세스가 CPU를 잡아먹고 있는지 찾아봐야 한다.
그리고 그 프로세스가 정상적인 연산 부하인지, I/O 병목인지, 정상 프로세스로 위장한 악성코드인지를 구분하려면 ps, /proc, strace, lsof로 이어지는 교차검증이 필요하다.
CPU 과점유 원인은 크게 세 가지로 나뉘고, 그 판별 흐름은 다음과 같다.

top 실행 결과, %CPU의 us/sy 비율, /proc/[PID]/exe가 가리키는 실제 실행 파일, strace -c의 syscall 분포가 각각 다른 결론을 뒷받침하는 근거가 된다.
1. 실습 환경 준비
1️⃣ 실습 환경 사양
이 실습은 의도적으로 CPU를 포화시키므로, 관찰과 조작을 위한 여유분을 확보한 스펙이 필요하다.
VMware Workstation 기준 권장 설정은 다음과 같다.
| 항목 | 최소 | 권장 | 목적 |
| 프로세서 (vCPU) | 2코어 | 4코어 | 부하 코어와 별개로 top·명령 입력용 여유 코어 확보 |
| 메모리 | 2GB | 4GB | 시나리오 B의 로그 파일 급증과 도구 동시 실행 대비 |
| 디스크 | 20GB | 40GB (여유) | 시나리오 B가 /tmp에 로그를 무한 기록 |
VMware에서 프로세서 설정은 다음과 같이 하면 된다.

2️⃣ 실습 도구 설치
분석 도구는 대부분 배포판에 기본 포함되지 않으므로 직접 설치한다.
nproc으로 코어 수를 확인하는 것이 중요한데, load average 값을 해석하는 기준이 되기 때문이다.
load average가 코어 수를 넘어서면 실행 대기 중인 프로세스가 쌓이고 있다는 뜻이다.
# Ubuntu/Debian 기준
sudo apt update && sudo apt install -y htop stress-ng strace lsof
# Rocky 기준
# EPEL 저장소 활성화 (stress-ng가 여기에 있음)
sudo dnf install -y epel-release
# 실습용 도구 설치
sudo dnf install -y htop stress-ng strace lsof

3️⃣ 코어 수 확인
load average 해석 기준이 된다.
nproc

🟢 load average와 nproc의 관계
load average는 실행 중이거나 실행 대기 중인 프로세스의 이동 평균이다.
4코어 시스템에서 load average가 4.0이면 모든 코어가 빈틈없이 일하는 이상적 포화 상태고, 8.0이면 절반의 프로세스가 CPU를 기다리며 놀고 있다는 뜻이다.
그래서 load average 숫자 자체보다 nproc 대비 비율이 판단 기준이 된다.
2. 시나리오 A: 정상 프로세스의 CPU 과점유
가장 단순한 케이스부터 시작한다.
악의 없이 그저 무한 루프를 도는 프로세스가 코어를 100% 점유하는 상황이다.
이 경우 CPU 시간이 어디에 쓰이는지를 먼저 이해해야 한다.
1️⃣ user 시간과 system 시간의 구분
top의 CPU 통계 줄에 나오는 us(user)와 sy(system)는 CPU가 어느 공간에서 시간을 쓰는지를 나타낸다.
us는 애플리케이션 코드 자체의 연산, sy는 커널이 시스템 콜을 처리하는 시간이다.
순수한 산술 루프는 커널에 아무것도 요청하지 않으므로 us 값만 치솟고 sy는 거의 0에 가깝게 유지된다.
2️⃣ 범인 프로세스 생성
무한 루프 스크립트를 만들어 코어 수만큼 백그라운드로 띄운다.
while true; do :; done에서 :은 아무것도 하지 않고 '성공'만 반환하는 내장 명령으로, 순수한 CPU 연산 루프를 만든다.
# 무한 루프로 1코어 100% 점유하는 스크립트
cat > /tmp/cpu_hog.sh << 'EOF'
#!/bin/bash
while true; do :; done
EOF
chmod +x /tmp/cpu_hog.sh
# 백그라운드로 4개 실행 (멀티코어 부하 재현)
for i in 1 2 3 4; do /tmp/cpu_hog.sh & done
# PID 확인
jobs -p

3️⃣ 1단계: 범인 프로세스 식별
top # 실행 후 대문자 P (CPU 정렬), 1 (코어별 사용률 펼치기)
top 실행 후 대문자 P로 CPU 사용률 정렬, 숫자 1로 코어별 사용률을 펼쳐 본다.
🟢 확인 포인트

각 cpu_hog.sh가 %CPU 100% 근처에 붙는다.

순수 user 연산이기 때문에 상단 통계 줄에서 us가 높고 sy는 낮게 나온다.
load average가 nproc 값에 근접하거나 초과하는지 같이 확인해본다.
stress-ng --cpu 4 --timeout 60s로도 같은 그림을 재현해 비교하면 정상 부하의 시그니처가 눈에 익는다.

4️⃣ 2단계: 상세 확인
top이 지목한 PID를 /proc 파일시스템에서 더 자세히 확인한다.
/proc/[PID] 아래의 특수 파일들은 커널이 실시간으로 제공하는 프로세스 메타데이터다.
PID=$(pgrep -f cpu_hog.sh | head -1)
ps -p $PID -o pid,ppid,cmd,%cpu,%mem

🟢 exe → /usr/bin/bash: 실제 실행 파일은 인터프리터
ls -l /proc/$PID/exe # → /bin/bash 를 가리킴 (인터프리터)

/proc/[PID]/exe는 커널이 관리하는, 이 프로세스가 실제로 실행 중인 바이너리에 대한 심볼릭 링크다.
cpu_hog.sh 스크립트를 실행했지만 실제 프로세스의 실행 파일은 스크립트 자신이 아니라 인터프리터인 /usr/bin/bash를 가리킨다.
스크립트 첫 줄의 #!/bin/bash 셰뱅(shebang)에 따라 커널이 bash를 실행하고, 그 bash가 스크립트를 읽어 실행하기 때문이다.
정상적인 셸 스크립트라면 당연한 결과다.
만약 위장 악성코드였다면 여기서 /usr/bin/bash가 아니라 /tmp/sshd 같은 의심스러운 경로가 찍힌다.
참고로 Rocky(RHEL 계열)에서는 경로가 /usr/bin/bash로 나온다.
Ubuntu에서는 흔히 /bin/bash로 보이지만, 요즘 배포판은 /bin이 /usr/bin으로 향하는 심볼릭 링크(usr-merge)라 실질적으로 같은 파일을 가리킨다.
🟢 cwd → /root: 작업 디렉터리
ls -l /proc/$PID/cwd

/proc/[PID]/cwd는 프로세스의 현재 작업 디렉터리(current working directory)를 가리키는 링크다.
root 사용자가 홈 디렉터리 /root에서 스크립트를 실행했으므로 cwd가 /root로 잡힌다.
이 값은 프로세스가 상대 경로로 파일을 다룰 때 기준이 되는 위치이고, 보안 관점에서는 프로세스가 /tmp나 /dev/shm 같은 의심스러운 디렉터리에서 돌고 있지 않은지 확인하는 단서가 된다.
여기서는 /root이므로 정상적인 관리자 실행으로 읽힌다.
🟢 cmdline → 실행 명령줄 전체
cat /proc/$PID/cmdline | tr '\0' ' '; echo # 실제 인자

/proc/[PID]/cmdline은 프로세스를 실행할 때 사용된 명령줄 인자를 담고 있다.
인자들이 널 문자(\0)로 구분되어 저장되어 있어 그냥 cat하면 붙어 나오므로, tr '\0' ' '로 널을 공백으로 치환해 사람이 읽을 수 있게 만든 것이다.
뒤의 echo는 마지막에 줄바꿈을 넣어 프롬프트가 다음 줄에서 시작되게 하는 역할이다.
출력된 /bin/bash /tmp/cpu_hog.sh는 "/bin/bash가 /tmp/cpu_hog.sh를 인자로 받아 실행했다"는 뜻으로, 앞서 exe가 bash를 가리킨 이유와 정확히 맞물린다.
즉 exe는 "무엇이 실행 중인가(bash)"를, cmdline은 "어떤 명령으로 시작됐는가(bash가 /tmp/cpu_hog.sh를 실행)"를 알려주며, 두 정보를 합치면 이 프로세스의 정체가 /tmp에 있는 셸 스크립트임이 확인된다.
정리하면 위의 세 출력을 통해 /root에서 root가 실행한, /tmp/cpu_hog.sh라는 셸 스크립트가 bash 인터프리터 위에서 돌고 있다는 것이다.
실행 경로(/usr/bin/bash)와 작업 디렉터리(/root) 모두 정상 범위이므로, 이 프로세스는 정상 부하 프로세스임이 확인된다.
5️⃣ 3단계: 추적
timeout 5 strace -p $PID -c # -c: 시스템콜 통계 요약

strace -c는 프로세스가 호출하는 시스템 콜을 5초간 집계해 요약한다.
attached는 strace가 대상 프로세스에 성공적으로 붙었다는 뜻이고, detached는 timeout 5에 의해 5초가 지나 strace가 떨어져 나갔다는 뜻이다.
여기서 중요한 건 attach/detach 사이에 아무 통계도 출력되지 않았다는 점이다.
-c 옵션은 원래 syscall별 호출 횟수와 소요 시간을 표로 보여주는데, 그 표가 비어 있다.
이유는 대상 프로세스가 순수 CPU 루프이기 때문이다.
이 루프는 산술 연산만 반복할 뿐 커널에 아무것도 요청하지 않는다.
파일을 읽지도, 쓰지도, 네트워크로 통신하지도 않으므로 시스템 콜이 발생하지 않는다.
strace는 시스템 콜을 가로채는 도구인데 시스템 콜 자체가 없으니 집계할 것이 없어 표가 비는 것이다.
만약 I/O나 네트워크 문제였다면 여기서 read/write/sendto가 폭주했을 것이다.
6️⃣ 정리
pkill -f cpu_hog.sh

3. 시나리오 B: I/O 유발형
시나리오 A와 대조군이 되는 케이스다.
파일에 끊임없이 쓰는 프로세스는 CPU 연산이 아니라 시스템 콜을 통해 부하를 만든다.
1️⃣ 개념: I/O 부하가 남기는 흔적
파일 쓰기는 사용자 공간의 산술 연산과 달리, 매번 커널에게 "이 파일을 열어라, 여기에 써라, 닫아라"를 요청한다.
이 요청이 시스템 콜이고, CPU가 커널 공간에서 이를 처리하는 시간이 top의 sy(system)로 집계된다.
따라서 I/O 부하는 순수 연산과 달리 us만이 아니라 sy도 함께 끌어올린다는 점이 판별의 핵심 단서다.
2️⃣ 범인 프로세스 생성
파일에 무한히 쓰는 스크립트를 만들어 백그라운드로 실행한다.
>> 리다이렉션은 루프가 한 바퀴 돌 때마다 파일을 열고 쓰고 닫는 동작을 반복한다.
# 파일을 계속 쓰는 프로세스
cat > /tmp/io_hog.sh << 'EOF'
#!/bin/bash
while true; do echo test >> /tmp/io_test.log; done
EOF
chmod +x /tmp/io_hog.sh
#백그라운드로 실행
/tmp/io_hog.sh &
3️⃣ 1단계: 범인 프로세스 식별
top # 실행 후 대문자 P (CPU 정렬), 1 (코어별 사용률 펼치기)
🟢 확인 포인트

io_hog.sh는 단일 프로세스라 한 코어만 점유한다.
코어별 사용률을 펼쳐 보면 코어 하나만 일하고 나머지는 id(idle)가 99% 이상으로 놀고 있다.
load average도 시나리오 A(4코어 포화 시 4.0 근처)와 달리 1.0 근처에 머문다.
그리고 가장 중요한 관찰은 부하가 걸린 코어의 us와 sy가 거의 반반으로 나뉜다는 점이다.
순수 CPU 루프였을 때는 us가 압도적이고 sy는 0에 가깝게 나왔는데, 파일 I/O라 커널 처리 시간인 sy가 크게 올라온다.
4️⃣ 2단계: 상세 확인
top이 지목한 PID를 /proc와 lsof로 이 프로세스가 어떤 파일을 붙잡고 있는지 확인한다.
PID=$(pgrep -f io_hog.sh)
ps -p $PID -o pid,ppid,cmd,%cpu,%mem

ls -l /proc/$PID/exe # → /usr/bin/bash (인터프리터)

cat /proc/$PID/cmdline | tr '\0' ' '; echo

5️⃣ 3단계: 추적
timeout 5 strace -p $PID -c # write, open 계열이 압도적으로 나옴
🟢 확인 포인트
시나리오 A에서는 strace 조회 결과가 비어 있었지만, 여기서는 파일 조작 계열 syscall이 채워져 있다.
실제 출력은 다음과 같은 형태다.

각 열은 순서대로 전체 대비 시간 비율(% time), 총 소요 시간(seconds), 호출당 평균 시간(usecs/call), 호출 횟수(calls), 오류 수(errors), 시스템 콜 이름(syscall)이다.
while true; do echo test >> ...; done 루프가 한 바퀴 돌 때마다 파일을 열고(openat), 디스크립터를 복제·조정하고(dup2, fcntl), 문자열을 쓰고(write), 닫는(close) 동작이 반복되며, 5초간 총 38,460회의 호출이 집계됐다.
이 syscall 폭주가 앞서 top에서 본 sy 상승의 원인이며, 부하의 원인이 파일 I/O라는 결론을 객관적 수치로 보여준다.
| 관찰 지점 | 시나리오 A (사용자 연산) | 시나리오 B(I/O 폭주) |
| 부하 코어 수 | 여러 코어 포화 | 1코어만 (load ≈ 1.0) |
| top의 us/sy | us 압도적, sy ≈ 0 | us ≈ sy |
| strace -c 통계표 | 거의 비어 있음 | write·openat·close 폭주 |
| lsof로 보이는 자원 | 특이 파일 없음 | 특정 로그 파일(3w) |
| 결론의 근거 | syscall 부재 = 순수 CPU | syscall 폭주 = I/O 병목 |
6️⃣ 정리
I/O 유발형 프로세스는 로그 파일이 순식간에 불어나므로, 프로세스 종료와 함께 생성된 파일도 반드시 삭제한다.
pkill -f io_hog.sh; rm -f /tmp/io_test.log
4. 시나리오 C: 위장 악성코드 판별
보안 관점에서 가장 중요한 케이스다.
이번에는 정상 프로세스명으로 위장했지만 실행 경로가 의심스러운 프로세스를 재현하고, top 명령 만으로는 확인할 수 없는 부분은 /proc으로 확인해본다.
실제 침해 사고에서 마이너나 백도어는 흔히 sshd, kworker 같은 정상 프로세스명을 흉내 낸다.
1️⃣ exe 심볼릭 링크가 위조 불가능한 이유
ps가 보여주는 프로세스 이름은 프로세스 메모리 안의 argv[0] 문자열인데, 이것은 프로세스 자신이 자유롭게 쓸 수 있는 값이다.
반면 /proc/[PID]/exe는 커널이 실행 시점(execve)에 기록한 실제 실행 파일에 대한 심볼릭 링크로, 프로세스가 조작할 수 없다.

2️⃣ 위장 프로세스 생성
/bin/bash를 /tmp/sshd로 복사한 뒤, exec -a로 프로세스명을 sshd로 위장해 실행한다.
exec -a는 실행 파일과 무관하게 argv[0]을 임의로 지정하는 옵션이라, ps에 표시되는 이름을 속이는 전형적 기법이다.
cp /bin/bash /tmp/sshd
chmod +x /tmp/sshd
( exec -a "sshd" /tmp/sshd -c 'while true; do :; done' ) &
jobs -p
3️⃣ 1단계: 범인 프로세스 식별
top # 실행 후 대문자 P, 1 (코어별 사용률 펼치기)
🟢 확인 포인트

top에서 CPU를 100% 점유하는 프로세스가 잡히는데, 이름이 sshd로 나온다.
여기까지는 정상 SSH 데몬과 구별되지 않아 그냥 지나치기 쉽다.
순수 연산 루프이므로 시나리오 A와 마찬가지로 us가 높고 sy는 낮다.
즉 부하의 성격만으로는 위장 여부를 알 수 없고, 다음 단계의 실행 경로 확인이 반드시 필요하다.
4️⃣ 2단계: 상세 확인
top에서 지목된 PID를 /proc으로 들어가서 ps가 보여주는 이름과 실제 실행 경로가 일치하는지 대조한다.
PID=$(pgrep -f 'while true' | tail -1)
ps -ef | grep $PID # ps상으로는 sshd처럼 보임 (속기 쉬움)

ps -ef의 출력만 보면 정상적인 sshd와 구별되지 않는다.
그러나 /proc/$PID/exe를 확인하는 순간 /tmp/sshd라는 실제 경로가 드러난다.
ls -l /proc/$PID/exe # → /tmp/sshd 정체 탄로! 정상이면 /usr/sbin/sshd

정상 sshd라면 exe는 /usr/sbin/sshd를 가리킨다. /tmp, /dev/shm, /var/tmp 같은 경로에서 실행 중인 프로세스는 강한 의심 신호다.
이 디렉터리들은 일반 사용자도 쓸 수 있고 종종 noexec가 걸려 있지 않아, 공격자가 자주 이 위치에 페이로드를 떨어뜨려 실행하기 때문이다.
5️⃣ 3단계: 추적
CPU만 태우는 마이너와 달리, 백도어나 C2 클라이언트는 외부와 통신한다.
위장 프로세스가 열어 둔 소켓과 원격 주소를 확인하면 단순 CPU 과점유를 넘어 침해 여부까지 판별할 수 있다.
lsof -p $PID # 열린 파일/소켓
lsof -p는 프로세스가 연 모든 파일을 나열하는데, FD 열에는 숫자 디스크립터뿐 아니라 특수한 약어가 들어간다.
이 약어들이 프로세스의 구조를 보여준다.

| FD값 | 의미 |
| cwd | 현재 작업 디렉터리 (current working directory) |
| rtd | 루트 디렉터리 (root directory) |
| txt | 실행 중인 프로그램 바이너리 자체 (text segment) |
| mem | 메모리에 매핑된 파일 (주로 공유 라이브러리) |
| 0u,1u,2u | 표준 입출력. stdin/stdout/stderr, u는 읽기·쓰기 모두 |
txt는 이 프로세스가 실제로 실행 중인 바이너리를 가리킨다.
COMMAND 열에는 sshd라고 찍혀 있지만(argv[0] 위장 때문), txt가 가리키는 실제 실행 파일은 /tmp/sshd다.
정상 SSH 데몬이라면 이 자리에 /usr/sbin/sshd가 나와야 한다.
앞서 /proc/$PID/exe로 확인한 것과 같이 ps의 이름은 속여도, 커널이 관리하는 실제 바이너리 경로는 이렇게 두 경로(/proc/exe, lsof txt) 모두에서 /tmp/sshd로 드러난다.
/tmp에서 실행 중인 바이너리라는 것 자체가 강한 침해 신호다.
정상적인 시스템 데몬이 /tmp에 실행 파일을 두고 돌아갈 이유가 없기 때문이다.
lsof -i -P -n | grep $PID # 외부 IP 연결 여부 (C2 통신 탐지 관점)
ss -tnp | grep $PID # 연결된 원격 주소
이번 실습의 위장 프로세스는 순수 CPU 루프라 네트워크 연결이 잡히지 않는다.
그러나 실제 침해 사고라면 이 단계에서 알려지지 않은 외부 대역으로 향하는 연결이 드러나는 경우가 많다.
lsof -i와 ss -tnp로 나타난 원격 주소가 정상 서비스 대역이 아니라면, 분석 범위를 침해 대응으로 넓혀야 한다.
6️⃣ 정리
kill $PID; rm -f /tmp/sshd
5. 마무리
세 시나리오 모두 마지막 단계는 로그 대조다.
프로세스 실시간 상태와 로그에 남은 기록이 일치하는지를 확인하는 과정이다.
🟢 Ubuntu/Debian 계열 기준
sudo journalctl -u ssh --since "10 min ago" # systemd 서비스 로그
sudo tail -f /var/log/syslog
sudo journalctl -f # 실시간 전체
🟢 RHEL 계열 기준
sudo journalctl -u sshd --since "10 min ago" # 서비스 유닛명이 sshd
sudo tail -f /var/log/messages # syslog 대신 messages
sudo tail -f /var/log/secure # 인증 로그는 secure
sudo journalctl -f # 실시간 전체 (동일)
시나리오 C에서 진짜 sshd 서비스는 systemd가 관리하므로 journalctl -u ssh에 인증 시도, 재시작 등의 기록이 남는다.
그러나 exec -a로 위장한 가짜 프로세스는 systemd 유닛 밖에서 실행되었으므로 해당 서비스 로그에 아무 흔적도 남기지 않는다.
이 불일치, 즉 ps에는 sshd가 있는데 sshd 서비스 로그에는 대응하는 활동이 없다는 사실 자체가 위장을 확증하는 교차검증 근거가 된다.
전체 분석에서 중요한 것은 단일 지표를 신뢰하지 않는 것이다.
top에서의 프로세스 지목, /proc/[PID]/exe의 실제 경로, strace -c의 syscall 분포, 그리고 로그의 정합성이 서로 검증될 때 비로소 정상 부하인지, I/O 병목인지, 위장 악성코드인지 근거 있는 결론을 내릴 수 있다.
'Journey to Security > Linux OS' 카테고리의 다른 글
| UEFI 펌웨어 구조와 부팅 원리 (0) | 2026.08.08 |
|---|---|
| 리눅스 싱글모드 - GRUB2 보안 설정과 다계층 방어의 중요성 (0) | 2026.06.25 |
| 리눅스 rescue mode에서 chroot /sysroot 동작 원리 (0) | 2026.06.23 |
| awk - 필드 단위 텍스트 처리 도구 (0) | 2026.06.18 |
| sed에서 사용하는 정규표현식 - BRE와 ERE (0) | 2026.06.18 |