| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2022-1592 |
| 영향 소프트웨어 | clinical-genomics Scout 4.42 미만 (NVD CPE 기준) |
| CWE | CWE-918 (Server-Side Request Forgery) |
| CVSS v3.1 | 8.2 (High, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N) — NVD 기준 |
| 공개일 | 2022-05-05 (NVD 등록) |
| 수정 커밋 | Scout b0ef15f4737d0c801154c1991b52ff5cab4f5c83, “Protect remote_cors from Server-Side Request Forgery” |
Scout는 임상 유전체 데이터를 다루는 웹 애플리케이션이며, 브라우저의 IGV.js 뷰어가 외부
클라우드에 있는 정렬·변이 트랙 파일을 읽을 수 있게 remote_cors라는 서버 측 프록시를 둔다.
패치 전 이 엔드포인트는 URL 경로에 들어온 문자열을 목적지로 그대로 requests.request()에
전달했다. 즉 브라우저가 CORS 때문에 직접 접근할 수 없다는 제약을 해결하려고 둔 서버의
네트워크 권한을, 로그인 여부나 현재 화면에 허용된 트랙인지 확인하지 않고 인터넷의 누구에게나
빌려준 셈이다. 공격자는 Scout 서버 자신이 접근할 수 있는 내부 HTTP 서비스나 클라우드 메타데이터
주소 등을 향하도록 만들 수 있었고, 응답은 다시 공격자에게 전달됐다. 수정은 단순 URL 형식 검사나
사설 IP 차단이 아니라, 인증된 사용자가 현재 IGV 화면을 만들 때 서버가 세션에 기록한 정확한 트랙
URL만 프록시하도록 바꿨다.
웹 브라우저는 한 웹 페이지가 다른 출처(origin)의 응답을 JavaScript로 읽는 일을 기본적으로
제한한다. 출처는 스킴·호스트·포트의 조합이므로 https://scout.example에서 열어 둔 IGV.js가
https://storage.example.org/sample.bam을 읽으려면, 저장소가 적절한 CORS 헤더를 보내야 한다.
그러나 과학 데이터 저장소가 그 헤더를 주지 않는 경우도 있다.
서버 측 프록시는 이 문제를 우회한다. 브라우저는 같은 출처인 Scout에 요청하고, Scout 서버가 대신 외부 저장소에 HTTP 요청을 보낸 뒤 응답을 돌려준다. 브라우저 관점에서는 Scout와만 통신하므로 CORS 검사를 통과한다. 하지만 이 구조는 브라우저에서 막아 둔 네트워크 경계를 서버가 대신 넘는 구조이기도 하다. 따라서 프록시는 “어느 URL에 요청해도 되는가”를 별도로 결정해야 한다.
[정상: 허용된 공개 트랙]
IGV.js ── GET /remote/cors/https://storage.example/track.vcf ──> Scout
│
└── GET ──> 공개 저장소
<── 데이터
IGV.js <──────────────────────────── Scout가 응답을 중계 ───────────────────
브라우저의 CORS 제약은 해소되지만, Scout의 네트워크 권한은 여전히 Scout가 통제해야 한다.
SSRF는 공격자가 URL, 호스트, 리다이렉트 같은 요청 대상의 일부를 통제해 서버로 하여금 원하지
않는 곳에 요청하게 하는 취약점이다. 서버는 외부 사용자의 브라우저와 다른 네트워크 위치에 있다.
예를 들어 127.0.0.1은 공격자 컴퓨터가 아니라 Scout 서버 자신을 뜻하고, 사설 주소 대역이나
클라우드 환경의 링크-로컬 메타데이터 서비스도 서버 쪽에서만 보일 수 있다. 응답 본문을 프록시가
그대로 되돌려주면 SSRF는 단순 연결 시도보다 훨씬 심각해져 내부 정보의 읽기까지 가능해진다.
중요한 불변식은 URL이 https://처럼 그럴듯한 모양인지가 아니다. 이 기능의 정상 목적은 “아무
원격 자원”이 아니라 현재 권한 있는 사용자가 보고 있는 사례의 IGV 트랙을 전달하는 것이다.
그러므로 안전한 인가 기준은 URL 문자열의 문법이 아니라, 그 URL이 신뢰된 서버 측 흐름에서
이미 선택된 자원인지여야 한다.
Scout의 IGV 화면 생성 경로는 사례 접근 권한을 확인한 뒤 make_igv_tracks()로 표시할 트랙을
만들고, set_session_tracks(display_obj)를 호출한다. 이 함수는 reference·sample·custom·cloud
public 트랙에서 URL을 모아 session["igv_tracks"]에 저장한다. 세션은 요청마다 클라이언트가
임의로 신뢰할 수 있는 데이터가 아니라, 애플리케이션이 로그인 상태 및 사례 권한을 바탕으로 만든
선택 결과로 취급된다. 화면이 닫히면 clear_session_tracks()가 같은 키를 제거한다.
따라서 remote_cors가 요청 URL이 이 목록에 정확히 포함되는지 검사하면, 이 프록시는 범용
HTTP 릴레이가 아니라 짧은 수명의 화면별 capability(허가권)만 수행한다. 로그인만 확인하는 것보다
강한 이유도 여기에 있다. 로그인 사용자가 자기 마음대로 내부 URL을 넣을 수 있다면 SSRF는 그대로
남지만, 서버가 생성한 목록에 없는 URL은 인증된 사용자도 요청할 수 없다.
패치 전 alignviewers/views.py의 라우트는 <path:remote_url>을 받았지만, 그 값의 출처·인증·
허용 목록을 확인하지 않았다. 이어지는 requests.request()는 HTTP 메서드, 헤더, 본문, 쿠키도
현재 요청에서 옮기고 리다이렉트도 따라간다.
@alignviewers_bp.route("/remote/cors/<path:remote_url>", methods=["OPTIONS", "GET"])
def remote_cors(remote_url):
resp = requests.request(
method=request.method,
url=remote_url, # 사용자 입력이 서버의 목적지가 됨
headers={key: value for (key, value) in request.headers if key != "Host"},
data=request.get_data(),
cookies=request.cookies,
allow_redirects=True,
)
return Response(resp.content, resp.status_code, headers)여기서 깨진 불변식은 다음 한 줄로 표현할 수 있다.
패치 전 기대: remote_url ∈ "현재 사례 화면에 표시하도록 서버가 선택한 트랙"
패치 전 실제: remote_url ∈ "HTTP 요청자가 경로에 넣을 수 있는 모든 문자열"
requests는 Scout 프로세스에서 실행되므로 URL 해석과 TCP 연결도 서버의 DNS·라우팅·권한으로
이뤄진다. 응답을 Response(resp.content, ...)으로 되돌리므로 공격 경로는 다음처럼 닫힌다.
[패치 전]
공격자
│ GET /remote/cors/<공격자가 고른 URL>
▼
Flask route: remote_url = 경로 문자열
│ 검사 없음
▼
requests.request(url=remote_url) ── 서버 네트워크에서 요청 ──> 내부/외부 HTTP 자원
│ │
│ resp.content <───────────────────────────┘
▼
Response(resp.content) ──> 공격자
결과: 목적지 선택권과 서버의 네트워크 신뢰 경계가 결합된다.
이 코드가 Host 헤더를 제거한 것은 프록시 요청에서 원래 Host를 넘기지 않기 위한 조치일 뿐,
url=remote_url 자체의 목적지를 제한하지 않는다. 또한 allow_redirects=True 때문에 처음 URL을
목록으로 제한하지 않는 한 리다이렉트도 별도의 우회 표면이 된다. 이 CVE의 핵심은 특정 내부
주소 하나가 아니라, URL 입력을 서버가 발급한 자원 참조로 바꾸지 않은 설계다.
수정 커밋은 check_session_tracks()를 controller에 추가하고, remote_cors()와 기존
remote_static()가 이를 공통으로 호출하게 했다.
def check_session_tracks(resource):
if current_user.is_authenticated is False:
LOG.warning("Unauthenticated user requesting resource via remote_static")
return False
if resource not in session.get("igv_tracks", []):
LOG.warning(f"{resource} not in {session.get('igv_tracks', [])}")
return False
return True
def remote_cors(remote_url):
if controllers.check_session_tracks(remote_url) is False:
return abort(403)
resp = requests.request(..., url=remote_url, ...)테스트도 두 경계를 분명히 고정한다. 로그인했더라도 세션 목록에 없는 http://google.com 요청은
403이어야 하며, 세션에 넣은 공개 클라우드 트랙 URL은 성공해야 한다. 즉 패치의 보안 효과는
“모든 원격 HTTP를 막는다”가 아니라 “정상 UI 흐름이 발급한 원격 트랙만 통과시킨다”는 데 있다.
가장 직접적인 완화는 영향을 받는 Scout를 NVD가 명시한 수정 경계(4.42) 이상으로 올리고, 배포
소스에 upstream 커밋 b0ef15f의 두 조건이 모두 존재하는지 확인하는 것이다. 단순 인증 추가가
아니라 (1) 인증된 사용자, (2) 그 세션의 igv_tracks에 있는 정확한 자원이라는 두 조건이 함께
필요하다. URL을 세션에 넣는 생성 경로도 사례 권한 확인 뒤에만 실행돼야 한다.
[패치 후]
사례 권한 확인 → IGV 화면 생성 → set_session_tracks() → session["igv_tracks"]
│
공격자/사용자 ── GET /remote/cors/<URL> ──────────────┘
│
▼
check_session_tracks(URL)
├─ 미로그인 ───────────────> 403
├─ 세션 허용 목록 밖 ───────> 403
└─ 로그인 + 정확히 일치 ───> requests.request(URL) → 응답 중계
불변식: 서버가 권한 검사 뒤 세션에 기록한 트랙만 서버 네트워크에서 가져올 수 있다.
운영상으로는 프록시를 필요한 HTTP 메서드·헤더·응답 크기로 더 좁히고, DNS 재결합과 리다이렉트 정책도 검토할 수 있다. 다만 이 CVE의 확인된 upstream 수정은 세션 기반 자원 인가이므로, 이 보고서는 공개 diff에 없는 IP 차단·리다이렉트 재검증을 이미 적용된 수정이라고 주장하지 않는다.
NVD 참고 자료에는 huntr.dev의 공개 제보 링크가 Exploit 태그로 등록돼 있으나, 이 보고서는
그 코드를 내려받거나 실행하지 않았다. 분석 근거는 NVD 메타데이터와 upstream 수정 커밋·테스트의
정적 대조이며, 위 요청 흐름은 공개 패치 전후 코드에서 재구성한 것이다.
- NVD CVE-2022-1592 — 영향 범위, CWE, CVSS, 참고 자료
- Scout upstream fix commit
b0ef15f—remote_cors인가 검사, 세션 목록 확장, 회귀 테스트 - Scout fix patch — 패치 전후 정적 diff
- huntr 공개 제보 — NVD가 연결한 원 제보 링크 (내용은 본 보고서에서 독립 검증하지 않음)
- NVD CPE는 4.42 미만을 영향 범위로 적지만, upstream 수정 커밋은 2022-05-05에 작성되었고 커밋의 CHANGELOG 문맥은 4.51 다음 릴리스 구간이다. 공개 자료만으로는 이 태그/버전 표기의 차이를 확정하지 못했다. 운영자는 NVD 버전 경계만 신뢰하지 말고 해당 커밋의 검사 존재 여부를 배포 브랜치에서 확인해야 한다.
- 공개 diff는 URL의 문자열 일치 인가를 보여주지만, 세션 쿠키 구성·URL 정규화·리다이렉트 최종 목적지에 관한 배포별 정책까지는 설명하지 않는다. 이 보고서의 SSRF 경계 설명은 확인된 코드 범위로 한정한다.