一句话总结
资料查询接口中,时间归一化函数使用了非线程安全的 std::localtime / std::mktime,在高并发场景下引发非确定性取值和跨用户数据泄漏.
问题背景
某即时通讯 (Instant Messaging, IM) 服务的资料卡片接口 GetProfileList 按安全要求,需将用户注册时间字段 (tag 20026) 做归一化处理后再下发:将注册时间归一到"注册年份的 1 月 1 日",同时保留原有的时分秒信息.
例如,某用户注册于 2023-06-15 14:30:00,归一化后下发的值应对应 2023-01-01 14:30:00. 这一处理的目的是模糊化精确注册日期,降低用户隐私暴露风险.
归一化 = 把"2023 年 6 月 15 日下午 2:30"改成"2023 年 1 月 1 日下午 2:30".
年份不变,时分秒不变,但月份和日期被抹平成年初.
负责这一转换的函数名为 modifyAndPrintTime,其核心逻辑大致如下:
|
这个函数于 2025 年 1 月上线,运行约 1.5 年后才被发现存在线程安全问题.
根因分析
std::localtime 的非线程安全性
std::localtime 返回一个指向静态内部缓冲区 (static internal buffer) 的指针. C 标准明确规定:
The return value is a pointer to an internal object whose validity or value may be altered by any subsequent call to
gmtimeorlocaltime.
这意味着所有线程共享同一块内存. 在多线程环境下,当线程 A 调用 localtime 后还没来得及读取结果,线程 B 的 localtime 调用就可能覆写这块缓冲区的内容.
|
两个竞态窗口
在同一次 modifyAndPrintTime 调用中,实际存在两个竞态窗口:
- 读竞态:
localtime返回后,tm_ptr指向的缓冲区可能被其他线程的localtime调用覆盖. - 写竞态:修改
tm_ptr->tm_mon和tm_ptr->tm_mday时,改的也是共享缓冲区;如果此时另一线程恰好调用localtime,会把修改后的值当作"原始解析结果"返回给那个线程.
std::mktime 同样使用静态缓冲区,存在相同的线程安全问题--它可能把其他线程刚修改过的 struct tm 当作自己的输出.
关键误区
很多人以为"返回指针的函数"天然不安全,而"返回值"的函数就安全.
实际上 std::mktime 虽然返回值类型是 time_t(值类型),但它同样依赖内部静态缓冲区做中间计算--多线程调用时,这层间接依赖同样会引发竞态.
症状表现
竞态条件导致的错误具有非确定性和偶发性两个核心特征,使得问题极难被发现和复现.
症状一:同一用户多次返回不同注册年份
同一用户连续多次调用 GetProfileList,返回的注册时间 (20026) 偶尔出现不同的年份值. 在压测环境下,对同一用户反复请求,部分返回中注册年份从 2023 跳变为 2020 或 2025,呈现完全不可预测的跳动模式.
症状二:跨用户数据泄漏
更隐蔽也更严重的是,接口偶发返回另一个用户的注册时间. 线上曾复现过一个典型脏值 1167614079 (Unix 时间戳),该值不属于当前请求用户,而是属于同一批次中另一个用户的注册时间. 在极端情况下,甚至出现"整段泄漏"--整个注册时间字段完全替换为其他用户的值.
跨用户泄漏的本质:线程 A 的 localtime 结果被线程 B 覆盖后,线程 A 读取到了线程 B 的时间结构体,后续每一步操作(置为 1 月 1 日 → mktime → 格式化)都基于错误的值,最终产出了完全属于另一个用户的归一化时间.
数据泄漏的本质
这不是内存安全漏洞(不会导致崩溃或任意代码执行),但它是逻辑层面的信息泄漏--用户 A 可能看到用户 B 的注册年份.
虽然低危,但存在隐私合规风险,需要修复.
影响评估
触发概率
压测数据提供了一个量化的触发概率参考:
- 高并发场景(单请求携带约 150 个用户 ID 批量查询):约 0.4% 的响应中出现错误年份.
- 正常流量(并发度远低于压测):触发概率估计在千分之几以下级别,偶发,非稳定复现.
影响范围
| 维度 | 评估 |
|---|---|
| 核心业务逻辑 | 不受影响,仅展示层注册时间字段异常 |
| 服务稳定性 | 不崩溃,不丢数据 |
| 数据安全 | 存在跨用户注册时间泄漏(年份级别),不涉及更敏感字段 |
| 用户感知 | 偶现注册年份显示错误,不影响功能使用 |
综合评定:低危展示类问题--不影响功能正确性和服务可用性,但存在隐私合规风险,需要修复.
修复方案
修复非常直接:将 modifyAndPrintTime 中对 std::localtime 和 std::mktime 的调用替换为对应的线程安全版本.
方案对比
| 方案 | 线程安全 | 可移植性 | 推荐度 |
|---|---|---|---|
localtime_r(调用方提供缓冲区) |
✅ 安全 | POSIX 标准,Linux/macOS 均支持 | ⭐ 推荐 |
localtime_s(C11 Annex K) |
✅ 安全 | C11 可选附件,Windows 支持好,Linux 部分支持 | 次选 |
加互斥锁保护 localtime 调用区域 |
⚠️ 安全但低效 | 通用 | 不推荐(性能退化) |
推荐实现
|
核心变化只有两处:
- 将
struct tm*改为栈上struct tm:每个线程在自己的栈帧中拥有独立缓冲区. - 用
localtime_r替代localtime:将结果写入调用方提供的缓冲区而非全局静态缓冲区.
为什么 mktime 不需要换?
mktime 的线程安全问题来自它内部使用的静态缓冲区(用于规范化中间结果). 但当传入一个调用方独立分配的 struct tm(栈上的 tm_buf)时,mktime 的读写都发生在这块独立内存上,不存在跨线程竞态.
需要警惕的是两次 mktime 调用之间共享同一个 struct tm 的场景--但本例中只有一次 mktime 调用. localtime_r 已经将缓冲区从全局搬到了栈上,一并解决了这个问题.
测试验证
修复后的验证策略聚焦于黑盒多次验证,不依赖代码内部实现:
稳定性验证
同一用户连续多次调用 GetProfileList 接口,注册时间 (20026) 字段取值应唯一且恒定. 修复前偶发出现多个不同年份的返回值,修复后任何多次请求之间不应有差异.
归一化正确性验证
返回值的时间应为"注册年份的 1 月 1 日 + 原始时分秒",且不得出现其他用户的注册时间. 验证方法:
- 从数据库直接查询用户真实注册时间,手工计算期望的归一化值
- 与接口返回值逐字段比对
- 对批量请求中的每个用户 ID 做交叉校验,确保不存在跨用户串值
经验教训
为什么这个 bug 能存活 1.5 年?
- 低触发概率:正常流量下千分之几以下的偶发错误,几乎不可能被人工发现.
- 非确定性:问题不会稳定复现--同一次请求有时对有时错,自动化测试也很难抓到.
- 影响面窄:只是年份偶尔串成别的值,不影响核心逻辑,不崩溃,缺乏告警信号.
- 认知盲区:
localtime是 C 标准库中最常用的函数之一,很多人默认它是"安全的"--直到第一次遇到并发 bug.
如何从流程上避免?
- Code Review 检查清单:审查涉及时间处理的代码时,
localtime/gmtime/asctime/ctime应作为高危关键词被标记. - 静态分析工具:Clang-Tidy 的
concurrency-mt-unsafe规则可以自动检测非线程安全的 C 标准库调用. - 线程安全测试:对涉及共享状态的函数,应在压测中引入"同一请求重复多次 + 批量高并发"的场景来放大竞态窗口.
- 编译器警告:GCC 和 Clang 在较新版本中会对
localtime等函数产生 deprecated 警告(当定义了_POSIX_C_SOURCE等宏时).
延伸:C 标准库中常见的非线程安全函数
| 函数 | 线程安全替代 | 问题机制 |
|---|---|---|
localtime |
localtime_r |
返回静态缓冲区指针 |
gmtime |
gmtime_r |
同上 |
asctime |
asctime_r |
同上 |
ctime |
ctime_r |
同上 |
strtok |
strtok_r |
内部静态指针保存切分位置 |
rand |
rand_r 或 <random> |
共享全局种子 |
tmpnam |
tmpnam_r 或 mkstemp |
返回静态缓冲区指针 |
经验法则
凡是返回 char* 或 struct tm* 且文档中没有注明 "thread-safe" 的 C 标准库函数,都应默认视为线程不安全,优先查找对应的 _r 后缀版本 (reentrant, 可重入).
快速回顾
- 根因:
std::localtime和std::mktime使用全局静态缓冲区,多线程共享导致竞态条件. - 症状:同一用户多次返回不同注册年份(非确定性);跨用户注册时间泄漏(A 返回 B 的值).
- 触发率:高并发压测约 0.4%,正常流量千分之几以下--低概率,偶发,难复现.
- 影响:低危展示类问题,不涉及核心业务逻辑,但存在隐私合规风险.
- 修复:用
localtime_r替代localtime,栈上分配缓冲区彻底消除竞态. - 防御:Code Review 标记高危函数,启用 Clang-Tidy
concurrency-mt-unsafe规则,压测中引入高并发+重复请求场景.
动手练习
- 查找非线程安全函数:选一门熟悉的语言 (C/C++/Rust/Go/Java),找出标准库中至少 3 个非线程安全的函数及其对应的安全替代方案.
- 编写竞态检测程序:写一个多线程测试程序(至少 10 个线程,每个线程循环 1000 次调用
localtime),对比同一次调用的输入时间戳和返回的年份是否一致. 量化当前环境中竞态的触发概率. - 项目审计:在实际项目中搜索
localtime,gmtime,strtok等非线程安全函数的使用,评估是否存在类似的并发风险.
参考答案
参考思路(练习 2):
|
errors 通常 > 0,证明 localtime 存在竞态.