nginx가 /shop을 정확히 막고 앱은 leg=='FLAG'를 막지만, nginx의 경로 매칭은 대소문자를 구분하고 Express 라우팅과 앱의 검색은 대소문자를 무시한다. 이 어긋남을 겹쳐, /Shop에 leg=flag를 보내 두 차단을 동시에 통과해 FLAG를 꺼냈다.
간단한 Node.js 상점 API다. /shop에서 단어를 조회하는데, FLAG라는 단어만 두 겹으로 막혀 있다. nginx가 /shop 경로를 통째로 막고, 앱은 leg == 'FLAG'를 막는다. 그런데 두 차단이 대소문자를 다루는 방식이 서로 어긋나 있어서, 케이스만 살짝 바꾸면 둘 다 빠져나간다.
nginx의 location = /path 는 "정확히 이 경로"를 뜻하는 완전 일치 매칭이고, 경로 비교는 대소문자를 구분한다. 그래서 location = /shop은 /shop에만 걸리고 /Shop·/SHOP에는 걸리지 않는다.
Express 라우팅 은 기본값이 대소문자 무시다. case sensitive routing 옵션이 꺼져 있으면 app.post("/shop")이 /Shop, /SHOP 요청도 같은 핸들러로 받는다.
즉 nginx가 대문자 케이스를 "다른 경로"로 보고 통과시키면, Express는 그걸 "같은 경로"로 보고 처리한다. 이 어긋남이 첫 번째 우회다.
🔬 코드 정찰 — 두 겹의 차단
nginx는 /shop과 /shop/을 정확히 막고, 나머지는 앱으로 넘긴다.
nginx.conf — location = /shop, /shop/ 만 deny, 나머지는 proxy_pass
앱은 leg가 정확히 'FLAG'면 403을 주고, 아니면 word.name === leg.toUpperCase()로 단어를 찾는다.
app.js — leg == 'FLAG' 차단과 leg.toUpperCase() 검색
여기서 두 번째 어긋남이 보인다. 차단은 leg == 'FLAG'로 정확한 문자열을 비교하지만, 검색은 leg.toUpperCase()로 대문자화한 값을 비교한다. 그러니 소문자 flag는 차단을 통과하면서도('flag' != 'FLAG') 검색에는 걸린다('flag'.toUpperCase() === 'FLAG').
🐛 삽질 — 정직하게 보내면 두 군데서 막힌다
▶🐛 삽질 — /shop과 leg=FLAG는 각각 다른 계층이 막는다
가장 먼저 POST /shop에 leg=FLAG를 정직하게 보내면 nginx의 location = /shop { deny all; }에 걸려 nginx의 403이 돌아온다(앱의 "Access Denied"가 아니라 nginx HTML 403 페이지다).
GET /shop — nginx가 정확히 /shop을 deny해 403
경로를 /Shop으로 바꾸면 nginx는 통과시키지만, 이번엔 앱의 leg == 'FLAG'에 걸려 앱이 직접 403 Access Denied를 준다. 두 차단이 서로 다른 계층에 있어서, 하나만 우회해선 부족하다. 두 어긋남을 동시에 노려야 한다.
정리하면 경로의 케이스(/Shop)로 nginx를, 값의 케이스(flag)로 앱을 각각 통과시키면 된다.
nginx는 경로를 대소문자 구분해 정확히 막았지만, 그 뒤의 Express는 대소문자를 무시해 같은 경로를 받아줬다. 앞단(프록시/WAF)과 뒷단(앱)의 매칭 규칙이 어긋나면, 앞단이 놓친 변형을 뒷단이 처리해 버린다. 접근제어는 요청을 실제로 처리하는 계층에서, 정규화된 값 기준으로 해야 한다. location = /shop처럼 정확 매칭으로 특정 경로만 막는 방식은 케이스·인코딩·슬래시 변형으로 쉽게 빠져나간다.
블랙리스트 비교와 실제 사용 값의 정규화가 어긋나면 안 된다.
앱은 leg == 'FLAG'(정확 비교)로 막고 leg.toUpperCase()(정규화)로 조회했다. 막을 때와 쓸 때 값을 다르게 다루면, 둘 사이 틈으로 우회가 들어온다. 차단도 조회와 똑같이 leg.toUpperCase() == 'FLAG'로 비교했다면 이 우회는 막혔다. 검증과 사용은 같은 형태의 값으로 해야 한다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…