403의 함정에서 컨테이너 root까지

시리즈 · CJ Ticket Shop 2편
  1. 01SSRF에서 애플리케이션 관리자까지
  2. 02403의 함정에서 컨테이너 root까지
🛡️ 강사 제공 인가된 실습 환경에서 수행

3일차: 드디어 /admin에 들어갔다

오늘은 3일 차이다. 2일 차에는 거의 뻘짓만 했다.

클로드가 S3 버킷 접미사만 알아내면 다 풀 수 있다고 해서, 생각나는 접미사를 브루트포싱으로 다 넣어봤는데 하나도 나오지 않았다. ops-diagnostics (admin-only) 힌트를 보고 여기에 접근해보려고 /admin/* 아래의 온갖 경로도 시도했지만 전부 제대로 된 응답을 얻지 못했다.

거기다가 이번 모의해킹에 필요한 개념을 거의 몰라서 AWS 구조와 SSRF를 공부하느라 정작 제대로 된 시도를 많이 하지 못했다.

드디어 오늘 나머지 Cloud API 관련 개념까지 배우고 나서야 어느 정도 구조를 파악했다.

서버가 오후 8시부터 12시 정도까지만 열려 있어서 생각보다 시간이 부족하다. 그래서 오늘은 모의해킹을 시작하기 전에 집에서 먼저 시도해볼 것들을 정리했다.

정찰 목록

집에 가서 확인할 것들:

  1. JS 번들 정밀 분석
    • 가능한 엔드포인트 목록을 전부 출력한 뒤 하나씩 다시 정찰
  2. ops/diagnostics 경로 퍼징 다시 하기
  3. ops/diagnostics에 관리자 JWT와 admin-api-key를 함께 넣어 POST 요청 보내보기

0. 정리

결과부터 말하자면 위에 적은 것들은 전부 실패했다. 그리고 내가 갖고있는 정보들은 활용하기엔 뭔가 하나씩 부족했다.

당시 내 머릿속에 들어 있던 정보는 다음과 같았다.

  1. 관리자 권한의 JWT를 만든상태이다.
  2. ops-diagnostics에 접근해야 할 것 같은데 경로를 모른다.
  3. S3 버킷 접근권한은 있지만 경로 접미사에 대한 단서가 없다.
  4. 번들의 API 클라이언트가 대부분 /api/v1 경로를 사용하므로 관리 기능도 /api/v1 아래에 있을 것 같다. 혹은 /admin
  5. 3000번 포트에서는 프런트엔드가 제공되고, 8080번 포트에서는 Spring 백엔드 API가 동작한다.

이 생각들로 했던 것

  1. /api/v1 아래라고 가정
    • /api/v1/ops-diagnostics, /api/v1/ops/diagnostics 등을 시도
    • 전부 SPA fallback이 아닌 실제 Spring JSON 404 응답
  2. /admin 아래라고 가정
    • /admin/ops/diagnostics, /admin/diagnostics 등을 시도
    • 전부 403 응답
  3. 8080번 포트가 외부에서 막혀 있다고 판단
    • /api/v1/proxy/fetch SSRF를 통해 http://127.0.0.1:8080/... 형태로 내부 접근 시도
    • 바깥쪽 프록시 요청은 200이었지만 응답 본문은 비어 있었음

번들 단서 진행 상황

  1. asset-service: 확인 완료
  2. config-backup: 위치 미상
  3. git-mirror: 소스 획득 실패
  4. ops-diagnostics: 경로 미상

이렇게 되다 보니 ops-diagnostics에 관한 내용은 아직 더 진행하기 어렵다고 생각했다. 그래서 다시 S3 버킷 접미사로 관례적으로 많이 쓰이는 문자열을 넣어봤지만 역시 발전이 없었다.

저번에는 SSM Parameter Store에서 얻은 RSA 개인키로 sub=admin, role=ADMIN인 RS256 JWT를 만들어 관리자 토큰으로 사용했다. 이번에는 sub=root인 토큰도 만들어 /api/v1/auth/validate에 보내봤는데, 이것 역시 valid=true가 나왔다.

하지만 여기서 valid=trueJWT의 서명, issuer, 만료 시간과 claim 형식이 검증됐다는 뜻일 뿐이다. sub=root라고 적었다고 해서 실제 리눅스 root 권한을 얻은 것은 아니다. 실제로 이 토큰으로 /admin/diagnostics 등에 요청해도 전부 403이었다.

생각해보면 웃긴 게, 나는 ops-diagnostics의 정확한 접근 경로도 모르고 이것이 무엇인지도 확실히 모르는 상태에서 계속 느닷없이 들어가려고 했다.

시간으로만 따지면 거의 6시간을 날렸다. 이미 뇌가 잘못 비벼진 것 같아서 지금까지 했던 추측을 다 잊고 처음부터 다시 시작하기로 했다.


1. 정찰

가장 먼저 외부에 공개된 포트를 확인하기 위해 포트 스캔을 돌렸다.

확인된 포트는 다음과 같았다.

  • 3000
  • 8080
  • 9090

3000번 포트는 Nginx가 프런트엔드 SPA를 서빙하고 있었다. 실제 백엔드 API는 8080번 포트의 Spring 애플리케이션에서 응답했다.

이전에 8080번 포트가 외부에서 접근 불가능하다고 생각한 이유는 /admin/ops/diagnostics 계열 경로로 요청했을 때 계속 403이 나왔기 때문이다. 그래서 “외부 요청을 막는다면 내부에서 요청하면 되지 않을까?”라고 생각했고, SSRF를 이용해 백엔드가 localhost로 요청을 보내도록 했다.

그랬더니 바깥쪽 /api/v1/proxy/fetch 요청은 200이었지만 응답 본문은 비어 있었다. 나는 처음에 이 200을 내부 요청의 성공 코드로 착각했다.

당시 내가 세운 가설은 이랬다.

외부 요청: 관리자 인증은 넣을 수 있지만 외부 요청이라 차단된다.

내부 요청: 내부 접근 조건은 만족하지만 관리자 인증 정보를 전달할 수 없다.

SSRF 프록시는 GET 요청만 지원했고 임의의 Authorization 헤더나 POST body를 내부 요청에 전달할 수 없었다. 하지만 diagnostics에 들어가려면 관리자 인증과 내부 요청 조건을 동시에 만족해야 한다고 생각했고, 이 딜레마에 빠져 계속 우회 방법만 찾았다.

그런데 포트 스캔 결과를 다시 보니 8080번 포트는 애초에 외부에서 직접 접근할 수 있었다.

궁금증 1: 403은 경로는 존재하지만 권한이 없을때 나오는 응답코드인데, 그렇다면 왜 직접 요청에서 계속 403이 나왔을까?

원인은 내가 처음 요청한 /admin/ops/diagnostics가 올바른 경로가 아니었기 때문이다. 그런데 존재하지 않는 경로인데 왜 404가 아니라 403이 나왔는지가 문제였다.

찾아보니 Spring Security 같은 보안 필터는 실제 경로를 찾기 전에 /admin/**의 권한부터 검사할 수 있다고 한다. 그래서 /admin 아래에 실제로는 없는 경로를 요청해도, 권한 검사에서 먼저 막히면 404가 아니라 403이 나올 수 있었다.

근데 또 이상한점이 있다.

궁금증 2: 토큰을 안넣고 요청을 보냈을땐 그렇다치는데 토큰을 넣고 보냈을때도 403이 나온 이유는 뭘까?

이거때문에 진짜 시간 다썼다. 분명 있는 경로에다가 토큰까지 넣었는데 403이 나온다는게 이해가 안갔는데, HTTP 상태 코드는 “규칙” 이 아니라 서버가 정하는것이라는걸 이제야 알았다. 그래서 서버 개발자나 관리자가 일부러 404 없는경로를 403으로 나오게 할수도있고 원래는 있는경로인데도 관리자용 경로의 존재 여부를 숨기기 위해서 404로 나오게 할수도 있다는거다.

즉, 403 응답이 반환됐다는 사실만으로 해당 하위 경로가 실제로 존재한다고 확정할 수는 없다. 상태 코드는 해당 요청에 대해 서버가 반환한 객관적인 결과이지만, 서버 내부의 실제 상태를 그대로 보여주는 절대적인 근거는 아니다. 따라서 여러 조건에서 발생한 응답을 비교하면서 상대적으로 해석해야 한다.

응답을 분석할 때는 상태 코드뿐만 아니라 응답 본문의 내용과 길이, Content-Type·Server 등의 헤더, 응답 시간, 리다이렉트 여부 등을 함께 확인해야 한다.

서로 다른 응답은 요청이 서로 다른 방식으로 처리됐을 가능성을 보여주는 중요한 단서가 될 수 있다. 하지만, 응답이 같다고 해서 서버 내부의 상태나 처리 과정까지 같다고 단정할 수는 없다. 서버가 보안을 위해 서로 다른 상황의 응답을 의도적으로 동일하게 만들 수도 있기 때문이다.

/api/v1/diagnostics 같은 경로는 Spring의 실제 404 JSON이 돌아와서 “아, 이건 없는 경로구나”라고 바로 알 수 있었다. 그런데 /admin/**는 경로를 확인하기도 전에 보안 필터가 응답해서, 아무 하위 경로나 넣어도 전부 똑같이 403처럼 보였던 것이다.

궁금증 3:그러면 SSRF를 이용한 내부 요청에서 바깥쪽 200과 빈 본문이 나온 이유는 무엇이었을까?

/api/v1/proxy/fetch가 반환한 200은 프록시 엔드포인트 자체의 상태 코드였다. 내부 대상 서버의 상태 코드를 그대로 전달한 것이 아니었다. 내부 응답 본문이 비어 있으면 바깥쪽에서도 빈 본문만 보였다.

이번 실습에서는 같은 경로를 직접 요청했을 때 403과 빈 body가 나왔기 때문에, SSRF의 빈 body도 내부 403에서 온 것이라고 추측할 수 있었다. 다만 빈 body만 보고 무조건 내부 응답이 403이라고 단정하면 안 된다. 이건 여러 응답을 비교하고 나서야 알 수 있었던 내용이다.

나는 /admin/** 아래에 존재하지 않는 여러 경로를 요청하면서 모두 403이 나오는 것을 보고, 하위 경로가 실제로 존재한다고 착각했다. 그 결과 스스로 “8080번 포트의 admin 경로는 외부에서 접근할 수 없다”는 잘못된 결론을 내리고 6시간을 버렸다.

<요청 요약>

요청관리자 토큰내가 본 응답왜 이렇게 나왔나
외부X403토큰이 없으니 /admin/** 보안 필터에서 먼저 막혔다. 이때는 403만 보고 경로가 실제로 있다고 착각했다.
외부O403처음에는 외부 요청이라 막힌 줄 알았다. 그런데 나중에 보니 그냥 내가 요청한 diagnostics 경로가 틀렸던 것이었다. /admin/** 아래라 없는 경로도 403처럼 보여서 더 헷갈렸다.
내부X200이지만 빈 응답겉에 보인 200은 내부 서버의 응답 코드가 아니라 /api/v1/proxy/fetch가 돌려준 코드였다. 실제 내부 요청은 토큰이 없어서 막혔고, 빈 body(403)이 실제 응답이다.
내부O시도 불가능이 SSRF는 GET만 가능했고 내부 요청에 Authorization 헤더를 넣을 수 없었다. 토큰은 body가 아니라 헤더에 넣어야 해서 이 방식으로는 관리자 인증을 같이 보낼 수 없었다.

2. 진입

다시 포트 스캔 결과로 돌아와 8080번 포트에 외부에서 직접 접근할 수 있다는 것을 확인했다. 기존에 시도한 경로가 잘못됐을 가능성을 생각하고, 가장 상위 경로인 /admin으로 요청을 보냈다. /admin 아래에 경로가 있다는걸 알면서 정작 /admin 으로는 요청을 안보냈다는것도 정말 큰 잘못이였다.

GET /admin HTTP/1.1
Host: <TARGET_IP>:8080
Authorization: Bearer <ADMIN_JWT>
Accept: application/json

드디어 정상적으로 응답을 받았다.

/admin 응답에는 사용할 수 있는 관리자 패널 경로가 들어 있었다.

  • /admin/dashboard
  • /admin/users
  • /admin/orders
  • /admin/system-config

서버 종료까지 시간이 얼마 남지 않아, 그중 시스템 설정을 확인할 수 있는 /admin/system-config로 요청을 보냈다.

설정 응답에는 여러 정보가 들어 있었다.

여기에는 AWS와 S3 버킷 관련 정보도 있었고, 무엇보다 내가 그토록 찾던 diagnostics의 실제 entrypoint가 표시되어 있었다.

ops-diagnostics는 URL 경로가 아니라 관리자 기능을 설명하는 모듈 이름이었다.


3. Command Injection

/admin/system-config의 응답에는 다음과 같은 diagnostics 설정이 들어 있었다.

{
  "diagnostics": {
    "method": "POST",
    "entrypoint": "/admin/system/diagnostic",
    "requestSchema": {
      "command": "<string>"
    },
    "examples": [
      "uptime",
      "df -h",
      "id"
    ],
    "capabilities": "system inspection, container introspection, network connectivity",
    "status": "enabled (admin-only)"
  }
}

파라미터 이름부터 command이고 예시도 전부 셸 명령이었다. 누가 봐도 서버에서 진단용 명령어를 실행하는 기능처럼 보였다. 정해진 명령만 실행되는 게 아니라 입력한 문자열이 그대로 셸로 들어간다면 Command Injection으로 이어질 수 있을 것 같았다.

먼저 현재 명령 실행 권한을 확인하기 위해 id를 실행했다.

결과에는 uid=0(root)가 표시됐다.

끝!

인 줄 알고 멘토님께 맞는지 여쭤봤다.

흠.

하긴 Command Injection을 제대로 확인하기도 전에 최종 root를 얻었다고 결론 내리는 건 이상하긴 했다.

여기서 확인한 uid=0(root)Docker 컨테이너 내부의 root 권한이었다. 최종 목표가 EC2 호스트의 root라면 컨테이너 root와 호스트 root는 구분해야 한다. 아직 호스트 권한을 획득한 것은 아니다.

다음에는 진단 기능에서 정해진 명령만 실행할 수 있는지, 아니면 내가 넣은 값이 그대로 셸로 들어가는지부터 확인해야 한다. 그다음에는 컨테이너에 호스트 파일이 마운트되어 있는지, Docker 소켓 같은 게 열려 있는지 찾아보면서 탈출할 수 있는지 봐야 할 것 같다.

일단 이번에 한 것은 여기까지다. 오늘 배운점이 많다. http 상태 코드를 어떻게 해석해야하는지에 대한 방향성도 있지만, 지금까지의 웹 모의해킹은 단서가 확실하거나 공격체인이 눈에 보여서 그 단서만을 이용해서 이어나갔는데, 이번엔 너무 단순하게 생각하다가 기본에 충실하지 못했다. 포트번호 탐색은 당연히 해야하고 경로탐색도 상위 폴더부터 하나하나 해봐야하는데, 단서에 써있는것만 보고 너무 쉽고 단순하게 생각했던것같다. 실무에서는 절대 이런 단서들이 있지 않을것이다. 빨리풀려고 하지말고 당연하다고 생각되는것부터 꼼꼼히 정찰해야겠다는 생각이 들었다.

내일은 Command Injection을 더 확인하고 DB와 S3 버킷도 살펴봐야 한다. 아직 컨테이너 탈출 방법은 잘 모르지만, 호스트 파일시스템 마운트나 과도한 capability 같은 설정 오류를 찾는 방식으로 진행해야할 것 같다.

그래도 거의 이틀 동안 진전이 없었는데 막판에 방 하나를 탈출한 것 같아서 도파민이 나온다. 휴.

원본 · velog.io