운영 어드민 화면이 외부 CDN 에서 스크립트와 CSS 를 불러오고 있었다. 차트, 슬라이더, 날짜 라이브러리, jQuery 같은 것들이다. 일부 화면은 앱 안의 WebView 로도 열린다.
전부 integrity 없이 불러오고 있었다. CDN 의 파일이 바뀌거나 도메인 주인이 바뀌면, 그 스크립트는 우리 화면의 권한으로 실행된다. 관리자 세션이 살아 있는 화면에서.
SRI 가 하는 일
<script src="https://cdnjs.cloudflare.com/ajax/libs/<lib>/<version>/<file>.min.js"
integrity="sha384-..." crossorigin="anonymous"></script>
browser ---- GET file.min.js ----> CDN
browser <--- bytes --------------- CDN
sha384(bytes) == integrity ? yes -> execute
no -> block + console error
받은 바이트의 해시가 적어 둔 값과 다르면 실행하지 않는다. 한 바이트만 달라도. crossorigin="anonymous" 도 같이 필요하다. 다른 출처 리소스는 CORS 로 받아야 브라우저가 내용을 검사할 수 있다. 빠지면 해시가 맞아도 막힌다.
대상 고르기
JSP 전체에서 외부 <script> · <link> 를 찾았다. 여러 줄에 걸친 태그도 있어서 한 줄 grep 으로는 빠진다. 태그 96개, 고유 URL 12종이 나왔다. SRI 의 전제는 그 URL 이 가리키는 바이트가 앞으로도 같다는 것이다. 그래서 먼저 걸렀다.
| 종류 | 처리 |
|---|---|
버전이 박힌 URL (.../lib/1.2.3/lib.min.js) |
SRI 적용 |
버전 없는 URL (외부 위젯, latest) |
제외 — 언제든 바뀐다 |
| Google Fonts CSS | 제외 — 브라우저마다 응답이 다르다 |
| IE 조건부 주석 안의 스크립트 | 삭제 (아래) |
최종 적용은 고유 URL 7종, 태그 72개, 파일 35개였다.
해시 계산 — 그리고 교차 확인
파일을 직접 받아 계산했다. openssl 과 Python hashlib 두 가지로 계산해 같은지도 봤다.
curl -sL "https://cdnjs.cloudflare.com/ajax/libs/<lib>/<version>/<file>" -o file.js
openssl dgst -sha384 -binary file.js | openssl base64 -A
# or
shasum -b -a 384 file.js | awk '{print $1}' | xxd -r -p | base64
그런데 이것만으로는 부족하다. 지금 받은 파일이 이미 바뀐 상태라면, 바뀐 파일의 해시를 정답으로 박제하게 된다. 받은 바이트가 “원래 그 파일”인지는 독립된 출처와 대조해야 안다.
cdnjs 는 API 로 파일별 SRI(sha512)를 준다. 받은 바이트의 sha512 가 같으면, 같은 바이트로 계산한 sha384 도 믿을 수 있다.
curl -s "https://api.cdnjs.com/libraries/<lib>/<version>?fields=sri" | jq -r '.sri["<file>"]'
echo "sha512-$(openssl dgst -sha512 -binary file.js | openssl base64 -A)"
jsDelivr 처럼 npm 패키지를 그대로 내주는 CDN 은 레지스트리에 기록된 tarball 무결성과 대조한다. tarball 이 레지스트리 기록과 같고, 그 안의 파일이 CDN 파일과 바이트까지 같으면 된다.
npm view <pkg>@<version> dist.integrity # sha512-...
npm pack <pkg>@<version>
echo "sha512-$(openssl dgst -sha512 -binary <pkg>-<version>.tgz | openssl base64 -A)"
tar -xzf <pkg>-<version>.tgz
cmp package/<path> file.js && echo same
틀린 해시로 정말 막히는지
“넣었으니 됐다”가 아니라 막히는 것까지 봐야 한다. 한 태그의 integrity 마지막 글자를 바꿔 대조군을 만들었다.
Failed to find a valid digest in the 'integrity' attribute for resource
'https://.../file.min.js' with computed SHA-384 integrity '...'.
The resource has been blocked.
정상 해시 7종은 모두 로드되고, 틀린 해시는 차단됐다. 둘 다 봐야 검증이다.
함정: jsDelivr 가 만들어 주는 .min.js
날짜 라이브러리의 한국어 로케일을 이렇게 불러오고 있었다.
<script src="https://cdn.jsdelivr.net/npm/dayjs@<version>/locale/ko.min.js"></script>
교차 확인하려고 npm 패키지를 열었더니 ko.min.js 라는 파일이 없었다. 패키지에는 ko.js 만 있다. jsDelivr 는 패키지에 없는 .min.js 를 요청받으면 그 자리에서 minify 해서 돌려준다. 받은 파일 맨 위에는 jsDelivr 가 붙인 주석이 있었고, 그 아래는 원본과 같았다.
이런 파일은 jsDelivr 가 다시 만들면(minifier 가 바뀌거나 주석 문구가 바뀌면) 바이트가 달라진다. 그날부터 SRI 가 로케일 파일을 막고, 화면의 날짜가 조용히 영어로 나오게 된다. 에러 화면도 없이.
<script src="https://cdn.jsdelivr.net/npm/dayjs@<version>/locale/ko.js"
integrity="sha384-..." crossorigin="anonymous"></script>
패키지 원본 ko.js 로 바꾸고 SRI 를 걸었다. npm tarball 안의 파일과 바이트가 같은 것까지 확인했다. 규칙으로 정리하면, jsDelivr 에서 SRI 를 걸 파일은 패키지에 실제로 있는 파일이어야 한다.
SRI 를 못 거는 것
- 버전 없는 스크립트 — 외부 위젯처럼 제공자가 수시로 갱신하는 URL. 내부에서 다른 스크립트를 또 불러오기도 한다. 해시를 걸면 제공자가 갱신하는 날 화면이 깨진다
- Google Fonts CSS — 같은 URL 이라도 브라우저(User-Agent)마다 다른 CSS 를 준다. 정답 해시가 하나가 아니다
이런 리소스는 SRI 로 다룰 수 없다. 직접 호스팅하거나 CSP 로 불러올 수 있는 출처를 좁히는 식의 다른 수단이 필요하다. 억지로 해시를 걸면 보안이 아니라 장애가 되니, SRI 작업에서는 따로 뺐다.
정리 1: IE 조건부 주석 블록
관리자 템플릿에서 복사해 온 이런 블록이 여러 화면에 있었다.
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
여기에도 해시를 걸려고 파일을 받았더니, 라이브러리가 아니라 Hello World!! 31바이트가 왔다. 그 도메인은 더 이상 이 파일을 내주지 않는다.
- 최신 브라우저는 조건부 주석을 무시하니 애초에 안 받는다
- 이걸 받는 IE 는 SRI 를 지원하지 않는다
- 원본 해시를 확인할 방법도 없다
걸 수도, 걸 의미도 없었다. 블록째 지웠다. 7개 파일, 42줄 삭제, 추가 0줄. 죽은 CDN 도메인은 그 자체로 위험이다. 도메인이 다른 사람 손에 넘어가면 그 <script> 는 새 주인의 코드를 실행한다.
정리 2: 웹 루트에 공개되던 에디터 샘플
외부 리소스를 찾다가 http 로 jQuery 를 불러오는 HTML 이 하나 걸렸다. 우리 화면이 아니라 리치 텍스트 에디터 배포본에 딸려 온 samples/ 폴더였다. 에디터를 통째로 웹 루트에 복사하면서 데모 페이지까지 같이 공개되고 있었다.
- 참조 0건 확인 (README · LICENSE 문구 말고는 없었다)
samples/폴더만 삭제(88파일). 에디터 본체는 공지사항 작성에서 쓰니 그대로 뒀다- 삭제 후 브라우저에서 에디터 초기화 완료, 에디터 관련 요청 15건 모두 200, samples 요청 0건 확인
결과
- 외부 리소스 7종(태그 72개, 파일 35개)에 sha384 SRI
- 해시는 직접 계산하고 레지스트리(cdnjs API, npm tarball)와 교차 확인. 브라우저에서 7종 정상 로드, 틀린 해시 대조군 차단 확인
- diff 는 72줄 추가 / 72줄 삭제. 줄바꿈(CRLF)을 그대로 둬서 속성 추가 말고는 바뀐 게 없다
- 아주 오래된 Android WebView 는 integrity 를 무시한다. 검사를 못 할 뿐 로드는 되니 화면이 깨지지는 않는다
배운 것
- SRI 는 버전 고정 URL 위에서만 성립한다. 바뀔 수 있는 URL 에 해시를 걸면 보안이 아니라 장애가 된다.
- 해시는 “지금 받은 파일”이 아니라 “원래 파일”의 것이어야 한다. 레지스트리 같은 독립된 출처와 대조하자.
- CDN 이 만들어 주는 파일은 원본이 아니다. jsDelivr 자동 minify 파일에는 SRI 를 걸지 말고 패키지 원본을 쓰자.
- 라이브러리 버전을 올릴 땐 integrity 도 같이 바꾸자. 틀리면 그 스크립트에 기대는 화면 기능이 통째로 안 뜬다.
- 외부 리소스를 전수 조사하면 죽은 코드도 같이 나온다. IE 블록과 샘플 폴더는 해시를 걸 대상이 아니라 지울 대상이었다.