在线OJ判题系统全流程测试:功能、性能与安全实战指南
在线OJ判题系统测试这事我做过不止一次但每次重新碰都还是觉得有点棘手。它跟普通Web项目不一样普通系统测完增删改查就能拍胸脯说功能没问题但OJ系统里跑的是用户提交的不可信代码一旦判题沙箱有漏洞服务器被人当肉鸡都是有可能的。这次我接到的任务是对一套自研在线OJ判题系统做全流程测试覆盖功能、性能、安全三条主线最后输出一份能指导上线决策的测试报告。这篇文章就是把我在这次测试里怎么设计方案、怎么压测、怎么挖漏洞、最后怎么把问题闭环讲清楚给准备做同类系统测试的同学一个可以直接照抄的参考。全文核心围绕三个关键词展开在线OJ、判题系统、测试报告。我会先从测试准备阶段的风险分析讲起再逐步拆解功能测试用例设计、压力测试瓶颈定位、安全渗透专项最后落到测试报告编写和上线放行标准上。每个环节都会给出我实际用过且验证有效的方案包括踩过的坑和修正过程。1. 测试准备先把OJ系统的风险面盘清楚1.1 在线OJ系统为什么不能按普通CRUD项目测聊测试之前得先说清楚OJ系统到底特殊在哪。常规业务系统是用户请求→业务逻辑→数据库读写→返回结果测试重点在数据正确性和权限控制上。但OJ系统的核心链路是用户提交代码→服务端接收→编译→沙箱内运行→比对输出→返回判题结果多了一个极其关键、也极其危险的环节执行用户提交的任意代码。这意味着系统最少有两个完全不同的风险域必须分开测第一个是普通Web应用风险注册登录、题目浏览、提交记录、排行榜、后台管理这些跟任何CMS、电商后台一样要按照常规Web测试用例库去跑。第二个是判题沙箱风险代码编译是否安全、运行环境是否隔离、超时和内存限制是否强制生效、能否读写宿主文件、能否网络外联、能否执行系统调用这些是OJ系统独有的也是最容易出大事故的地方。我在测试前就把这两条主线拆开分别建了测试矩阵防止混在一起测导致漏项。普通Web功能漏一个按钮没点顶多是功能缺陷沙箱隔离没测透上线后可能被恶意用户直接打穿服务器性质完全不同。1.2 测试环境与数据准备的几个关键决策OJ系统测试不能随便拿生产环境或开发环境凑合有些关键决策得提前定。第一判题执行环境必须与生产保持一致。如果生产用Docker容器隔离测试环境也必须用同版本的Docker和基础镜像否则容器里有限制和镜像里有漏洞这两个维度没法分开评估。我这次测试环境用的是跟生产一致的Dockerfile构建的镜像唯一差别是判题节点数量少一些但每个节点的CPU、内存配额和cgroup限制完全一致。第二数据量得接近真实生产。OJ系统的性能瓶颈往往不在业务表数据量而在提交记录表和判题队列的规模。我造数的时候按5万用户、10万道提交记录、3000道题目来准备了MySQL数据同时压测过程中持续写入提交记录模拟长时间运行后的数据膨胀。第三需要一个独立的判题沙箱节点。因为后面要做沙箱逃逸测试和资源耗尽测试这些操作很可能把判题节点搞挂如果跟功能测试共用环境会互相干扰。我申请了两台独立的测试服务器一台专门跑恶意代码安全测试随便折腾坏了直接重置容器。1.3 测试范围与优先级矩阵测试范围不能拍脑袋我把整个系统拆成了六个模块按风险等级排了优先级模块测试重点风险等级用户认证与权限注册、登录、JWT续期、越权访问高题目与提交题目CRUD、代码提交、状态查询高判题核心编译、沙箱执行、结果判定极高排名与统计排行榜、通过率、用户统计中管理后台用户管理、题目管理、公告高基础设施判题队列、消息通知、日志中排优先级的原则很简单越接近执行用户代码这个环节风险等级越高测试资源越往这边倾斜。我实际投入的时间比例大概是判题核心40%Web功能30%性能压测20%安全渗透10%。别觉得安全渗透占比低因为判题核心的沙箱测试本身就包含了大量安全测试内容两边是重叠的。2. 功能测试判题链路是全系统最脆弱的环节2.1 判题核心流程的用例设计思路功能测试阶段我花最多精力在设计判题链路的用例上。判题不是简单的提交→返回结果完整流程是用户提交代码后端校验代码长度和格式代码进入判题队列Redis队列或数据库队列判题节点取出任务创建隔离执行环境把用户代码写入源文件执行编译Java、C/C、Python等不同语言的编译命令不同针对每组测试数据运行程序设置时限Time Limit和内存限制Memory Limit捕获程序输出与标准输出比对按优先级返回判定结果编译错误、超时、内存超限、运行错误、答案错误、答案正确针对这个流程我设计了三类用例正常功能类每种受支持语言分别提交一份标准解法代码确认能正确编译、运行、判定正确/错误。这部分看起来简单但容易出问题的是不同语言的编译命令差异比如Java需要处理类名与文件名一致的问题Python需要确认解释器版本Go需要处理模块初始化。我实测发现系统对Java的Main类命名做了硬编码一旦用户类名不叫Main编译直接报错这个问题在上线前必须改成自动提取public类名或者提示用户类名规范两种方式之一。判定优先级类OJ判题有个隐含规则——当程序同时存在超时和输出错误时一般优先报Time Limit Exceeded当同时存在运行时报错和超时时优先报Runtime Error。这个优先级在不同OJ上不完全一致但必须在代码里写死否则用户提交同一个代码会得到不稳定结果。我专门构造了死循环但输出部分错误除零前先输出错误这类用例验证系统的判定优先级是否符合预定义规则。重复提交类同一用户对同一道题重复提交完全相同的代码结果必须一致连续快速提交时不能出现前一条还没判完后一条就覆盖掉前一条状态的情况。这个我后面在并发测试里还会专门压功能阶段先做单用户的连续提交验证。2.2 边界条件与特殊提交那些让判题机翻车的输入边界条件测试是功能测试里最容易发现深度Bug的部分。我整理了一批OJ系统特有的边界输入个个都是踩过坑才总结出来的空代码提交用户不写任何代码直接提交后端必须给出编译错误或参数校验提示不能直接给System Error。实测发现系统对空代码会走到编译环节然后因为源文件为空报编译错误这勉强算通过但错误提示信息不友好我提了个Low级优化建议。超长代码提交提交的源代码超过系统限制一般限制64KB系统要返回代码长度超限而不是让判题机去硬编译。我测试时发现超过限制的代码会走到正常编译流程编译进程吃满了单核CPU好几秒这就是一个可以通过前端参数校验就避免的资源浪费点。超长输出程序运行时生成了远超预期的输出比如无限打印如果系统没有输出大小限制判题机内存会被打爆。正确的做法是边执行边统计输出字节数超过阈值通常是64MB直接杀掉进程判为Output Limit Exceeded或Runtime Error。这个用例我用一个while(true) print的Python脚本验证系统能正确杀进程并返回结果。读入大量数据的程序判题数据里如果包含超大输入文件而程序没有高效读取会表现为TLE这不算系统Bug但测试时要确认判题机不会因为生成超大输入文件自身出问题。带特殊字符的代码代码里包含中文注释、Unicode字符、反引号、$符号等要确认写入源文件和编译执行时不会引发Shell注入。这是一个Web安全与判题功能交界点后面安全测试部分详细讲。提交不存在的语言用户伪造请求提交一个后端不存在的语言标识系统要能兜住给出清晰错误而不是判题机尝试执行未知命令。2.3 并发提交与结果一致性校验单用户测完我用脚本模拟了多用户同时提交同一道题、同时提交不同题、同一用户并发提交多个不同代码三种场景。这个测试的重点是结果一致性每个提交记录必须对应唯一的判题结果不能串号、不能丢状态。测试方法是用Python脚本并发发HTTP请求记录每次提交返回的submissionId再轮询结果接口把每个submissionId对应的代码和判题结果做一一对比。我实测发现的第一个问题是当20个用户同一秒提交同一道题时判题队列偶尔出现重复消费同一个submissionId被两个判题节点同时取走导致状态被更新两次。最终结果虽然一致但判题节点的日志里出现了同一任务执行两遍的记录浪费了一半的执行资源。排查后发现是Redis队列的POP操作没有配合原子性去重修复方案是消费前用SETNX加个分布式锁或者改成原子的LPOP 任务ID去重表。这个阶段还发现一个排行榜一致性问题当提交记录表写入和排行榜统计不是同一个数据库事务时会出现用户提交通过后排行榜计数滞后甚至漏计。功能测试里我用高并发提交实时刷新排行榜的方式复现了这个Bug后来改成了提交状态更新后通过消息队列异步刷新排行榜缓存。3. 性能压力测试从500并发到3000并发的瓶颈定位3.1 压测方案工具选型与场景模型设计功能测试跑完第一轮我紧接着进性能测试。压测工具我选的是JMeter InfluxDB Grafana的组合JMeter负责施压InfluxDB存结果Grafana实时看聚合指标。这台组合的优势是结果可追溯而且可以自己写脚本定制断言。后面对判题节点做单点压测时我用wrk作为补充因为它更适合高并发的短连接压测能更精确地测出单机QPS上限。压测场景我建了三个场景A浏览型负载。模拟用户浏览题目列表、查看题目详情、查看排行榜。特点是读多写少主要打Web服务器和MySQL读库。场景B提交型负载。模拟用户提交代码并轮询判题结果。特点是写入多、判题队列持续积压、判题节点满载。这是OJ系统最核心的压力场景。场景C混合负载。70%浏览 30%提交贴近真实使用。用这个场景确定系统支撑能力和容量上限。压测指标重点盯四个QPS/TPS、响应时间P95/P99、错误率、服务器资源水位。判题类接口的响应时间有一个特殊性用户提交后轮询判题结果提交接口本身是快速返回的真正的耗时在判题结果上所以判题性能要看提交→判题完成的整体耗时而不是只看HTTP响应。3.2 第一轮压测数据库连接池被打穿第一轮压测跑场景A线程组从200并发起步逐步加到500、1000。结果500并发时系统还能支撑QPS大约1800P95响应时间380ms看起来还算健康。但加到1000并发的时候问题一下爆出来了接口错误率突然飙到12%后端服务日志大量报错connection pool exhausted、HikariPool-1 - Connection is not available, request timed outMySQL CPU使用率不算高但活跃连接数飙到280接近连接池上限这就是典型的连接池被打穿。原因是后端服务默认配置了maximum-pool-size: 50但在线程池和连接池之间没有做合理的队列缓冲高并发下每个请求都去抢连接排队时间超过了连接等待超时阈值。我做了一次线上规整把三个参数做了调整maximum-pool-size从50提到100实测够用但不宜无限调大因为MySQL服务端也有max_connections限制接口层加了一级Redis缓存题目详情、排行榜这些热点数据直接从缓存返回不再穿透到数据库给提交接口加了信号量限流超过阈值直接返回系统繁忙请重试避免雪崩调整后重新跑1000并发错误率降到0.2%以下P95响应时间回到300ms以内。这里有个心得压测的时候别只盯着QPS曲线是否升高要盯着数据库连接数、线程池活跃数、GC频率这些次生指标。QPS是结果资源指标是原因原因先爆了QPS再高也维持不住。3.3 判题沙箱扩容后的CPU密集场景优化场景B的压测才是OJ系统的重头戏。我构造了一个100个用户同时提交代码的场景其中60%是正确答案会跑满测试用例30%是死循环代码会跑满超时时间10%是编译错误代码编译阶段就会失败。第一次跑判题队列积压严重。我统计了一下100道提交全部判完耗时4分半钟而每个单独用例的期望耗时不超过2秒。这个执行效率明显不合格。分析判题节点日志后发现瓶颈很清晰每个判题任务都会启动一个Docker容器容器启动耗时占比高达40%容器内执行编译、运行、关闭容器串行完成一个任务接一个任务执行完全没有并行死循环代码把CPU跑满导致同一节点的其他判题任务被拖慢针对这个我协同开发做了三处优化容器复用用预启动容器池空闲容器常驻任务进来直接复用避免每次创建销毁容器的开销。容器内每次执行前重置cgroup限制和工作目录即可。多Worker并行执行每个判题节点启动多个Worker进程每个Worker处理一个任务充分利用多核CPU。之前是单Worker4核CPU只用了1核。超时任务单独隔离死循环代码的CPU占用会被更严格地限制同时降低它在进程调度中的优先级。优化后重跑同样场景100道提交全部判完耗时从4分半钟压缩到55秒判题节点CPU利用率从25%提升到75%左右效果非常明显。3.4 压测指标与稳定性评估的注意点压测不是跑完就出结论还有几个容易被忽视的注意点注意系统预热。JVM应用刚启动时会有类加载和JIT编译的预热过程性能数据偏低。我压测前会用中低并发比如50并发跑10分钟让系统热起来再上高并发。注意长时间稳定性。短时间的高并发压测不能暴露内存泄漏问题。我单独跑了一个12小时的稳定性场景用场景C的混合负载模式固定200并发持续跑。这个过程中监控了几个关键指标JVM堆内存曲线是否平稳、Full GC次数是否递增、Redis内存占用是否持续上涨、判题队列是否有积压堆积。最后发现Redis里存的判题中间结果对象没有设置过期时间长时间运行后内存占用从800MB涨到2.1GB虽然还没到危险水位但是明显的隐患我提了Low级缺陷要求后续加过期清理任务。注意压测结果的可对比性。每次调优前后跑的场景、并发数、数据量必须一致不然没法对比。我每次压测都会把JMeter脚本、线程数、测试数据文件备份下来附到测试报告里这样开发和复查的人能完整还原测试过程。4. 安全测试OJ系统特有的攻击面4.1 越权与IDOR换个用户ID就能看别人代码安全测试阶段我先从常规Web攻击面入手因为这类问题最容易快速发现严重漏洞。越权测试IDORInsecure Direct Object Reference我第一个做。OJ系统里有几个典型对象ID题目ID、提交记录ID、用户ID。我用两个普通用户账号A和B做交叉测试用A的token访问B的提交记录详情接口/api/submission/{id}如果后端只校验了用户是否登录而没有校验这条提交记录是否属于当前用户就能看到B提交的源码。OJ系统的提交流源码比普通资料更敏感——里面可能包含没清理的注释、数据库连接信息、甚至私钥。修改请求中的userId参数尝试查看其他人的个人主页和统计信息。实测发现两个严重问题提交记录详情接口只校验了用户已登录没有校验记录归属导致任意登录用户可以通过遍历ID查看所有用户的代码。虽然前端页面没有暴露查看他人代码的入口但接口层面完全没拦住这个属于高危越权漏洞我作为P0级缺陷提交。管理后台的用户列表接口没有校验管理员角色普通用户伪造请求可以直接拉取全站用户列表和邮箱。这个我归为P1。修复方案分别是提交记录接口增加WHERE user_id ?条件校验管理接口增加RequireRole(ADMIN)注解。修复后我做了三组回归测试跨用户访问、跨角色访问、未登录访问全部返回403。4.2 Web注入与文件上传的常规检查越权测完接下来是注入类和文件上传类共性问题。SQL注入我主要用sqlmap跑了所有带参数的接口包括题目搜索、排行榜筛选、用户提交记录列表等。结果还算干净因为后端ORM框架参数化查询做得比较规范。不过我在输入框里手动提交了 OR 11 --这类特殊字符串时发现题目搜索接口的报错信息会直接抛出原始SQL片段不是查询结果是异常堆栈里的SQL存在信息泄露问题我提了个Low级建议统一封装全局异常处理器把数据库异常转换成通用提示。XSS测试重点放在题目内容展示和代码高亮页面。OJ系统里用户提交的代码会原样展示在页面上如果前端没有做转义用户提交一段包含script的代码其他用户查看时会执行恶意脚本。实测发现代码展示使用的是innerHTML直接渲染确实存在存储型XSS。修复方式是改为纯文本渲染代码高亮在前端用专门的库处理标签转义。文件上传接口我重点测了几个头像上传、题目附件上传。检查点包括扩展名白名单是否生效、文件大小是否限制、文件内容是否真实校验防止上传伪装成图片的webshell、上传目录是否在Web根目录下。实测头像上传允许上传SVG格式SVG文件里可以内嵌JavaScript脚本结合图片访问路径可以触发XSS。这个我归为P2修复方案是禁用SVG格式或者对SVG内容做脚本标签过滤。4.3 恶意代码提交沙箱逃逸与资源耗尽专项OJ系统安全测试的核心是判题沙箱本身的安全。这个环节我专门用了一台独立的测试判题节点因为有些测试会直接把节点搞挂。我构造了一批恶意代码提交样本覆盖以下几类攻击手段资源耗尽类Fork炸弹比如while(1) fork();目标是耗尽进程数导致宿主机器卡死死循环while(true){}目标是耗尽CPU大内存分配申请超过内存限制的数组目标是测试是否有cgroup内存限制兜底疯狂写文件dd if/dev/zero of/tmp/bigfile bs1M目标是写满磁盘系统逃逸类进程注入或调试附属试图附加到其他进程读取宿主文件比如读/etc/passwd、/proc/1/environ目标是确认容器隔离是否有效网络外联尝试连接外部地址目标是确认网络隔离策略执行系统命令比如Go语言里调用exec.Command(ls, /)Python里os.system(cat /etc/shadow)实测结果我认为可以给这个沙箱打70分但有两个关键问题内存限制是生效的。申请大数组的程序会在cgroup内存限制处被OOM Kill不会影响宿主。CPU时间限制也生效死循环在超过时限后被强制杀掉。致命问题是沙箱内的网络隔离没有完全封住。默认配置下容器可以访问外部网络虽然做了DNS屏蔽但直接通过IP访问外网端口是可以成功的。这意味着恶意用户提交代码可以扫描内网、访问同VPC下的其他服务。这是P0级安全缺陷我对判题容器增加了--network none配置并在宿主机iptables层面封禁了容器网段的非本地访问。至于文件系统隔离和进程隔离因为底层用了Docker容器基础隔离是有的但没有读到宿主文件。不过为了更稳妥我建议在判题容器内使用seccomp过滤危险系统调用只保留程序运行必需的系统调用白名单这在主流OJ系统里是标配。4.4 渗透测试报告里的高危与中危项复盘整个安全测试做完我输出了渗透测试阶段的专项发现。这里说一下最终整理出来的问题清单和处置建议编号风险类型严重程度问题描述修复建议SEC-001越权访问高危提交记录接口未校验归属可查看他人源码增加数据归属校验SEC-002容器网络隔离缺陷高危判题容器可访问外部网络--network none 防火墙规则SEC-003越权访问管理接口高危管理接口缺少角色校验增加权限注解SEC-004存储型XSS中危用户代码展示处未转义HTML纯文本渲染代码SEC-005文件上传格式绕过中危允许上传SVG文件导致XSS禁用SVG/过滤脚本SEC-006错误信息泄露低危SQL异常堆栈回显到前端全局异常处理器SEC-007Redis对象无过期策略低危长时间运行内存持续上涨增加过期时间和清理策略不要觉得只有高危才重要。SEC-006这种低危问题看起来只是报错信息不优雅但如果团队后续引入了更复杂的查询逻辑异常堆栈里的信息可能越来越敏感越早治理成本越低。我在报告里给了一个原则P0/P1问题必须上线前解决并回归P2问题可以带风险上线但必须排期P3问题记入技术债。5. 测试报告编写与问题闭环让老板和开发都满意的写法5.1 测试报告的结构从摘要到风险登记表前面测了那么多最后能不能转化成决策依据完全看测试报告怎么写。一份能让技术负责人和产品负责人都看明白的OJ系统测试报告我建议按这个结构组织测试概述测试对象、测试周期、测试环境拓扑、参与角色。这一段要短是给没时间看细节的人扫一眼用的。测试结论最后一段话给结论——是否达到上线标准、有哪些必须解决的高危问题、剩余风险是什么。这个位置很重要我见过很多人把结论写在最后但项目负责人根本没耐心翻到那一页。功能测试结果用例数量、通过率、未通过项清单、每个缺陷的复现步骤和截图。性能测试结果压测方案、场景模型、关键指标数据、调优前后的对比曲线、最大支撑容量的结论。安全测试结果渗透测试专项、漏洞清单、严重程度分级、修复状态。风险登记表所有未解决或有待观察的问题标清楚风险等级、影响范围、建议处置时间。报告不是越厚越好而是任何一个角色快速找到自己关心的模块越好。我写测试结论时会用一到两句话总结例如主力功能验证通过发现2个高危问题均已完成修复和回归性能指标满足预期具备上线条件但建议上线后持续观察判题队列积压情况。5.2 缺陷分级与复现步骤的规范缺陷描述的规范程度直接决定开发能不能快速定位。我在这次测试里形成了一套模板后面就一直沿用【标题】一句话说清楚模块-现象-条件 【优先级】P0/P1/P2/P3 【环境】测试环境 / 生产环境 【前置条件】需要哪些数据、权限、状态 【复现步骤】1. 2. 3. ... 【实际结果】发生了什么 【预期结果】应该是什么 【附件】截图、日志、压测数据举一个这次测试里真实出现的例子【标题】判题队列重复消费同一submissionId被两个Worker同时执行 【优先级】P1 【环境】测试环境判题节点2个MySQL 8.0Redis 6.2 【前置条件】同一道题有20个以上用户同时提交 【复现步骤】 1. 使用并发脚本在同一时刻发起20个提交请求 2. 观察判题节点日志 3. 检索相同submissionId的执行日志 【实际结果】出现同一submissionId被两个Worker同时取出并执行判题状态被覆盖但结果一致日志中出现两条同ID执行记录 【预期结果】每个submissionId只被一个Worker消费一次 【附件】日志片段、复现脚本缺陷分级我参考的是通用标准再做了微调P0是系统不可用或严重安全事故比如沙箱逃逸、全网崩溃P1是核心功能不可用或有高危漏洞比如判题结果错误、越权看源码P2是功能部分受影响但有变通方案比如排行榜统计延迟P3是使用体验或优化类问题。5.3 回归验证与上线放行标准测试报告写完后项目组最关心的是能不能上线。我给这次OJ系统定的上线放行标准是P0、P1缺陷清零所有高危漏洞和核心功能问题必须修复并且回归测试通过性能指标达标场景C混合负载下500并发时错误率不超过1%P95响应时间不超过500ms判题队列积压不能超过未处理任务的10%安全专项复测通过沙箱逃逸样本全部拦截容器网络隔离验证通过越权接口全部封堵风险登记表有明确责任人P2级问题可以带风险上线但必须写明修复排期回归测试我做了三轮。第一轮在开发修复完后立刻做范围是所有P0/P1缺陷相关的功能点第二轮是核心链路回归把判题主流程、提交记录、排行榜3个模块全量跑一遍防止修复引入新问题第三轮是安全专项回归把所有恶意代码样本重新跑一遍确认沙箱补丁没有绕过路径。三轮回归下来SEC-001越权修复后出现了新问题提交记录查询SQL加了user_id条件但普通用户看到自己提交记录列表时因为JOIN了题目表索引没有覆盖到新条件查询从80ms涨到了1.2秒。这个就是经典的修复一个漏洞引入一个性能问题所以回归测试不能只测功能还要顺带看一眼关键接口的性能有没有明显劣化。5.4 结束后我留下的三项长期资产测试结束、报告交付、系统上线后我没有就此收工。每次大测试我都会整理三样东西留给团队这次也不例外第一份是可复用的测试用例集。这次开发的所有恶意代码样本、并发提交脚本、压测JMeter脚本全部按模块整理好放到测试仓库里。下个版本迭代时判题模块只要改动直接拉取这批用例回归不用重新写脚本。第二份是判题节点健康检查清单。因为判题沙箱和普通服务不一样它出问题往往是执行环境层面的不是业务代码层面的。我写了一份运维检查清单容器网络策略是不是被改动了、cgroup配置是否被覆盖、Docker镜像版本是不是最新的、seccomp规则是否还在、磁盘空间是否被判题垃圾文件占满每个检查项都配上命令和期望输出运维值班的人照着跑一遍就能判断判题节点是否健康。第三份是测试报告的可追溯索引。这份文档记录了测试环境的所有配置、造数脚本的版本、压测脚本的参数、缺陷的修复commit记录。几个月后如果有人问当时500并发为什么过了现在300并发就崩了我可以直接从索引里找到当时的压测环境配比对比现在有什么差异而不是凭印象拍脑袋。如果这次测试非要让我说一个最想分享的体会那就是测试报告不是测试的终点而是团队对系统认知的沉淀起点。报告里写的每一个问题、每一个数据都在为下一次迭代提供参照坐标。OJ系统这种高并发、高风险、代码执行型产品尤其需要这种可追溯的测试资产。你这次把判题沙箱隔离测透了下次加语言支持、加难度算法、加挑战模式的时候就不会在同一个坑里再摔一遍。