상세 컨텐츠

본문 제목

SSH 끊어도 죽지 않는 퍼징 캠페인 만들기 (nohup은 왜 죽었나)

보안

by 코드스키 2026. 7. 28. 10:59

본문

집 Linux 박스에서 AFL++ 캠페인을 며칠씩 돌리고, 맥북을 덮었다가 다음 날 결과를 확인하고 싶었다. 그런데 SSH로 띄운 퍼저가 연결을 끊은 뒤 자꾸 사라졌다. 처음에는 코어를 많이 써서 OOM이 난 줄 알았고, 실제 원인은 프로세스가 SSH 세션에서 충분히 분리되지 않은 실행 방식이었다.

SSH가 끊겨도 유지되는 AFL++ 장기 퍼징 캠페인 구성 대표 이미지
SSH가 끊겨도 유지되는 AFL++ 장기 퍼징 캠페인 구성 대표 이미지

먼저 OOM인지 세션 종료인지 구분했다

journalctl -k --since today | grep -Ei 'oom|killed process'
dmesg -T | grep -Ei 'oom|out of memory'
ps -o pid,ppid,sid,tty,stat,cmd -p <PID>

OOM killer 기록이 없고 SSH 종료 시점과 프로세스 종료가 맞아떨어지면 세션·터미널 신호를 본다. 단순히 “프로세스가 없다”만 보면 자원 부족과 세션 종료를 구분하기 어렵다.

nohup이 하는 일과 하지 않는 일

GNU Coreutils nohup 공식 문서
GNU Coreutils nohup 공식 문서

nohup은 SIGHUP을 무시하게 하고 표준 출력·에러를 파일로 돌린다. 하지만 프로세스를 완전한 서비스로 만들거나 실패 시 재시작해주지는 않는다. 셸 스크립트 안에서 여러 워커를 띄우고 일부 스트림이 터미널에 남아 있으면 기대와 다른 수명 문제가 생길 수 있다.

nohup ./run-fuzzers.sh >campaign.log 2>&1 </dev/null &
disown

이 조합은 간단한 배치에는 충분하지만, 재부팅 후 자동 복구와 자원 제한까지 필요하면 systemd 쪽이 명확하다.

setsid로 새 세션을 만든다

Linux setsid(1) 매뉴얼
Linux setsid(1) 매뉴얼
setsid -f sh -c '
  exec afl-fuzz -i seeds -o out -M main -- ./target @@
' >main.log 2>&1 </dev/null

setsid -f는 새 세션을 만들기 위해 fork하고 호출 셸의 제어 터미널에서 분리한다. 예전에는 PPID가 1인지 하나만 봤지만, 시스템에 subreaper가 있으면 다른 부모로 재귀속될 수 있다. SIDTTY=?, 실제 프로세스가 SSH 종료 뒤에도 남는지를 같이 확인하는 편이 정확하다.

AFL++ 병렬 워커는 하나의 출력 디렉터리를 공유한다

AFL++ 공식 Parallel Fuzzing 문서
AFL++ 공식 Parallel Fuzzing 문서
afl-fuzz -i seeds -o sync -M main -- ./target @@
afl-fuzz -i seeds -o sync -S s01 -p fast -- ./target @@
afl-fuzz -i seeds -o sync -S s02 -p explore -- ./target @@

마스터와 세컨더리는 같은 -o 동기화 디렉터리를 사용하고 서로 다른 ID를 가져야 한다. 코어 수와 워커 수를 기계적으로 같게 두지는 않았다. 대상 프로그램의 메모리 사용량과 시스템 load, exec/s가 떨어지는 지점을 보고 여유 코어를 남겼다.

돌아왔을 때 5초 안에 상태를 읽게 한다

각 워커의 fuzzer_stats에서 last_update, execs_done, execs_per_sec, saved_crashes를 모으고, 전체 작업이 끝났을 때는 완료 마커를 남겼다.

printf 'ALL_DONE %s
' "$(date -Is)" > status.txt

로컬 SSH 명령이 타임아웃이나 aborted로 끝나도 서버 프로세스는 이미 실행 중일 수 있다. 같은 캠페인을 다시 띄우기 전에 서버의 PID, 로그 증가, 출력 디렉터리를 확인해야 중복 실행을 피할 수 있다.

장기 운영은 systemd가 더 낫다

며칠 이상 반복 실행하거나 재부팅 복구가 필요하면 unit 파일로 옮겼다. Restart=on-failure, WorkingDirectory, 로그 정책, CPU·메모리 제한을 명시할 수 있다. 일회성 실험은 setsid, 운영에 가까운 캠페인은 systemd라는 선을 잡으니 관리가 단순해졌다.

공식 자료

중복 실행을 막는 시작 스크립트

#!/usr/bin/env bash
set -euo pipefail
cd /srv/fuzz/campaign

if [[ -f campaign.pid ]] && kill -0 "$(cat campaign.pid)" 2>/dev/null; then
  echo "already running: $(cat campaign.pid)"
  exit 1
fi

setsid -f sh -c '
  echo $$ > campaign.pid
  exec ./run-workers.sh > logs/campaign.log 2>&1
'

sleep 1
cat campaign.pid
ps -o pid,ppid,sid,tty,stat,cmd -p "$(cat campaign.pid)"

PID 파일만 믿으면 이전 비정상 종료의 낡은 파일 때문에 막힐 수 있어 kill -0로 실제 프로세스 존재를 같이 확인했다. 캠페인 디렉터리와 출력 경로도 절대 경로로 고정해 SSH를 어디서 실행했는지에 따라 결과가 흩어지지 않게 했다.

watcher가 경고한 조건

  • last_update가 일정 시간 이상 갱신되지 않음
  • exec/s가 0 또는 장시간 급락
  • 워크 PID가 사라졌는데 완료 마커가 없음
  • 디스크 여유 공간이 임계값 아래로 내려감
  • 새 크래시는 생겼지만 triage 큐가 처리되지 않음

watcher는 퍼저를 무조건 다시 띄우지 않았다. 디스크 부족이나 잘못된 대상 바이너리 때문에 죽었다면 자동 재시작이 같은 실패를 반복하기 때문이다. 상태를 기록하고 알림을 남긴 뒤 사람이 원인을 확인하도록 했다.

같이 읽기

이 인프라에서 얻은 크래시를 중복 제거하고 공개 제보까지 연결한 과정은 AFL++ 네트워크 파서 퍼징과 GHSA 제보 기록에 정리했다.

관련글 더보기