PHP弱类型与科学计数法:从CTF题目到真实业务漏洞
看到[鹤城杯 2021]AreYouRich 这道题的名字时我第一反应是出题人挺会整活。大部分 Web 题都开门见山让你找 flag这道题倒好先问一句“你有钱吗”。打开容器地址页面果然简单到极致居中一行字显示当前余额 0下方一个输入框和一个 Add Money 按钮再也没有多余提示。如果你是第一次接触这类题目可能以为要无限点击或者找个隐藏按钮但仔细玩下来会发现这其实是一道非常典型的 PHP 弱类型签名题页面允许你往余额里加钱但每次加的金额有数量和长度双重限制。真正巧妙的地方在于后端校验长度用的是 strlen校验大小用的却是 PHP 的宽松比较于是科学计数法这类“看着短、数值大”的字符串就成了出题人留给你的后门。这篇文章不打算只丢一个 payload 就结束。我会把从信息收集、源码还原、原理分析到最终拿 flag 的完整过程都写出来顺便聊聊同款漏洞在真实业务里的危害和修法以及 CTF 里其他常见的“变富”套路。适合刚入门 Web 方向、想搞懂 PHP 弱类型到底是怎么回事的选手有经验的老手也可以跳着看防御部分。1. 打开页面后的信息收集先别急着加钱题目页面一打开就是一张极简的“银行”界面没有 logo没有跳转链接核心信息只有三样当前余额显示为 0一个表单输入框一个 Add Money 按钮。我的习惯是先把页面完整看一遍再看 HTML 源码最后才抓包。因为很多信息收集的突破口就藏在看似无用的地方。先试正常操作。我在输入框里填 100点击 Add Money页面刷新后余额变成了 100。这个结果说明两件事后端用 session 保存了状态而且金额确实是累加进去的。再填 200刷新后变成 300说明只要不断提交钱就能一直涨。然后开始试探边界。输入9999999999个9页面没有显示余额变化直接弹出一行提示Are you rich? No, you are a hacker!。输入-100同样被拦。输入0.5也被拦。这说明后端对金额做了过滤不能小于等于 0不能是负数可能也不是普通小数。我先列一下最初几轮试探的记录输入内容页面表现推测100余额变 100正整数可以累加200余额变 300session 状态保持999999999提示 hacker存在最大值限制-100提示 hacker负数被拦截0.5提示 hacker小数可能被拦截或者长度/格式限制到这里还看不出关键。我打开浏览器开发者工具切到 Network 面板重新提交一次 100观察 POST 请求。请求体非常简单就是一行money100没有任何 user_id、account、session_id 之类的附加字段。这说明后端就是靠 Cookie 里的会话 ID 区分用户的。正常功能摸清之后接下来是常规路径探测。很多题目喜欢把源码备份留在常见位置比如index.php.bak、www.zip、flag.php、admin.php。我分别访问了一下结果在index.php.bak上拿到了文件下载。这才是整道题真正的入口。顺带一提信息收集阶段最容易犯的错是“拿到页面就开始暴力点按钮”。其实一个页面给你暴露的信息远不止表面那些HTML 注释、JS 文件、响应头、备份文件都可能是出题人埋好的线索。这道题如果不去试.bak后面就只能靠盲猜逻辑效率差很多。2. 源码泄露的 index.php.bak 里藏着全部答案把index.php.bak下载下来内容是一段不长的 PHP 代码。我做了简单格式化核心逻辑如下?php session_start(); if (!isset($_SESSION[balance])) { $_SESSION[balance] 0; } if (isset($_POST[money])) { $money $_POST[money]; if ($money 0 strlen($money) 3) { $_SESSION[balance] $money; } else { die(Are you rich? No, you are a hacker!); } } if ($_SESSION[balance] 1000000000) { echo $flag; } else { echo Your balance: . $_SESSION[balance]; } ?这段代码逻辑非常直白三道关卡一目了然参数必须大于 0字符串长度必须小于等于 3最后余额要达到 10 亿才能拿到 flag。前两条是充值接口的过滤条件第三条是最终的目标判断。先看第一道过滤$money 0。注意这里的$money来自$_POST[money]本质上是字符串。字符串和整数用比较PHP 会先尝试把字符串转成数字。问题就在这里一个看起来不像“正常数字”的字符串转成数字后可能大得离谱。再看第二道过滤strlen($money) 3。strlen数的是字符个数不是数值大小。于是我们的目标变成了找到一个字符串长度不超过 3 个字符但数值上比 10 亿还大而且还要大于 0。看到这里我想大多数读者已经反应过来了——科学计数法。9e9这个字符串只有 3 个字符但在 PHP 里它会被解析成 9 * 10^9也就是 90 亿。这个数满足 0满足strlen 3而且累加之后余额直接超过 10 亿。关键点在于设计者用strlen限制金额是拿“文本长度”去代替“数值大小”这在逻辑上就是错位的。字符串短不代表数值小一个9e9就足以击穿整道防线。这种出题方式在 CTF 里很常见属于典型的“源码泄露 弱类型比较”组合。拿到源码之后几乎不用猜漏洞就写在脸上。真正值得研究的反而是第三部分要讲的原理为什么 PHP 会允许9e9参与数值比较以及这套规则在不同 PHP 版本下有什么差异。3. 核心原理strlen 和 PHP 数值比较的“长度错觉”要理解这个漏洞得先明白 PHP 中字符串和数字比较的底层规则。PHP 是一种弱类型语言$money 0这一步系统并不知道$money到底该当成字符串还是数字它只能根据运算符和上下文做一次隐式转换。当字符串与整数比较大小的时候PHP 会优先尝试把字符串解析为数字。解析规则是如果字符串开头是合法的数字内容就取前面的数字部分转成整数或浮点数如果没有数字开头通常转成 0。9e9为什么特殊因为它在数字解析里属于科学计数法形式。9e9在数学上就是 9 乘以 10 的 9 次方PHP 的解析器认识这个写法于是9e9被直接转换成浮点数 9000000000.0。你再看strlen(9e9)它只有三个字符9、e、9。长度限制过了。大小比较更不用提90 亿远远大于 0。这里最反直觉的地方是人类看strlen会下意识觉得“3 个字符最多就是 999”但 PHP 看的是字符串被解析后的数值。出题人正是利用了这种认知错觉把“文本长度”和“数值大小”两个维度混在一起做过滤。再看累加那一步$_SESSION[balance] $money;$_SESSION[balance]初始是整数 0$money是字符串9e9。加法运算会把两边的操作数都转成数值于是0 9000000000.0结果就是浮点数 9000000000.0。再往后$_SESSION[balance] 1000000000自然成立flag 分支执行。还有一点需要说明PHP 8 之后对字符串和数字比较的规则做了调整非数字字符串和数字比较时会把数字转成字符串做字符串比较。但9e9属于数字字符串它和数字比较时仍然会被转成数值所以这个漏洞在 PHP 7 和 PHP 8 下都可以稳定复现。类似的字符串还有很多1e9是 10 亿正好压线1e10是 100 亿但长度变成 4 个字符过不了这道题的strlen限制9e8是 9 亿不到 10 亿也不行。所以在这个特定场景下9e9几乎是完美的选择数值够大长度刚好。打个比方这就相当于你把一张大额支票折成三折塞进一个小信封里。邮局只看信封尺寸觉得这封信小得很根本没想到里面装的东西价值九十亿。strlen就是邮局9e9就是那张被折起来的支票。4. 实际验证一条命令从穷光蛋变成十亿富翁原理清楚了实际打起来就很快。我直接用 curl 模拟表单提交payload 就是money9e9curl -v -X POST http://target/ -d money9e9响应里能明显看到行为变化。第一次提交后返回的页面里余额显示为Your balance: 9000000000说明累加成功。再刷新一次 GET 请求后端判断余额已经大于等于 10 亿直接进入 flag 分支返回了flag{...}。用浏览器操作也可以在输入框里填9e9点 Add Money效果一样。需要注意9e9中间的e是英文字母不是乘号。有些输入法会自作主张把e自动纠正成别的字符提交前记得检查一下。验证完主 payload我又顺手试了几个变体把边界情况也摸了一遍输入内容字符长度数值含义结果9e9390 亿成功1e9310 亿成功1e104100 亿被拦截9e9490 亿被拦截9E9390 亿成功大写 E 同样被识别这里面有两个细节值得注意。第一大写E也能被 PHP 解析成科学计数法所以9E9一样可以打通。第二加上正号之后长度变 4直接过不了strlen这也说明这道题的过滤就卡在字符数量上跟符号无关。还有一个有趣的“笨办法”既然每次加 100 都合法理论上写脚本循环一万次加 100 也能凑够 10 亿。实际上这种解法更费时而且部分平台会限制请求频率不如一个9e9来得干净。不过这个思路本身说明出题人并没有禁止所有合法充值路径只是把效率提得极低真正的后门还是留给懂弱类型的人。拿到 flag 之后不要急着关容器。把题目环境保留下来再试试其他 payload能帮你更好地理解 PHP 在不同上下文里的转换行为。这一步对加深印象非常有帮助。5. 别笑这个漏洞在真实业务里真的会要命很多只打 CTF 的选手会觉得弱类型比较这种漏洞现实里谁还会犯说实话现实业务里这种问题不仅存在而且造成的损失往往比 CTF 题目夸张得多。举几个真实场景电商平台的优惠券金额接口用了strlen($_POST[amount]) 5来“限制金额”结果9e99直接被当成超大数值写入订单积分商城的签到接口用$points 0判断是否合法增加积分传1e6直接加一百万甚至一些内部管理系统的余额调整功能也存在类似的字符串转数字问题导致小数点、科学计数法、特殊进制文本混入金额计算。问题根源往往不是开发者不懂弱类型而是前后端之间缺少统一的类型约束。前端传上来的是字符串后端接口拿到后不主动转换直接放进比较运算和加减法里PHP 的隐式转换规则就会被利用。这方面可以给出一个相对稳妥的修复方案。首先金额字段应该用纯整数或正则严格约束?php session_start(); if (!isset($_SESSION[balance])) { $_SESSION[balance] 0; } if (isset($_POST[money])) { $money $_POST[money]; // 只允许非负纯数字字符串 if (!preg_match(/^\d$/, $money)) { die(Invalid money format!); } $money (int)$money; // 额外限制数值范围 if ($money 0 || $money 100) { die(Are you rich? No, you are a hacker!); } $_SESSION[balance] $money; } if ($_SESSION[balance] 1000000000) { echo $flag; } else { echo Your balance: . $_SESSION[balance]; } ?改动核心就两条用preg_match(/^\d$/)先掐死非纯数字格式再用(int)显式转成整数。这样9e9在第一关就会被拦掉因为e不属于\d。显式转换之后strlen不再成为校验依据数值范围由真正的整数比较来把关。更深一层的防御是金额计算不要用浮点数。电商系统里最常见的做法是用“分”作为最小单位所有余额、价格、积分都存成整数而不是用带小数的元。浮点数存在精度问题哪怕没有弱类型漏洞0.1 加 0.2 不等于 0.3 这种误差也会在账务系统里累积出大问题。真实业务里还有一个容易被忽略的点即便是强类型语言也会遇到类似的字符串解析问题。JavaScript 的parseFloat(9e9)同样返回 90 亿Python 的float(9e9)也一样。所以这类问题不是 PHP 专属任何“用户输入字符串 宽松解析”的组合都要小心。6. 从 AreYouRich 延伸开CTF 里那些“一夜暴富”的套路这类“让你变富”的题目在 CTF 里其实是一个大家族核心目标都是让某个数值达到阈值但解法五花八门。了解这些套路以后遇到类似题目就能快速定位考点。第一种是整数溢出。有些题目把余额存在整数变量里比如 32 位整数上限 2147483647。如果你能把余额加到超过上限它可能会溢出变成负数但某些场景下溢出又会被模运算转回一个极大的正数。常见 payload 是传2147483647然后再加 1或者直接传一个负数让逻辑判断误以为余额仍然合法。这类题的关键是弄清目标语言里整数类型的内存长度和溢出行为。第二种是浮点精度问题。比如每次充值 0.1充十次理应是 1.0但浮点数累加会有误差。反过来某些判断条件是 1.0利用误差可能提前触发。更极端的情况是传1e309在 PHP 里会被解析成 INF无穷大无穷大和任何整数比较都成立。如果题目长度限制放宽到 5 个字符1e309就是比9e9更暴力的选择。第三种是类型混淆。这里说的不只是字符串和数字还有数组、对象、布尔值之间相互比较的怪异行为。PHP 里[]和0比较、空字符串和 false 比较、0e123456这类 MD5 碰撞字符串都是经典考点。这类题更考验对语言底层规则的理解而不只是背 payload。第四种是逻辑越权。有的“变富”题根本不碰类型漏洞而是接口设计缺陷。比如请求参数里带着user_id你可以改成管理员 ID或者购买 flag 时把数量改成负数金额变成负数反而让余额增加再比如兑换码接口没有加幂等同一个码并发请求几百次积分唰唰涨。这种属于业务逻辑漏洞和编程语言的坑没关系。还有一类很常见的思路是竞态条件。如果一个充值接口先读取余额再写入新余额两步之间没有加锁多个请求同时打进来就可能重复累加。这在大厂业务里也叫“并发刷单”漏洞CTF 里偶尔会结合异步任务来考。个人建议是每次做完一道取巧的“变富”题都顺手整理一下它的考点属于哪一类是弱类型、溢出、精度、越权还是竞态整理多了你会发现大多数题都是老套路换了一层新皮。就像这道 AreYouRich表面看是个银行充值页面内核却是 PHP 科学计数法与 strlen 的错位校验。最后说一点做题体会遇到金额、余额、数量这类输入点时不要只试常规数字。多试试科学计数法、十六进制、正负号、数组键名、超长数字、浮点边界值这些“奇形怪状”的输入往往比正经参数更容易撞开大门。CTF 里没有多余的设计页面越简单坑往往埋得越深。