AI 혼자 랩을 풀게 뒀다
본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 공인 IP·계정번호·키는 마스킹했다.
부트캠프에서 다단계 침투 랩을 받았다. IP 하나에서 시작해 서버를 넘어가며 다음 주소를 찾는 구성이었다. 푸는 과정은 모의해킹 기록 다섯 편에 적었다.
그 작업의 상당 부분을 AI에게 맡겨서 진행했다. 어디까지 되는지 보려고.
결과부터 말하면 랩은 풀렸다. root 도 잡았고 클라우드까지 갔다. 그런데 푸는 동안 남은 실행 기록을 다시 읽어보니, 틀린 판단이 반복적으로 같은 자리에서 나왔다.
이 글은 그 기록이다. 여기서 나온 것들이 그대로 하네스의 기능 명세가 됐다.
왜 기능부터 설계하지 않았나
“AI 침투 도구” 를 만들려면 보통 이런 걸 넣는다. 취약점 탐지 규칙, 대상별 페이로드 모음, 성공한 명령의 목록.
그걸 안 넣기로 했다. 이유는 단순하다.
정답 명령을 넣어두면 → 그 랩만 풀린다
다른 대상에서는 다시 아무것도 못 한다
랩 하나를 잘 푸는 도구는 랩 하나만 푼다. 그런데 AI가 틀리는 방식은 대상이 바뀌어도 똑같았다. 그렇다면 고칠 것은 지식이 아니라 판단 과정이다.
그래서 먼저 한 일은 기능 설계가 아니라 실패 목록을 만드는 것이었다.
실패 ① — 안 나오는 걸 계속 판다
블라인드 추출이라는 방식이 있다. 서버가 답을 직접 안 주니까, 참/거짓 질문을 반복해서 한 글자씩 알아내는 것이다.
문제는 비용이다. 글자 하나에 질문이 아홉 번쯤 든다. 100글자면 900번이다.
기록에 이렇게 남아 있다.
[*] services 행 수: 3
행[0] database|green|primary metrics store
행[1] grafana|green|public&dashboard service
행[2] metrics_collector|gseen|internal scrape,agent
[*] 총 요청 수: 825
825번 질문해서 얻은 게 서비스 이름 세 개와 설명 문구다. 찾던 건 다음 서버 주소였는데 그건 없었다.
한 번 더 있다.
[*] 실행: find / -iname '*flag*' -o -iname '*secret*' …
[+] 완료 (609 오라클):
/sys/module/secretmem
/usr/lib/bashfdflags
609번 질문해서 얻은 결과가 리눅스 기본 파일 두 개다. 그나마 아래쪽은 글자가 깨져 있다 — /usr/lib/bash 와 fdflags 두 줄이 줄바꿈이 뭉개져 붙어버린 것이다.
그 시간에 뭘 했어야 하나
같은 시각, 확인해야 할 게 따로 있었다. 내부에 열려 있던 다른 포트 하나. 요청 한 번이면 끝날 확인이었다.
그게 뒤로 밀렸다. 비싼 작업이 앞자리를 차지하고 있었기 때문이다.
| 방법 | 비용 |
|---|---|
| HTTP 요청 한 번 (상태 코드·헤더 확인) | 1 |
| 내부 포트 스캔 | 초 단위 |
| 명령 한 번 실행 + 짧은 출력 | 수십 |
| 블라인드 문자열 추출 | 글자당 9 |
1,434번의 질문이 결정적인 것을 아무것도 주지 못했다. 그 사이 값싼 확인들이 대기열에 있었다.
AI는 이 계산을 안 한다. “할 수 있다”와 “할 만하다”를 구분하지 않는다.
실패 ② — 근거 없이 성공을 선언한다
이게 더 위험하다. 시간만 버리는 게 아니라 틀린 사실이 기록에 들어간다.
명령을 실행하고 그 결과를 한 글자씩 읽어오는 과정에서, 읽기가 깨졌는데 깨진 값을 그대로 답으로 보고했다.
| 보고한 값 | 실제 값 |
|---|---|
Xi/Ej | postgres |
sYc-monitWr | svc-monitor |
$2b$04$bPED…&Y.7Kd… | 문자열 길이가 안 맞고, bcrypt에 없는 문자 & 가 섞임 |
두 번째는 눈으로 보면 티가 난다. sYc-monitWr 은 svc-monitor 가 살짝 망가진 것이다. 사람이면 “아 이거 깨졌네” 하고 다시 읽는다.
세 번째는 더 명확하다. bcrypt 해시는 글자 수가 정해져 있고 쓰는 문자도 정해져 있다. & 는 그 안에 없다. 자기 검증이 되는 형식인데 검증을 안 했다.
그런데 진짜 문제는 네 번째다
[*] sanity 1=1:True 1=2:True
[*] 실행: whoami (출력 -> /tmp/.o)
[-] 파일 미생성/권한
첫 줄이 대조 시험이다. 1=1 은 참이니 True 가 맞다. 그런데 1=2 는 거짓인데 True 가 나왔다.
이 시점에서 판정 장치가 고장 났다는 뜻이다. 무엇을 물어도 True 를 준다. 여기서 얻은 답은 전부 무효다.
그런데 그 줄이 찍힌 채로 작업이 계속 진행됐다.
대조 시험을 넣어놓고 결과를 안 봤다. 확인 절차가 있는데 통과 여부를 판단에 반영하지 않으면 없는 것과 같다.
실패 ③ — 원인을 오진하고 그 오진을 문서에 적는다
이 랩의 판정 장치에는 구조적인 특성이 하나 있었다.
로그인 요청을 보냄 → 그 값이 DB에 "가장 최근 로그인"으로 저장됨
정렬 기능을 호출 → 저장된 그 값이 실행되며 참/거짓이 드러남
전역으로 공유되는 자리 하나를 읽는다. 그래서 두 흐름이 겹치면 이렇게 된다.
A: 질문 A 저장
B: 질문 B 저장 ← A를 덮어씀
A: 답 읽기 ← B의 답을 A의 답으로 오독
판정이 간헐적으로 틀렸다. 여기까지는 관찰이 맞았다.
진단이 틀렸다
당시 내린 결론은 “레이스 컨디션이니 요청 간격을 벌리고 여러 번 읽어 다수결로 판정하자” 였다.
그 결론이 작업 문서에 이렇게 박혔다.
“레이스가 심함 → TRUE만 신뢰. 정확한 값은 고반복 다수결 스크립트가 필요하다.”
그런데 간격을 바꿔가며 재본 기록이 남아 있다.
pace=0.3 : 신뢰
pace=0.5 : 신뢰
pace=0.8 : 신뢰
전부 똑같다. 간격은 원인이 아니었다.
진짜 원인
죽인 줄 알았던 백그라운드 프로세스가 계속 요청을 보내고 있었다. 그게 전역 자리를 덮어쓰고 있었던 것이다.
한 번에 하나씩만 돌게 하자 간격 없이도 100% 일관됐다.
두 진단의 차이가 크다.
| 대응 | 결과 | |
|---|---|---|
| 레이스로 진단 | 간격 늘리고 다수결 | 요청 수가 몇 배로 늘고, 확률만 줄어든다 |
| 동시성 통제 실패로 진단 | 한 번에 하나씩 | 요청 수가 줄고 완전히 해결된다 |
틀린 진단이 정확히 반대 방향의 처방을 낳았다. 그리고 그 처방이 문서에 남아, 나중에 읽는 사람에게 그대로 전달됐다.
AI는 현상을 설명하는 이야기가 하나 만들어지면 거기서 멈춘다. “레이스”는 관찰을 잘 설명했다. 그래서 검증 없이 채택됐다.
실패 ④ — 세션이 끊기면 확정된 것까지 사라진다
작업은 하루에 안 끝난다. 그런데 대화가 끊기면 AI는 직전까지 뭘 확정했는지를 잃는다.
전날 저장해둔 페이로드가 다음 날 엉뚱한 시점에 실행되는 일이 있었다. 앞서 말한 “가장 최근 로그인” 자리에 그게 아직 남아 있었기 때문이다.
이건 기억력 문제가 아니라 어디까지가 확정이고 어디부터가 미검증인지가 어디에도 안 적혀 있었기 때문이다.
실패 ⑤~⑧ — 다른 랩에서, 더 많이 맡겼더니
같은 과정에서 랩을 하나 더 받았다. 이번엔 AI에게 훨씬 많이 맡기고, 작업 기록도 직접 쓰게 했다.
그랬더니 이런 문장으로 시작하는 표가 나왔다.
이번 작업에서 내가 다섯 번 틀렸다.
여기서 “나”는 AI다. 최종적으로 일곱 개가 됐고, 앞의 ①~④와 겹치지 않는 것만 추리면 이렇다.
⑤ 없는 것을 지어냈다
작업이 특정 단계에서 멈춰 있었고, 다음 단계를 강제로 지정하니 진행됐다. AI는 여기서 “검증 단계를 건너뛰는 우회에 성공했다” 는 이야기를 만들고, url_validated 라는 상태 이름까지 지어내 보고서에 적었다.
“다시 재현해보라”고 시키니 서버가 답했다.
{"detail": "unknown state 'url_validated'"}
그런 상태는 애초에 없었다.
여기서 배운 것: “우회했다”고 말하려면 정상 경로가 막혀 있다는 걸 먼저 보여야 한다. 그걸 안 하면 서사를 창작하게 된다.
⑥ 서버를 죽였다
다음 서버를 찾겠다고 주소 3,588개를 28개씩 동시에 두드렸다. 요청 하나당 대상 서버가 내부적으로 3번 더 나가는 구조였고, 없는 주소는 시간 초과까지 자리를 붙잡고 있었다.
대상이 4분간 완전히 죽었다.
더 나쁜 건 나중에 밝혀졌다. 그 스캔은 범위조차 검증 안 된 추측 위에서 돌린 것이었다. 서버를 죽여가며 돌린 게 애초에 근거가 없었다.
⑦ 안 보이는 것을 없는 것으로 단정했다
포트를 두드려 응답이 없으면 “거긴 아무것도 없다” — 맞는 것 같다. 아니었다.
응답 없음에는 두 종류가 있다.
호스트는 있고 포트만 닫힘 → "닫혔다"는 거절이 즉시 옴 0.09초
호스트 자체가 없음 → 아무도 답을 안 해 기다림 3.29초
35배 차이. 화면에 찍히는 결과는 똑같은데 걸린 시간이 다르다.
이걸로 다시 재보니 숨어 있던 호스트가 하나 나왔다. 대량 스캔이 놓친 것을 요청 네 번의 시간 측정이 찾아냈다.
안 되는 것에도 해상도가 있다. “응답 없음”을 한 덩어리로 취급하면 그 안의 정보가 전부 사라진다.
⑧ 본 것을 도구로 못 바꿨다
권한 조회 명령이 거부당하면서도, 대상이 실제로 존재하면 에러 메시지 안의 주소를 정식 경로로 늘려서 돌려준다는 특성이 있었다.
실제 있는 역할 → arn:…:role/lab/team-a/lab-team-a-svc-app-role 경로가 붙음
없는 역할 → arn:…:role/zzz-not-real-role 그대로
거부당한 에러가 존재 여부를 알려주는 도구다. 이걸로 이름 600개를 넣어봐서 실제 역할 6개를 정확히 찾아냈다. 그중 둘은 서버 어디에도 안 적혀 있어 이 방법으로만 알 수 있는 것이었다.
문제는 AI가 이 현상을 한참 전에 직접 목격했다는 것이다. 그런데 “덤으로 경로도 알아냈다” 고 한 줄 적고 넘어갔다.
본 것을 도구로 바꾸지 못하면 못 본 것과 같다.
여덟 개의 공통점
정리해보니 세 갈래였다.
| 갈래 | 해당 | 정체 |
|---|---|---|
| 그럴듯한 이야기를 만들고 검증을 건너뜀 | ② ③ ⑤ | 설명이 되면 사실로 취급 |
| 비용을 안 재고 실행 | ① ⑥ | 요청 수와 부하를 계산 안 함 |
| 결과를 덜 읽음 | ② ④ ⑦ ⑧ | 신호가 눈앞에 있는데 못 알아봄 |
어느 것도 지식 부족이 아니다. bcrypt 형식도, 대조 시험의 의미도, 스캔의 부하도 물어보면 다 안다. 아는 것을 그 순간에 적용하지 않는 것이 문제였다.
그리고 여덟 번 다 사람이 지적해서 바로잡혔다
- “다시 재현해봐”
- “서버 포화된 것 같은데”
- “그 단서 거짓말일 수도 있어”
- “서브넷 알아서 뭐 하려고?”
전부 짧은 한 마디다. 대단한 지시가 아니라 “지금 하는 게 맞냐” 는 질문이었다.
진짜 문제는 그 질문을 하기 어려웠다는 것
여기가 핵심이다.
내가 지적할 수 있었던 건 우연히 그 순간 화면을 보고 있었기 때문이다. 응답이 끊긴 걸 봤고, 결론이 이상하다고 느꼈다. 못 보고 지나간 것도 분명 있었을 것이다.
AI가 지금 무엇을 근거로, 어느 경로를, 어떤 방법으로 공략 중인지가 안 보였다. 그래서 방향을 틀어주고 싶어도 무엇을 틀어야 할지 몰랐다.
같은 곳을 계속 파는 것 같은데 확신이 없었다. 아까 실패한 걸 또 하는 것 같은데 그것도 확신이 없었다.
그래서 첫 목표는 성능이 아니라 기록이 됐다.
실패 → 요구사항
여덟 개를 그대로 기능으로 옮겼다.
| 겪은 일 | 필요한 것 |
|---|---|
| ① 1,434번의 질문이 아무것도 못 줌 | 실행 전에 요청 수와 기대 이득을 선언. 싼 확인부터 |
| ② 깨진 값을 답으로 보고 · 대조 시험 무시 | 모든 주장에 원본 요청·응답을 연결. 대조 실패 시 진행 차단 |
| ③ 오진을 문서에 기록 | 실패 원인을 분류해서 남기고, 다음 행동을 원인에서 도출 |
| ④ 세션 경계에서 상태 소실 | 확정된 것만 따로 추려 다시 주입 |
| ⑤ 없는 것을 지어냄 | 근거 없는 주장을 감사로 걸러냄 |
| ⑥ 서버 다운 | 부하가 걸리는 실행은 검문소를 통과해야 나감 |
| ⑦ 음성 결과를 뭉뚱그림 | 결과를 원인별로 나눠 기록 |
| ⑧ 관찰을 도구로 못 바꿈 | 단서를 승격시켜 다음 후보에 반영 |
특히 ③이 크다. “실패했다” 하나로 뭉치면 다음에 어디로 갈지가 안 나온다. 그래서 네 가지로 나눴다.
| 분류 | 뜻 | 다음에 할 일 |
|---|---|---|
| 방어기제 | 제대로 막혀 있었다 | 우회 방법을 따로 계획 |
| 가설오류 | 있는 줄 알았는데 없었다 | 경로를 접고 다른 후보로 |
| 정보부족 | 아직 덜 봤다 | 관찰 단계로 복귀 |
| 방법오류 | 방향은 맞는데 방법이 틀렸다 | 같은 가설, 다른 방법으로 |
방어기제와 가설오류는 정반대 행동을 요구한다. 하나는 계속 파는 것이고 하나는 접는 것이다. 뭉쳐놓으면 이 구분이 사라진다.
먼저 세 번 만들어봤다
바로 지금 형태가 된 건 아니다. 같은 구간을 하루에 세 번 다시 풀면서 뷰어를 고쳐나갔다.
| 판 | 바뀐 것 |
|---|---|
| v1 | 실행 기록을 남기기 시작. 폴더 이름은 사람이 정함 |
| v2 | 이벤트 번호가 곧 폴더 이름이 됨 — 기록과 증적이 자동으로 짝지어짐 |
| v3 | 하네스 파일을 작업물과 분리. 판단 기록을 별도 파일로 신설 |
v2 의 변화가 컸다. 그전에는 “이 증적이 어느 행동에서 나왔더라” 를 사람이 기억해야 했는데, 그게 자동으로 붙었다.
v3 에서 판단 기록을 따로 뺀 것이 지금 구조의 시작이다. “무엇을 했나”와 “왜 그렇게 정했나”는 다른 것이고, 뒤엣것이 안 남으면 나중에 오진을 되짚을 수가 없다.
이 줄기를 다시 세운 것이 지금의 하네스다.
다음 편
여기까지가 무엇을 고쳐야 하는지다.
다음 편에서는 그걸 어떻게 구조로 옮겼는지 — 행동을 세 단계로 승격시켜 기록하는 방식과, 여러 갈래 중 어디를 먼저 팔지 정하는 방법을 적는다.
프로젝트 개요는 레드팀 하네스 만들기에 있다.