0x00 前言

时隔近两年,回头看了一下之前写的《MySQL Order By 注入的小技巧》,结尾留了一句:

至于 MySQL 的行为为何如此奇怪,本人并没有了解清楚,有待研究。

最近在 Claude Code 的帮助下终于把这个坑填上了。本文基于 MySQL 8.4 源码(tag mysql-8.4.0)、官方 Bug Tracker,以及 Docker 里跑 mysql:8.4 抓到的 optimizer_trace,把这套行为彻底捋清楚。

结论先抛在前:当年那个「直接写 sleep / GTID_SUBSET 没效果、包成子查询就能跑」并不是优化器的三条独立优化路径叠加的结果:

  1. JOIN::remove_constplan_is_const() 为真时,把整段 ORDER BY 直接砍掉。
  2. 同一函数在逐项扫描时,会把 used_tables() == 0 的常量项静默丢弃,完全不调用 val_int
  3. 子查询里的 derived table(派生表)会在优化阶段被独立物化,与外层 ORDER BY 的命运无关 —— 这才是 POC 写法稳定可用的根本原因。

Oracle 工程师 Roy Lyseng 在 Bug #67632 已经明确表态,这种「能算就提前算」的激进求值是** by design**。

0x01 重新审视现象

用 10 行整数表 rows10 和 MySQL 内置的 dual(行为上等价于一行的常量表)作为两种外层 FROM,跑下面 5 类 ORDER BY 表达式:

-- A. 直接函数调用
ORDER BY SLEEP(N)
ORDER BY GTID_SUBSET(2, 2)

-- B. 裸标量子查询包装
ORDER BY '', (SELECT SLEEP(N))

-- C. 派生表包装(即当年 POC 写法)
ORDER BY '', (SELECT(1) FROM (SELECT(SLEEP(N))         WHERE(1=1)) test)
ORDER BY '', (SELECT(1) FROM (SELECT(GTID_SUBSET(2,2)) WHERE(1=1)) test)

完整的可执行 SQL(包含建表、optimizer_trace 设置和全部 9 个测试查询)见 test.sql。下表测试时 A 类统一用 SLEEP(2);B、C 类的 Ndual 列取 5、rows10 列取 2,让两侧耗时差异都能明显观察。

图例:以「ORDER BY <expr> 会对结果集中每一行求值一次」作为朴素 SQL 预期。 ✅ 表示与预期相符;❌ 表示相符,是本文要解释的现象。

ORDER BY 表达式 FROM dual(1 行 const plan) FROM rows10(10 行真表)
A. 直接 SLEEP(2) 0.075 s(无 sleep;预期 ≈ 2 s) 20.13 s ≈ 10×2 s
A. 直接 GTID_SUBSET(2,2) ❌ 无错误(预期:ERROR 1772) ❌ 无错误(预期:ERROR 1772)
B. 裸子查询 (SELECT SLEEP(N)) 0.087 s(无 sleep;预期 ≈ 5 s) 20.10 s ≈ 10×2 s
C. 派生包装 SLEEP 5.10 s ≈ 1×5 s 2.10 s(仅 sleep 一次;预期 ≈ 10×2 s)
C. 派生包装 GTID_SUBSET ERROR 1772 ERROR 1772

把两列对照着看,能直接读出三条事实:

  • A、B 类dual 时副作用根本没触发,真表时每行触发一次。当年 POC 在 from dual 上看不到 SLEEP 效果,并不代表 SLEEP 永远被砍 —— 是 from dual 这一侧特殊。
  • A 类的 GTID_SUBSET 是例外:两种 FROM 下都不报错,函数从未被求值。
  • C 类(派生表包装):两种 FROM 下副作用都只触发一次。这才是 POC 稳定生效的关键。

也就是说,「子查询包装」并不是任意写法都行,关键在那一层 derived table;而且它把副作用稳定收敛到「优化阶段执行一次」上。对延时注入而言这是个二值预言(slept / didn’t sleep),拿不到「时间随行数线性增长」的信号;报错注入则不受影响,触发一次就够。

0x02 第一条路径:plan_is_const() 整段消除

所有 ORDER BY 消除都集中在 JOIN::remove_const 里。第一条最简单粗暴:

// sql/sql_optimizer.cc:10193-10195
if (plan_is_const())
  return change ? nullptr : first_order;       // No need to sort

当 FROM 子句里所有表都是 const table(空表、单行表、dual,或被索引/WHERE 证明只有 0~1 行)时,整段 ORDER BY 直接被丢弃,里面写了什么、是不是常量、有没有副作用,全都不看

这就是 A、B 类在 dual 上一律不触发副作用的根本原因 —— 根本没进入 sort 阶段,也没进入下面要讲的逐项扫描循环。

0x03 第二条路径:常量项被静默丢弃(兼论 SLEEP 为何幸存)

plan_is_const() 不成立时(FROM 里有真实多行表),remove_const 进入逐项扫描循环

// sql/sql_optimizer.cc:10226-10256(节选)
const table_map not_const_tables = ~const_table_map;

for (ORDER *order = first_order; order; order = order->next) {
  const table_map order_tables = order->item[0]->used_tables();

  if (... aggregate / window function ...) {
    *simple_order = false;
  } else if ((order_tables & not_const_tables) == 0 &&
             evaluate_during_optimization(order->item[0], query_block)) {
    if (order->item[0]->has_subquery()) {
      if (!thd->lex->is_explain()) {
        const Opt_trace_array trace_subselect(trace, "subselect_evaluation");
        String str;
        order->item[0]->val_str(&str);          // ← 这里会真的执行子查询
      }
      order->item[0]->mark_subqueries_optimized_away();
    }
    trace_one_item.add("uses_only_constant_tables", true);
    continue;                                   // ← 把这一项扔掉
  }
  // ...
}

判断只看两点:

  1. 表达式的 used_tables() 是不是「全是 const table」?是 → 进入消除分支。
  2. 进入消除分支后,如果还含子查询(has_subquery()),先把子查询执行一遍再扔。

const_table_map 初值是 INNER_TABLE_BITsql/sql_optimizer.cc:174),后续靠 mark_const_table 累加,不包含 RAND_TABLE_BIT

GTID_SUBSET(2,2) 为何被静默丢弃

Item_func_gtid_subset 没重写任何「常量性」相关方法,基类 Item_func::get_initial_pseudo_tables() 返回 0。两个字面量参数下:

  • used_tables() == 0const_item() == true
  • (0 & not_const_tables) == 0 恒为真
  • 没有 has_subquery(),直接 continue;
  • val_int() 根本没被调用

而错误判断在 Item_func_gtid_subset::val_int 里 —— 字面量 2 解析为 GTID set 必然失败,但既然函数从未被调用,错误也就不会触发。这解释了 A 类 GTID_SUBSET 即使在 rows10 上也不报错。

SLEEP(2) 为何反而被保留

Item_func_sleep 主动声明自己「非确定」:

class Item_func_sleep final : public Item_int_func {
 public:
  table_map get_initial_pseudo_tables() const override {
    return RAND_TABLE_BIT;
  }
  // ...
};

Item_func::update_used_tables 会把这个 bit OR 进 used_tables_cache,所以 SLEEP(2)used_tables() == RAND_TABLE_BITRAND_TABLE_BIT 不在 const_table_map 里,(RAND_TABLE_BIT & not_const_tables) != 0根本不进消除分支。这一项被原封不动保留,filesort 阶段每行调一次 val_int,于是 A 类在 rows10 上跑出 20.13 秒(10 × 2s)。

GTID_SUBSET 没覆盖 get_initial_pseudo_tables、SLEEP 有,一个被消除一个被保留,差别就在这一处。

0x04 第三条路径:派生表物化 —— POC 的真正秘密

理解了 0x02 和 0x03 后,下面这个问题就很尴尬了:C 类派生包装 SLEEP 在 dual也 sleep 了一次 5 秒。但 plan_is_const()dual 返回 true,按 0x02,remove_const 应该直接 return,根本没机会进入 0x03 的 val_str 分支。那 SLEEP 是哪儿来的?

把 C 类的 optimizer_trace 抓出来:里面没有 simplifying_order_by没有 subselect_evaluation(因为 remove_const 早就 return 了),但 creating_tmp_table 和两次 join_execution,对应 (SELECT (SLEEP(5))) test derived table 的物化过程

$ grep -oE '"(simplifying_order_by|subselect_evaluation|creating_tmp_table|join_execution|derived)"' trace.txt | sort | uniq -c
   1 "creating_tmp_table"
   1 "derived"
   2 "join_execution"

也就是说:哪怕外层 ORDER BY 被整段砍掉,内层 derived table 仍然会在优化阶段被独立物化,物化的过程就执行了 SLEEP。这是一条独立于 remove_const 的代码路径,属于 MySQL 「能算的常量子查询就提前算掉」的总策略(Bug #67632 里 Roy Lyseng 的明确表态):

If MySQL can evaluate an expression, also subqueries, it will do that, in order to simplify queries as much as possible and potentially reduce join order calculation as much as possible.

B 类裸子查询 vs C 类 derived 包装:差在哪里

这才是 POC 之所以非得写成 (SELECT(1)FROM(SELECT(SLEEP(...))WHERE(1=1))test) 这一团嵌套的真正原因:

  • B 类裸子查询 (SELECT SLEEP(N)):没有 derived table,没有可独立物化的对象。在 dual 外层下随 plan_is_const() 一起被丢弃。在真表外层下,SLEEP 把所在 query block 标记为 UNCACHEABLE_SIDEEFFECTdo_itemize),让 Item_subselect::update_used_tablesINNER_TABLE_BIT 收敛分支走不到,scalar subquery 不被识别为常量,每行重新求值一次
  • C 类 derived 包装 (SELECT 1 FROM (SELECT SLEEP(N)) test):内层 (SELECT SLEEP(N)) 是 derived table,独立物化一次;外层 SELECT 1 FROM <derived> 看不到 SLEEP 的副作用标记,照常被识别为常量子查询。结果就是两种 FROM 下都只 sleep 一次:
    • dual 外层remove_const 早早 return,derived table 物化照样发生 → 5 秒。
    • rows10 外层:进入 0x03 的逐项循环,命中 val_str 分支,把整个嵌套子查询执行一次 → 2 秒(不是 20 秒)。

GTID_SUBSET 的 derived 包装走同一路径:物化时调用一次 val_int,触发一次 ERROR 1772。

一个安全相关的细节:EXPLAIN 也会触发

实测 EXPLAIN SELECT 1 FROM dual ORDER BY '',(SELECT(1)FROM(SELECT(SLEEP(5))WHERE(1=1))test) 同样卡 5 秒。remove_const 里的 val_str 调用有 if (!thd->lex->is_explain()) 守卫,但 derived table 的物化路径没有这层守卫 —— EXPLAIN 本身就足够把 SLEEP / GTID_SUBSET 触发一次。这也是 Bug #67632 当年报告人最关心的安全语义问题。

0xFF 小结

  • ORDER BY sleep() 没效果」? 仅在 from dual / 单行表场景成立,由 plan_is_const() 整段消除引起;换成多行真表,SLEEP 每行执行一次。
  • ORDER BY GTID_SUBSET(2,2) 不报错」? 永远成立。Item_func_gtid_subset 没声明非确定,常量参数下 used_tables() == 0,被 remove_const 静默丢弃,val_int 从未调用。
  • 「子查询包装能解决」? 真,但秘诀是那一层 derived table 在优化阶段被独立物化,而不是「让函数按行执行」。它把副作用稳定收敛到「优化阶段执行一次」,对报错注入完美适用;对延时注入只能当二值预言。
  • 副作用甚至在 EXPLAIN 阶段就会触发。Oracle 已明确认定为 Not a Bug,未来很长一段时间内都将持续可用。

参考