sprintf漏洞

sprintf函数

函数功能

sprintf是 PHP 中一个格式化字符串的函数,功能是根据指定的格式化字符串,将一系列参数转换并格式化为一个新的字符串

函数语法

1
sprintf(format, arg1, arg2,...)
  • format:是必需参数,规定字符串以及如何格式化其中的变量,如果 format 字符串中不包含任何占位符(如 %s%d 等),那么可以不需要额外的 arg 参数
  • arg1, arg2,...:可选参数,要被插入到format字符串中指定位置的参数,可以有多个
参数 描述
format 必需。规定字符串以及如何格式化其中的变量。可能的格式值:

%% - 返回一个百分号 %

%b - 二进制数

%c - ASCII 值对应的字符

%d - 包含正负号的十进制数(负数、0、正数)

%e - 使用小写的科学计数法(例如 1.2e+2)

%E - 使用大写的科学计数法(例如 1.2E+2)

%u - 不包含正负号的十进制数(大于等于 0)

%f - 浮点数(本地设置)

%F - 浮点数(非本地设置)

%g - 较短的 %e 和 %f

%G - 较短的 %E 和 %f

%o - 八进制数

%s - 字符串

%x - 十六进制数(小写字母)

%X - 十六进制数(大写字母)

附加的格式值。必需放置在 % 和字母之间(例如 %.2f):

+ (在数字前面加上 + 或 - 来定义数字的正负性。默认情况下,只有负数才做标记,正数不做标记)

‘ (规定使用什么作为填充,默认是空格。它必须与宽度指定器一起使用。例如:%’x20s(使用 “x” 作为填充))

- (左调整变量值)

[0-9] (规定变量值的最小宽度)

.[0-9] (规定小数位数或最大字符串长度)

注释:如果使用多个上述的格式值,它们必须按照以上顺序使用
arg1 如果format中有占位符(如 %s%d 等),这个参数是必需的,规定插到 format 字符串中第一个 % 符号处的参数
arg2 可选,规定插到 format 字符串中第二个 % 符号处的参数

常见用法

  • 格式化数字
1
2
3
4
$num = 123.456;
$result = sprintf("%.2f", $num);
echo $result;
// 输出 123.46,将数字格式化为保留两位小数的字符串

image-20250331135554907

  • 拼接字符串和变量
1
2
3
4
5
$name = "John";
$age = 30;
$result = sprintf("My name is %s and I'm %d years old.", $name, $age);
echo $result;
// 输出 My name is John and I'm 30 years old.

image-20250331135700220

  • 格式化日期
1
2
3
4
$timestamp = time();
$result = sprintf("%02d-%02d-%d", date("m", $timestamp), date("d", $timestamp), date("Y", $timestamp));
echo $result;
// 输出当前日期,格式为 mm-dd-yyyy

image-20250331135759133

sprintf函数存在的漏洞

占位符漏洞

原理

格式化字符串函数在处理占位符时,会按照特定规则将传入的参数替换到占位符所在的位置,当用户能够控制格式化字符串(包含占位符)或部分参数时,就可能出现安全问题。

攻击者可以利用特殊构造的格式化字符串来读取内存中的敏感信息、修改内存数据、甚至执行任意代码。

%1$

%1$是格式化字符串中的一种高级占位符用法,在格式化字符串中,%通常是占位符的起始符号。%1$ 中的 1 代表参数列表中的第 1 个参数(编号从 1 开始),$ 是分隔符,它的作用是明确指定要使用的参数序号。借助这种方式,你能够精确控制在格式化字符串中使用哪个参数进行替换,还可以多次使用同一个参数。

1
sprintf(format, arg1, arg2, arg++)

arg1、arg2、++ 参数将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推。

如果 % 符号多于 arg 参数,则您必须使用占位符。占位符位于 % 符号之后,由数字和 “$” 组成

1
2
echo sprintf("a=%2\$s, b=%1\$s", "a", "b");
// 执行结果为:a=b, b=a

%2\$s 表示将第 2 个参数(即 "b" )以字符串类型插入,%1\$s 表示将第 1 个参数(即 "a" )以字符串类型插入。所以最终输出 a=b, b=a 。 其中 \ 是转义字符,用来转义 $ ,防止它被 shell 等环境提前解析 ,确保 sprintf 函数能正确识别占位符。 而 %2$s 里的 2 指明用第 2 个参数,s 表明参数类型为字符串。

sprintf底层实现代码

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
switch (format[inpos]) {
case 's':
{
zend_string * t;
zend_string * str = zval_get_tmp_string(tmp, &t);
php_sprintf_appendstring( & result, &outpos, ZSTR_VAL(str), width, precision, padding, alignment, ZSTR_LEN(str), 0, expprec, 0);
zend_tmp_string_release(t);
break;
}
case 'd':
php_sprintf_appendint( & result, &outpos, zval_get_long(tmp), width, padding, alignment, always_sign);
break;
case 'u':
php_sprintf_appenduint( & result, &outpos, zval_get_long(tmp), width, padding, alignment);
break;
case 'g':
case 'G':
case 'e':
case 'E':
case 'f':
case 'F':
php_sprintf_appenddouble( & result, &outpos, zval_get_double(tmp), width, padding, alignment, precision, adjusting, format[inpos], always_sign);
break;
case 'c':
php_sprintf_appendchar( & result, &outpos, (char) zval_get_long(tmp));
break;
case 'o':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 3, hexchars, expprec);
break;
case 'x':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 4, hexchars, expprec);
break;
case 'X':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 4, HEXCHARS, expprec);
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;
}

通过底层实现代码可以发现,sprintf()方法就是对15种类型做了匹配,15种类型以外的就直接break了没有做任何处理,所以就会导致一个问题:

如果我们输入%\或者%1$\,他会把反斜杠当做格式化字符的类型,然而找不到匹配的项那么%\,%1$\就因为没有经过任何处理而被替换为空。

因此sprintf注入的原理就是用一个15种类型之外的\来代替格式字符类型让函数替换为空,则%1$\'后面的单引号就能闭合前面的单引号。

此外,还要注意**函数addslashes()**作用是返回在预定义字符之前添加反斜杠的字符串。预定义字符是单引号'、双引号"、反斜杠\NULL。比如下面的代码,这样就造成了得结果是我们无法在注入的过程当中使用单引号(’)

image-20250331182802265

题目

题目来源

NSSCTF–git泄露

这是这题的后半部分的考点,源码如下

1
2
3
4
<?php
$pass=sprintf("and pass='%s'",addslashes($_GET['pass']));
$sql=sprintf("select * from user where name='%s' $pass",addslashes($_GET['name']));
?>

审计代码,这段代码通过GET请求的namepass参数,构造一条SQL查询语句,从 user 表中筛选出符合特定条件的记录。

payload如下

1
?name='admin'&pass=123%1$' or 1=1--+

addslashes()将’转义成' 则passpass=123%1$'

最终构造的SQL语句如下

1
select * from user where name='admin' and pass='123' or 1=1--+'

image-20250331184548380

可以看到跳转到wjbh.php这个页面了,虽然表面上看着是Not Found,但这实际是出题人写来混淆的,抓包可以知道状态码是200,查看源码可以看到flag的位置在/flag

image-20250331184805384

但是访问后却发现又回到最初的界面了

image-20250331184936727

那就去抓包看看

image-20250331185243464

发现cookie请求头中有一个参数file,对其值进行 Hex 解码

image-20250331185501796

解出flag.txt,访问又是什么都没有,再继续抓包

image-20250331185545261

image-20250331185805000

没什么区别,看到之前的cookie参数file,想到是不是用来读取flag.txt这个文件的,但是没有读到,要是给它加上伪协议读取呢

image-20250331190020446

还是不行

image-20250331190440729

想到之前的提示在/flag文件中,那就读取这个文件

image-20250331190532826

1
file=2f666c6167

image-20250331190609726

1
ctfshow{31ba3d58-60d3-40f8-9e39-291c554cdf46}

这就出flag了,好难的萌新赛


sprintf漏洞
https://yschen20.github.io/2025/04/25/sprintf漏洞/
作者
Suzen
发布于
2025年4月25日
更新于
2025年4月25日
许可协议