티스토리 뷰

PCI DSS ASV 스캔이나 웹 취약점 진단을 받으면 거의 빠지지 않고 등장하는 항목이 있습니다.

 

 

Web Application Potentially Vulnerable to Clickjacking

 

이름만 보면 심각해 보이는데, 막상 조치는 응답 헤더 한 줄이면 끝납니다. 그런데 실무에서는 이 한 줄을 넣기까지 소명서를 두세 번 주고받으며 시간을 다 쓰는 경우가 많습니다.

 

이 글에서는 왜 오탐 소명이 반려되는지, 기존에 적용해 둔 통제로는 왜 대체가 안 되는지, 그리고 어떻게 한 번에 닫을 수 있는지를 정리했습니다.


🎯 취약점 내용

스캐너가 출력하는 설명은 대략 이렇습니다.

원격 웹 서버가 모든 콘텐츠 응답에 X-Frame-Options 또는 Content-Security-Policy의 frame-ancestors 헤더를 설정하지 않고 있다. 이로 인해 공격자가 사용자를 속여 사용자가 인식하는 것과 다른 영역을 클릭하도록 유도하는 클릭재킹 또는 UI 리드레스 공격에 노출될 수 있다.

Output 예시

The following pages do not use a clickjacking mitigation response header
and contain a clickable event :

  - https://<대상호스트>/example/Login.action

즉 "프레이밍을 제한하는 헤더가 없고, 그 페이지에 클릭 가능한 요소가 있다" 는 두 가지 조건이 동시에 잡힌 겁니다.


🧩 클릭재킹이 뭔가요?

공격자가 자기 페이지 안에 우리 서비스 페이지를 투명한 iframe으로 겹쳐 놓는 공격입니다.

동작 순서는 이렇습니다.

  1. 공격자가 그럴듯한 미끼 페이지를 만든다 (경품 이벤트, 무료 쿠폰 등)
  2. 그 위에 우리 서비스의 로그인·결제 페이지를 opacity: 0인 iframe으로 덮는다
  3. 미끼 버튼과 실제 버튼의 위치를 정확히 겹쳐 놓는다
  4. 사용자는 "경품 받기"를 눌렀다고 생각하지만, 실제로는 위쪽 iframe의 버튼이 클릭된다

핵심은 사용자 눈에 보이는 것과 실제 클릭 대상이 분리된다는 점입니다. 사용자가 이미 로그인된 세션을 가지고 있다면, 본인도 모르는 사이에 인증이나 거래 승인이 이뤄질 수 있습니다.


⚠️ 영향도: 로그인 페이지라면 더 심각합니다

"우리는 그냥 로그인 화면인데 뭐 어때"라고 넘기기 쉽지만, 오히려 로그인 페이지가 가장 위험한 대상입니다.

시나리오 설명
자격증명 탈취 실제 로그인 폼을 투명하게 겹쳐, 사용자가 공격자 화면에 입력한다고 믿는 상태에서 실제 계정정보를 입력하게 유도
의도치 않은 승인 사용자가 인지하지 못한 상태로 로그인 및 후속 트랜잭션 승인 클릭 발생
피싱 결합 정상 도메인의 실제 화면이 렌더링되므로 URL 확인·인증서 확인 같은 사용자 측 방어가 무력화됨
컴플라이언스 ASV 스캔 미통과 항목. 미조치 시 분기 스캔 Pass 판정 불가

 

특히 스캐너 설명에는 "페이지가 보안상 민감한 트랜잭션을 수행하지 않으면 오탐일 수 있다" 는 문구가 있는데, 로그인은 민감 트랜잭션의 대표 사례입니다. 이 조항으로 소명하려다 반려당하는 케이스가 많으니 주의하세요.


🚫 이건 오탐 소명이 안 됩니다

실무에서 자주 제출되는 소명 사유들입니다. 결론부터 말하면 **전부 반려됩니다.

 

 

① "통신을 암호화하고 있습니다"

TLS는 전송 구간의 기밀성과 무결성을 보호합니다. 브라우저가 페이지를 iframe에 넣는 동작과는 아무 관계가 없습니다. HTTPS로 서비스해도 iframe 삽입은 그대로 가능합니다.

② "WAF로 탐지하고 24×365 관제 중입니다"

클릭재킹은 피해자의 정상 브라우저가 정상 세션으로 보내는 정상 요청입니다. 서버 입장에서 이상 징후로 식별할 근거 자체가 없습니다. 그리고 PCI DSS 관점에서 탐지(Detective) 통제는 예방(Preventive) 통제를 대체하지 못합니다.

③ "Referer 헤더로 화이트리스트 검증 중입니다"

이게 가장 위험한 착각입니다. 공격자는 iframe에 속성 한 줄만 추가하면 됩니다.

<iframe src="https://victim.example/login" referrerpolicy="no-referrer"></iframe>

또는 페이지 상단에:

<meta name="referrer" content="no-referrer">

이러면 브라우저가 **Referer 헤더를 아예 보내지 않습니다. 그럼 서버 로직이 어떻게 되나요?

  • Referer 없을 때 통과시킨다 → 우회 완료, 공격 그대로 성립
  • Referer 없을 때 차단한다 → 북마크·주소창 직접 입력하는 정상 사용자가 막힘

대부분 전자로 구현되어 있습니다. 즉 **실제로 뚫립니다. ASV가 형식적으로 트집 잡는 게 아니라, 정말 방어가 안 되는 상태입니다.


✅ 해결 방법

먼저 딱 하나만 확인하면 됩니다.

이 페이지가 외부 사이트에서 iframe으로 호출되는 대상인가?

 

 

CASE A — 외부 호출 없음 (대부분 여기)

로그인 화면, 일반 서비스 페이지라면 전면 차단하면 됩니다.

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none';

CASE B — 같은 도메인 내에서만 임베딩

X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self';

CASE C — 외부 파트너사가 iframe으로 호출

여기가 함정입니다. X-Frame-Options에는 **특정 외부 출처만 허용하는 기능이 없습니다. ALLOW-FROM 지시자가 있긴 했지만 폐기되어 최신 브라우저에서 동작하지 않습니다.

이 경우에는 해당 URL에 X-Frame-Options를 설정하지 않고 CSP만 적용합니다.

Content-Security-Policy: frame-ancestors https://partner-a.example.com https://partner-b.example.com;

OWASP 치트시트에서도 frame-ancestors는 일반적인 CSP 문법으로 여러 도메인을 한 번에 허용할 수 있고, 특별한 임베딩 요구사항이 없다면 'none'을 기본값으로 쓰라고 권고합니다.

허용 도메인이 수십·수백 개라면? 정적으로 나열할 필요 없습니다. 기존 화이트리스트 조회 로직을 그대로 재사용해서, 요청마다 해당 출처 하나만 헤더에 실어 보내면 됩니다.

// Servlet Filter 예시
String host = extractOrigin(req.getHeader("Referer"));  // 스킴+호스트+포트만 추출
String ancestors = whitelist.contains(host) ? host : "'none'";
res.setHeader("Content-Security-Policy", "frame-ancestors " + ancestors + ";");

여기서 Referer는 "허용 여부를 판단하는 근거"가 아니라 "이미 검증된 목록 중 어느 값을 헤더에 넣을지 고르는 힌트" 로만 쓰입니다. 목록에 없으면 무조건 'none'이므로 Referer를 위조해도 통과하지 못합니다.


⚙️ 서버별 설정

Nginx

add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none';" always;

always 옵션이 중요합니다. 이걸 빼면 4xx·5xx 응답에는 헤더가 붙지 않아 재스캔에서 또 걸릴 수 있습니다.

Apache

Header always set X-Frame-Options "DENY"
Header always set Content-Security-Policy "frame-ancestors 'none';"

IIS (web.config)

<system.webServer>
  <httpProtocol>
    <customHeaders>
      <add name="X-Frame-Options" value="DENY" />
      <add name="Content-Security-Policy" value="frame-ancestors 'none';" />
    </customHeaders>
  </httpProtocol>
</system.webServer>

Tomcat (web.xml)

내장 HttpHeaderSecurityFilter를 사용하면 됩니다.

<filter>
  <filter-name>httpHeaderSecurity</filter-name>
  <filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
  <init-param>
    <param-name>antiClickJackingOption</param-name>
    <param-value>DENY</param-value>
  </init-param>
</filter>
<filter-mapping>
  <filter-name>httpHeaderSecurity</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

💡 앞단에 WAF나 L7 스위치가 있다면 거기서 헤더를 주입하는 방법도 있습니다. 애플리케이션 수정 없이 처리할 수 있어 배포 리스크가 가장 낮습니다.


🔍 조치 후 검증 — 여기서 많이 놓칩니다

ASV 스캔은 보통 도메인이 아니라 IP 주소로 직접 접근합니다. 그런데 헤더를 도메인 기반 vhost에만 설정하면, IP로 접근했을 때 default 서버 블록으로 빠져서 **헤더가 안 붙습니다.

조치했다고 회신했는데 재스캔에서 또 걸리는 사유 1위가 이겁니다. 반드시 IP 기준으로 확인하세요.

curl -I -k https://<대상IP>/example/Login.action

체크리스트

확인 항목  
응답 헤더에 X-Frame-Options 존재 ☐
응답 헤더에 Content-Security-Policy: frame-ancestors 존재 ☐
IP 직접 접근 시에도 동일하게 헤더 반환 ☐
리다이렉트(3xx) · 오류 페이지(4xx/5xx)에도 헤더 포함 ☐
로그인 등 서비스 정상 동작 확인 ☐
외부 iframe 연동이 있다면 파트너 화면 정상 표시 확인 ☐

💬 자주 나오는 질문

Q. 헤더 적용하면 사용자 접속이 막히지 않나요?

아닙니다. 이건 접근 제어 헤더가 아니라 **프레이밍 제어 헤더입니다.

동작 헤더 적용 후
주소창에 직접 입력해서 접속 ✅ 정상
북마크로 접속 ✅ 정상
외부 링크 클릭해서 이동 ✅ 정상
검색엔진 노출 ✅ 정상
다른 사이트가 iframe으로 삽입 ❌ 차단

바뀌는 건 마지막 한 줄뿐입니다. 누구나 자유롭게 접속하는 공개 페이지라도 적용에 아무 문제가 없습니다.

Q. 두 헤더를 다 넣어야 하나요?

스캔 통과 기준으로는 하나만 있어도 됩니다. 취약점 설명에도 "X-Frame-Options **또는 CSP"로 명시되어 있습니다.

다만 frame-ancestors는 meta 태그로는 적용할 수 없고 반드시 응답 헤더로 내려야 하며, 일부 브라우저는 frame-ancestors가 있어도 X-Frame-Options를 함께 참조하므로 두 헤더를 병행하는 것이 호환성 측면에서 유리합니다.

사내 규정상 CSP 적용이 부담스럽다면, frame-ancestors는 페이지 내부 리소스 로딩(스크립트·스타일·이미지)에 전혀 관여하지 않는 지시자라는 점을 근거로 예외 검토를 받아보시길 권합니다. CSP 사고가 자주 나는 건 script-src, default-src 쪽이지 frame-ancestors가 아닙니다.

Q. 프레임 버스팅 JavaScript로 대체할 수 있나요?

OWASP는 프레임 버스터를 세 가지 방어 수단 중 하나로 언급하지만, 이들은 서로 독립적이며 가능하면 여러 개를 함께 적용해 다층 방어를 구성하라고 권고합니다. 프레임 버스팅은 iframe의 sandbox 속성으로 우회가 가능해서, 단독 통제로는 ASV가 잘 인정하지 않습니다.

Q. 카드데이터가 없는 서버인데도 스캔 대상인가요?

PCI DSS 외부 취약점 스캔(요구사항 11.3.2)의 대상은 "CHD를 담고 있는 서버"가 아니라 CDE에 속하거나, CDE에 연결되거나, CDE 보안에 영향을 줄 수 있는 모든 외부 노출 시스템입니다. 세그멘테이션이 안 되어 있거나 결제 흐름의 일부라면 카드데이터를 직접 다루지 않아도 범위에 들어갑니다.

그리고 스캔 범위는 ASV가 아니라 고객사가 제출한 IP 목록으로 정해집니다. 스캔에 잡혔다는 건 우리 쪽에서 범위에 포함시켜 제출했다는 뜻이니, 스코프 자체가 잘못됐다면 오탐 소명이 아니라 **스코프 조정(out-of-scope) 요청으로 트랙을 바꿔야 합니다.


📮 ASV 회신 문안 예시

조치 완료 후 이렇게 보내면 됩니다.

Thank you for the follow-up.

We have applied the following response headers to the affected URL
and verified that they are correctly returned:

    X-Frame-Options: DENY
    Content-Security-Policy: frame-ancestors 'none';

The headers were verified when the host is accessed directly by IP
address, matching the conditions of the scan. Response headers
captured from the affected URL are attached as evidence.

We withdraw our previous false positive request and kindly ask you
to re-scan the affected host for verification.

앞서 오탐 소명을 제출한 이력이 있다면 **철회 문장을 반드시 넣으세요. 안 그러면 소명 건과 조치 건이 별도로 열린 채로 처리되어 일정이 꼬입니다.

외부 파트너 허용이 필요한 CASE C라면 마지막 문단을 이렇게 바꿉니다.

X-Frame-Options is not set on these specific URLs because the header
does not support an origin allowlist (the ALLOW-FROM directive is
obsolete and unsupported in current browsers). Per the CSP Level 2
specification, user agents that support frame-ancestors ignore
X-Frame-Options, so frame-ancestors is the applicable control here.

🎉 정리

  1. 클릭재킹은 브라우저에서 일어나는 공격이다. 서버 측 통제(TLS, WAF, Referer 검증)로는 막을 수 없다.
  2. **로그인 페이지는 오탐 소명 대상이 아니다. 민감 트랜잭션에 해당한다.
  3. 외부 iframe 호출 여부만 확인하면 조치 방법이 정해진다. 대부분은 DENY / 'none'으로 끝.
  4. 외부 파트너 허용이 필요하면 CSP frame-ancestors 로 간다. XFO에는 그 기능이 없다.
  5. 검증은 반드시 IP 직접 접근 기준으로. 도메인 vhost에만 넣으면 재스캔에서 또 걸린다.

소명서 쓰는 데 드는 시간보다 헤더 한 줄 넣는 게 훨씬 빠릅니다. 그리고 스코프에서 빠지든 안 빠지든, 클릭재킹 방어 자체는 해두는 게 맞습니다.


🔗 참고 자료