银行核心系统里最容易被低估的“安全死角”就是文件上传。业务那边拿来一个几十 MB 的交易流水文件要求稳定上传到核心服务器做批量入账开发自然会想到用 WebUploader 做分片上传用 PHP 接收后合并。可真落到“防篡改”这三个字上绝大多数实现只是传完算个 MD5 一比对觉得文件大小一样、MD5 一致就万事大吉。这个做法在普通业务系统里勉强够用在银行核心周边系统里风险很大。这篇文章不教你搭一个简单上传页而是把“交易记录分片上传防篡改”这条链路拆开讲前端每个分片怎么生成独立校验值PHP 端怎么在接收中实时校验合并之后怎么复核整体哈希攻击者伪造分片或中间篡改数据时又该怎么办。顺便把我这几年在银行项目中踩过的坑、调过的参数一并说清楚希望能给正在做同类需求的同行省点时间。1. 为什么在银行场景里“分片上传”必须先讲“防篡改”1.1 分片上传到底解决了什么问题分片上传的出发点从来不是“炫技”而是解决三个实实在在地问题一是单次请求体积过大导致服务端直接拒绝二是网络不稳定时整包重传成本太高三是 PHP 这类服务端语言对内存和超时时间有硬限制。打个比方搬家时你不可能把一整柜子衣服直接塞进电梯必须拆成几个大包分次搬运。文件分片也是这个道理原文件从业务前端拆成 1MB 或 4MB 的若干分片逐个传上去服务端收齐后再拼回完整文件。这个机制在普通系统里已经很成熟WebUploader 正是这类前端组件里用得比较多的一套方案。但银行核心系统的场景有个特殊之处传输的不是随便能补的文件而是交易记录、批量清单、对账流水。这类文件一旦在传输途中被破开、插入、删改哪怕只是几行数据都可能影响资金对账结果。分片上传把文件拆碎了如果不给每个分片也加上独立校验反而让“篡改”变得更不容易发现。1.2 “传上去就完事”为什么在银行场景不成立很多人做上传接口时只关注“能不能把文件存下来”却忽略了一个问题服务端收到的数据凭什么相信它就是客户端发出来那份原始数据在银行核心系统这个链路里数据要经过客户端浏览器、办公网络、负载均衡、应用服务器等多层节点。每一层都可能因为网络故障产生位翻转也可能被别有用心的人截获后修改。更隐蔽的是内部人员手动改了文件内容再重新上传或者攻击者伪造一份哈希一样的分片混进来。普通系统的“传上去就行”逻辑在这种信任模型下根本站不住。所以银行场景下的防篡改至少要覆盖三件事完整性收到的分片拼起来后整体哈希与客户端声明的哈希完全一致。来源可信分片确实来自本次合法上传会话而不是被第三方伪造或重放。过程可审计谁在什么时候传了哪些分片、分片哈希是多少、最终合并后的文件哈希是多少都有日志可查。这也决定了我们在设计方案时不能只依赖 WebUploader 本身的默认能力必须把校验逻辑拆成前置算法和后置算法两层来写前置算法负责在文件进入传输管道前生成多个层级的校验值后置算法负责在服务端接收和合并时逐环节核验。2. WebUploader 前端分片参数设计决定了防篡改的起点2.1 分片参数与 WebUploader 初始化WebUploader 虽是百度开源的老项目官方维护节奏已经慢了不少但胜在稳定、社区资料多很多银行内部系统至今还在用。初始化时最影响分片防篡改效果的参数有三个chunkSize、threads、duplicate。chunkSize建议看业务网络条件来定。银行内网带宽通常不错分片设成 2MB 到 4MB 都行如果用户可能通过外网访问最好控制在 1MB 左右否则单片传输时间太长超时重传的压力会变大。threads是并发上传数很多教程喜欢调成 3 或 5但银行服务端要面对大量网点同时上传我一般建议不要超过 3否则临时分片文件会暴涨PHP 进程也被拖垮。duplicate默认是 false也就是同一文件重复选择会被忽略这个在银行场景里最好保持默认防止用户误操作重复提交。下面是一份比较稳妥的初始化配置示例var uploader WebUploader.create({ swf: /static/Uploader.swf, server: /api/upload/chunk, pick: #picker, accept: { title: 交易文件, extensions: txt,xlsx,csv, mimeTypes: text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv }, chunked: true, chunkSize: 2 * 1024 * 1024, threads: 2, duplicate: false, fileVal: file, formData: { identifier: , file_md5: , chunk_hash: , sign: } });注意formData里的字段在每次分片上传时都会带上。但真正的分片校验值不能在这里写死因为每个分片的哈希都不一样需要在分片即将发出时动态生成并写进请求体。这也是 WebUploader 容易让人踩坑的地方很多人把file_md5理解为分片校验实际它只是整个文件的 MD5服务端拿它做秒传判断可以拿它来验证某个分片就完全不够。2.2 前端怎么给每个分片“盖章”每个分片在发出前都应该独立计算一次哈希。这个哈希要包含当前分片的序号、内容和原始文件的总量信息这样服务端不仅能验证数据完整性还能通过哈希值的差异快速定位是哪个分片出了问题。WebUploader 提供了uploadBeforeSend事件在每个分片上传前触发我们可以在回调里拿到当前分片的序号、文件对象以及分片总大小。基于这些信息用原生crypto.subtle.digest计算 SHA-256 是比较推荐的做法虽然 MD5 计算速度更快但在银行场景里我始终建议用 SHA-256MD5 的碰撞风险在安全要求高的系统里不值得赌。下面是一个动态注入分片哈希的示例思路uploader.on(uploadBeforeSend, function (block, data) { var file block.file; var chunkIndex block.chunk ? block.chunk : 0; var chunkSize 2 * 1024 * 1024; var start chunkIndex * chunkSize; var end Math.min(start chunkSize, file.size); var blob file.source.slice(start, end); return blob.arrayBuffer().then(function (buffer) { return crypto.subtle.digest(SHA-256, buffer); }).then(function (hashBuffer) { var hashArray Array.from(new Uint8Array(hashBuffer)); var hashHex hashArray.map(function (b) { return b.toString(16).padStart(2, 0); }).join(); data.chunk_hash hashHex; data.identifier uploader.options.formData.identifier; data.file_md5 uploader.options.formData.file_md5; // 如果服务端要求 HMAC 签名这里再对 chunk_hash 做一次签名见第 3.3 节 data.sign signChunk(chunkIndex, hashHex); uploader.options.formData data; return data; }); });这里有个细节值得注意file.source.slice(start, end)拿到的是当前分片的二进制内容和在服务端最终保存下来的分片内容是同一份字节流。前端计算出来的chunk_hash会随请求一起发出PHP 端接收后需要立刻对这个分片做同样的 SHA-256 计算如果两者不一致说明分片在传输过程中已被篡改或损坏必须直接丢弃。另外WebUploader 自带的秒传逻辑是基于整个文件 MD5 的这个 MD5 不能当作防篡改依据。秒传只适合“服务端已经明确保存过同一份文件”的场景银行交易文件如果确认必须重新归档我一般会直接关掉秒传或者至少保留服务端二次完整校验不能让客户端说“我有 MD5 相同文件”就放行。3. PHP 服务端接收、合并、校验一条链3.1 接收分片的接口设计与目录隔离PHP 端接收分片时最关键的一点是“不要信任客户端传来的任何标识”。客户端传的identifier、chunk、chunks都只能当作线索不能当作安全依据。更合理的做法是开始上传前先由服务端下发一个不可猜测的上传会话 ID也就是高熵字符串后续所有分片都以它为目录名归档。我对接收接口的设计一般如下public function receiveChunk(Request $request) { $identifier $request-input(identifier); $chunk (int) $request-input(chunk); $chunks (int) $request-input(chunks); $fileHash strtolower($request-input(file_md5)); $chunkHash strtolower($request-input(chunk_hash)); // identifier 必须是服务端下发的形式正则先过滤掉路径穿越等危险字符 if (!preg_match(/^[A-Za-z0-9\-_]{16,64}$/, $identifier)) { return json([code 400, msg invalid identifier]); } if (!isset($_FILES[file]) || $_FILES[file][error] ! UPLOAD_ERR_OK) { return json([code 400, msg upload file error]); } $dir /data/bank_upload/ . $identifier; if (!is_dir($dir)) { mkdir($dir, 0700, true); } $partPath $dir . / . sprintf(%06d, $chunk) . .part; if (!move_uploaded_file($_FILES[file][tmp_name], $partPath)) { return json([code 500, msg save part failed]); } // 实时校验分片哈希 $calcHash hash_file(sha256, $partPath); if (strtolower($chunkHash) ! $calcHash) { unlink($partPath); return json([code 400, msg chunk hash mismatch]); } // 记录当前已收到的分片状态方便合并接口检查 $this-markPartReceived($identifier, $chunk, $chunks, $calcHash); return json([code 0, msg ok]); }这段代码里有几个关键点值得展开。一是目录权限。/data/bank_upload/下的每个会话目录都应该设成 0700并且通过 PHP 进程账号来创建避免其他用户通过浏览器路径直接猜目录。目录名用高熵 identifier 而不是业务流水号能大大降低攻击者遍历目录的可能性。二是分片文件命名。我习惯用sprintf(%06d, $chunk)补零命名方便执行glob()后按字典序就能得到原始顺序合并时不需要再额外解析文件名。但要注意$chunk必须小于$chunks并且在写入之前要做范围判断否则攻击者可以传一个超大编号把磁盘打满。三是实时校验。这里其实消耗不小每收到一个 2MB 分片就做一次 SHA-256CPU 开销大约是几十毫秒级别。在银行核心周边系统里这个代价是值得的。因为一旦写入临时目录后再校验就算发现了篡改恶意分片已经在磁盘上留下了痕迹清理不干净还可能被后续流程误用。3.2 合并分片时的最终完整性校验所有分片收齐之后前端会再发一次合并请求。PHP 端在合并阶段至少要做三件事检查分片数量是否齐全、按顺序合并文件、对合并后的完整文件重新计算 SHA-256 并与客户端声明的file_md5比对。合并的核心代码如下public function merge($identifier) { $dir /data/bank_upload/ . $identifier; $chunks glob($dir . /*.part); // 会话信息里记录了该 identifier 应有的分片总数 $expectChunks $this-getChunkCount($identifier); if (count($chunks) ! $expectChunks) { // 分片未收齐直接拒绝合并不能拼一个残缺文件 return json([code 400, msg chunk count mismatch]); } // glob 默认按文件名排序前面用了补零命名顺序天然正确 $finalPath $dir . /final.bin; $fp fopen($finalPath, wb); foreach ($chunks as $partPath) { $partFp fopen($partPath, rb); stream_copy_to_stream($partFp, $fp); fclose($partFp); } fclose($fp); $fileSha256 hash_file(sha256, $finalPath); $fileMd5 hash_file(md5, $finalPath); if (strtolower($fileSha256) ! strtolower($this-getExpectedFileHash($identifier))) { unlink($finalPath); foreach ($chunks as $partPath) { unlink($partPath); } return json([code 400, msg merged file hash mismatch]); } // 合并成功后再做一次归档这次归档的哈希值要单独记录到审计表 $this-archive($identifier, $finalPath, $fileSha256, $fileMd5); return json([code 0, msg ok]); }这里特别说下为什么要重新计算整个文件的哈希而不是直接信任每个分片校验的结果。因为分片校验只能证明每个分片在“接收那一刻”是对的但无法证明合并逻辑本身正确。如果合并时因为补零命名或排序问题导致顺序错了合并出的文件会和原始文件完全不同而每个分片单独看都是好分片。重新算整体哈希才能把“顺序正确性”也纳入验证范围。合并成功后做归档时我建议把file_sha256同时写入两张表一张是业务表记录这个文件对应的交易批次另一张是审计表只存文件哈希、上传人、上传时间、文件大小。这两张表解耦哪怕业务表数据被误删也能靠审计表追溯原始文件哈希。3.3 HMAC 防伪签名别让攻击者“伪造一致”到目前为止的校验体系有一个隐蔽的漏洞如果攻击者可以同时篡改文件内容和对应哈希值分片校验就形同虚设。攻击者只需要在传输链路中把某个分片替换成自己的恶意数据再手动把请求里的chunk_hash改成恶意数据的 SHA-256服务端依然会校验通过。这就是单纯哈希校验的局限。哈希只能防随机损坏、防误操作防不了有意的、知道校验逻辑的伪造。银行场景下需要给校验值加上“只有合法客户端能生成”的约束。最实用的做法是引入服务端下发的上传凭证客户端在创建上传会话时先从服务端取得一个随机的session_key然后用它来对每个分片做 HMAC-SHA256 签名。服务端收到分片后用自己保存的同一把session_key重新计算签名两次不一致就直接拒绝。PHP 端校验伪签名的代码如下$calcSign hash_hmac(sha256, $chunk . | . $chunkHash . | . $fileHash, $sessionKey); if (!hash_equals($calcSign, $request-input(sign))) { // 签名不一致说明分片内容或哈希被改动过 unlink($partPath); return json([code 403, msg sign mismatch]); }hash_equals是一个容易被忽略但很重要的函数。普通判断字符串相等存在时序侧信道风险攻击者可以通过毫秒级响应时间差异猜测签名。hash_equals内部固定比较时间不会把比较耗时暴露给外部在安全要求高的场景里必须用它。session_key不能放 Cookie也不能存 Redis 里超过上限我一般放在 MySQL 的上传会话表中字段带过期时间默认 30 分钟。整个会话超时后所有未合并分片统一清理签名校验也随之失效。4. 从上传到归档一套可落地的完整流程4.1 上传会话与状态流转前面几节分别讲了前端分片、PHP 接收、合并校验现在把整套流程串起来看一个完整的状态机。我在实际项目中通常会将上传过程划分为四个阶段阶段动作核心校验申请客户端请求上传凭证服务端返回 identifier、session_key、过期时间传输客户端逐个上传分片服务端校验分片哈希、签名、次数合并收齐后触发合并分片数量、顺序、整体哈希归档合并文件写入正式存储区记录全文件哈希到审计表落库留痕申请阶段特别要说一下服务端下发 session_key 之前应该先校验客户端身份和上传权限。这个校验和后续分片校验是两码事前者解决“谁能传”后者解决“传的东西对不对”两者不能互相替代。分片传输阶段最容易出的问题是并发上传导致分片到达顺序不定。WebUploader 的threads参数设成 2意味着客户端会同时发出两个分片请求服务端接收顺序不一定是分片序号顺序。但只要临时文件按分片序号命名合并时按序号排序顺序问题就不会影响最终结果。真正要防的是同一个分片被重复上传覆盖比如网络重试导致 PHP 收到两次编号相同的分片。我一般在记录分片状态时会额外存一个分片哈希第二次收到相同分片时先比对哈希一致则跳过不一致则报错防止后到的恶意分片覆盖已校验的好分片。4.2 Nginx 与 PHP 环境的关键配置分片上传方案跑在 WebUploader 和 PHP 之上但能不能稳定落地很多时候取决于底层环境配置。围绕“分片防篡改”我调过的参数基本是这三组client_max_body_size 8M; client_body_timeout 300s;这组配置在 Nginx 里很关键。很多项目的分片大小设成了 5MB但 Nginx 默认client_max_body_size只有 1MB结果每个分片上传都会返回 413前端还看不出原因。这里的 8M 只是示例建议设置为“分片大小 1MB 余量”。PHP 配置则是这套方案的重中之重upload_max_filesize 6M post_max_size 8M max_execution_time 300 memory_limit 256Mupload_max_filesize必须大于单片大小post_max_size建议比upload_max_filesize大一点因为 POST 数据里除了分片文件本身还有identifier、chunk_hash、sign等字段。max_execution_time设太短会导致大分片接收算哈希时脚本被杀设太长又容易被攻击者长期占用 PHP 进程我通常取 300 秒结合 Nginx 的超时来用。还有一个容易忽略的点PHP 默认 SESSION 有文件锁多个分片并发上传时如果每个分片请求都先开 session后到的请求会被锁阻塞表现就是上传速度极慢或者偶发超时。解决办法是接收分片的接口在读取完 session 后立刻执行session_write_close()主动释放锁让分片接收请求能真正并发处理。5. 常见问题与排查技巧实录5.1 分片上传层“来得莫名其妙的失败”我在实际支持银行项目上线时遇到最多的问题不是哈希校验失败而是分片上传请求没有达到 PHP 就被各种中间层拦了。下面整理成一张速查表都是真实排查过的场景现象常见原因解决方案上传返回 413Nginx client_max_body_size 小于单片大小调大 Nginx 配置至少为单片大小加 1MB收到 null 文件PHP post_max_size 或 upload_max_filesize 不足调整 php.ini确保值大于单片上传中途超时max_execution_time 或 nginx client_body_timeout 太短同时调大两边超时以较小者为准分片合并后文件损坏分片未收齐就触发 mergemerge 前严格比较分片总数与实际数量秒传误跳过新文件WebUploader md5 秒传逻辑命中相同哈希银行场景关闭秒传或服务端必做二次校验其中“收到 null 文件”这个坑尤为隐蔽。PHP 会把超过post_max_size的请求判定为无效此时$_FILES[file]存在但file[size]为 0error码也异常。如果只看isset($_FILES[file])根本发现不了问题。排查时应该检查$_FILES[file][error]是否等于UPLOAD_ERR_OK顺手把$_SERVER[CONTENT_LENGTH]和post_max_size做个对比能省很多时间。5.2 校验链路里的监控点防篡改不只是上线前的代码逻辑还包括运行时的持续监控。我总结下来有三个时间点一定要留日志分片接收时、合并完成后、归档后抽检时。分片接收日志至少包含identifier、分片序号、分片哈希、客户端 IP、用户账号。合并日志则要记录整个文件的哈希、分片总数、合并耗时。归档后抽检可以做成定时任务每天夜里把归档目录里的文件重新算一遍哈希和审计表里的记录比对发现不一致立即告警。这样做能兜住一种特殊风险文件在归档后由于存储介质故障、人为误操作等原因发生了静默损坏如果不做定期抽检等到月底对账时才发现批量入账数据有误代价就很难估量了。日志本身的存储也要防篡改。最简单有效的方式是让 PHP 进程只往日志文件追加写入不提供任何删除或修改接口同时定期将日志同步到独立的日志服务器。银行场景下不建议把日志只写在应用服务器本地一旦服务器被攻破攻击者顺手把日志清掉审计链路就断了。6. 进阶加固银行场景下还能怎么提升可信度6.1 服务端下发上传凭证让校验有“源头”第 3.3 节提到的 HMAC 签名其实已经属于“服务端下发凭证”的范畴。再往上加固还可以把凭证做成一次性、短时效、绑定用户和 IP 的形式。我试过一种更强的做法申请上传凭证时服务端不仅返回session_key还返回一个基于时间戳的nonce。客户端在计算每个分片签名时把nonce、分片序号、分片哈希、用户标识拼接起来做 HMAC。服务端校验时先检查nonce是否在有效时间窗口内过期的一律拒绝。这样即使session_key被泄露攻击者也只能在很短的窗口期内伪造分片大幅降低了被重放攻击的风险。代码上只需要把原来拼接的字符串里加一段时间戳$timeWindow 300; if (abs(time() - $request-input(timestamp)) $timeWindow) { return json([code 401, msg timestamp expired]); } $calcSign hash_hmac(sha256, $request-input(nonce) . | . $chunk . | . $chunkHash, $sessionKey);这样做的核心逻辑是校验值必须在某个时间点和某个上传上下文中才有效而不是一个可以无限重放的静态哈希。6.2 归档、留痕与事后审计最后想说说归档阶段的“可信度”问题。防篡改做到前文这个程度已经能防住绝大多数外部攻击和网络传输损坏了。但银行场景对审计的要求往往更高需要证明“这份文件在某个时间点确实就是这个哈希值”后面有人想赖账都难。比较通用的做法是把合并后的文件哈希、上传人、上传时间、文件大小打包成一个审计摘要再用内部 CA 证书对这个摘要做数字签名然后连同原始文件一起归档。相当于给文件盖了一个“时间戳 签名”的双保险。后续不管文件被谁改动只要重新计算哈希或验签都能立刻发现证书签名失效。如果所在机构内部有更严格的区块链存证体系也可以把审计摘要同步到链上存证。我在实际项目里的体会是技术方案从单纯“计算哈希比对”升级到“基于凭证计算 HMAC 归档签名 定期抽检”听起来复杂其实代码量增加不大但安全模型完全不一样了。后者已经把“能传文件的人”和“传的文件被篡改”这两类风险都纳入了防御范围而不是只防一个单点。前端每个分片独立计算 SHA-256、PHP 接收时实时比对、合并后整体复核、全程 HMAC 签名、日志留痕、定期抽检这几层缺一不可组合起来才配得上“银行核心系统”这几个字。