> 手痒逆向了一个女朋友老师自己搭建的考试系统,本人鄙视这种限制学生粘贴的行为🤣。本文记录了对两个版本的前端反作弊机制进行逆向分析与绕过的完整过程,最终通过编写 Tampermonkey 油猴脚本,实现了对两个版本所有反作弊机制的完全绕过,并额外实现了本地 Python 代码执行、下载任意学号报告等扩展功能。 --- ## 一、系统架构概述 该考试系统采用前后端分离架构: ```plaintext ┌─────────────────┐ ┌──────────────────┐ │ 前端 (HTML/JS) │ HTTP │ 后端 (Flask) │ │ Port 80 / 81 │ ◄─────► │ Port 5105 / 5106 │ │ Monaco Editor │ │ Python 3.10 │ └─────────────────┘ └──────────────────┘ ``` * **考试1**:前端 `:80`,后端 `:5105`,基础反调试 * **考试2**:前端 `:81`,后端 `:5106`,升级版反调试 两个版本共享相似的代码结构,但考试2在安全层面做了大量增强。后端基于 Werkzeug/Flask,提供题目获取、代码运行、批改、提交等 RESTful API。 --- ## 二、考试1逆向分析 ### 2.1 debugger 循环炸弹 **考试1的核心反调试手段是 ****`debugger` 循环**。当开发者工具处于打开状态时,`debugger` 语句会导致 JavaScript 引擎暂停执行,配合计时就能判断工具是否打开: ```javascript function detectDevToolsComprehensive() { // 学号 101242805(老师)不做检测 const studentId = localStorage.getItem('student_id'); if (studentId === "101242805") return false; const start = performance.now(); for (let i = 0; i < 100000; i++) { debugger; // 关键:如果 DevTools 打开,每次都会暂停 } const debuggerTime = performance.now() - start; if (debuggerTime > 5000) { // 正常执行远小于 50ms detected = true; } } ``` **原理分析**: * 当 DevTools 关闭时,`debugger` 语句被忽略,10 万次循环几乎瞬间完成 * 当 DevTools 打开时,每个 `debugger` 都会触发断点暂停,总耗时远超 5 秒 > ⚠️ 注意:这里硬编码了老师的学号 **`101242805` 作为白名单。这个信息后面会被利用。 ### 2.2 计时器漂移检测 第二道防线是 **计时器漂移检测**。页面有一个考试计时器,每秒通过 `setInterval` 更新 DOM。同时每 3 秒检查一次页面计时器的显示值是否与系统真实流逝时间一致: ```javascript function detectDevToolsByTimer() { const timerText = document.getElementById('timer').textContent; const match = timerText.match(/考试计时[::](\d+):(\d+)/); const totalTimerSeconds = parseInt(match[1]) * 60 + parseInt(match[2]); const expectedElapsed = Math.floor((Date.now() - startTime) / 1000); const timeDiff = Math.abs(totalTimerSeconds - expectedElapsed); if (timeDiff > 3) { // 允许 ±3 秒误差 handleDevToolsDetection(`计时器异常 (差值: ${timeDiff}秒)`); } } ``` **原理**:如果 `debugger` 循环卡住了主线程,`setInterval` 回调会被延迟,导致页面显示的时间比系统实际流逝的时间慢。差值超过 3 秒就判定异常。 ### 2.3 综合检测调度中心 **所有检测通过一个调度函数统一启动:** ```javascript function setupCompleteProtection() { if (!isTopWindow()) { handleIframeEmbedding(); // 防止 iframe 嵌入 return; } setupVisibilityDetection(); // Page Visibility API setupFocusDetection(); // window blur/focus setInterval(detectDevToolsComprehensive, 3000); // 每 3 秒一次 debugger 检测 setInterval(detectDevToolsByTimer, 3000); // 每 3 秒一次计时器漂移检测 setTimeout(detectDevToolsByTimer, 5000); // 首次延迟 5 秒检测 } ``` 检测触发后的处置逻辑是**分级制**: | **次数** | **处置** | | -------------- | ----------------------------------------------------- | | 第 1 次 | `myAlert` 警告 | | 第 2 次及以上 | `showBlockingOverlay()` 黑色全屏遮罩,阻止一切操作 | ### 2.4 DOM 级别的防护 除了 JavaScript 检测之外,还有 DOM 属性级别的防护: ```javascript document.oncontextmenu = () => false; // 禁止右键 document.oncopy = () => false; // 禁止复制 ``` 以及在 editorDiv 上直接绑定的 keydown 拦截器: ```javascript editorDiv.addEventListener('keydown', e => { if ((e.ctrlKey && (e.key === 'v' || e.key === 'c')) || (e.shiftKey && e.key === 'Insert')) { e.preventDefault(); myAlert('⚠️ 当前考试禁止复制粘贴!'); } }); ``` --- ## 三、考试2逆向分析 考试2在考试1的基础上进行了**全面升级**(可能求助了豆包^\_^),新增了 7 项检测/防护机制。 ### 3.1 服务端 Token 验证 考试1只检查 `localStorage` 里有没有 `student_id`,没有任何后端验证。考试2加入了 Token 机制: ```javascript async function verifyLogin() { const sid = localStorage.getItem("student_id"); const token = localStorage.getItem("token"); const res = await fetch(`${BASE_URL}/verify_login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ student_id: sid, token: token }) }); const data = await res.json(); if (!data.valid) { localStorage.clear(); location.href = "login.html"; // 踢回登录页 } } verifyLogin(); // 页面加载即执行 ``` Token 的格式为 `学号:时间戳:随机盐:HMAC签名`,例如: ```plaintext 24016666:1774455505:2567e28a458782ed:050665574339ba89f8a0aed36374536c4ada082da3aaec52a8eb4765bc894358 ``` ### 3.2 窗口最大化 + 焦点检测 这是考试2**最显著的新增机制**。每 500ms 检查一次窗口是否处于最大化状态: ```javascript function checkExamStatus() { // 检测是否最大化(允许 20px 误差) const isMaximized = Math.abs(window.screen.availWidth - window.outerWidth) < 20 && Math.abs(window.screen.availHeight - window.outerHeight) < 20; // 检测当前窗口是否拥有焦点 const isFocused = document.hasFocus(); if (!isMaximized || !isFocused) { document.getElementById("warningLayer").style.display = "flex"; // 显示红色全屏警告遮罩 } } setInterval(checkExamStatus, 500); window.addEventListener('resize', checkExamStatus); ``` `warningLayer` 是一个预置在 HTML 中的全屏红色遮罩层: ```html ``` ### 3.3 违规计数器 考试1检测到异常直接封锁,没有宽限。考试2引入了**分级违规计数器**: ```javascript let violationCount = parseInt(localStorage.getItem('violationCount') || 0); const MAX_VIOLATIONS = 3; function handleViolation(reason) { violationCount++; localStorage.setItem('violationCount', violationCount); if (violationCount >= MAX_VIOLATIONS) { myAlert(`请保持考试页面为顶层页面!`); } else { myAlert(`警告:${reason}!当前违规次数:${violationCount}/${MAX_VIOLATIONS}`); } } window.onblur = () => handleViolation("尝试切换窗口或离开考场"); ``` **关键发现**:违规计数存储在 `localStorage` 中,然而**并不会上报后端。**所有违规处置逻辑都是纯前端的,没有任何 `fetch` 调用将违规记录发送到服务器。 ### 3.4 自定义弹窗替换原生 alert 考试1使用原生 `alert()`,很容易通过 `window.alert = () => {}` 拦截。考试2改用了自定义的 DOM 弹窗: ```javascript function myAlert(msg) { const modal = document.getElementById("customAlert"); document.getElementById("myAlertBody").innerHTML = `考生 ID: ${getSid()}${msg}`; modal.style.display = "flex"; } ``` 这意味着简单地覆盖 `window.alert` 无法拦截考试2的警告弹窗,必须同时拦截 `window.myAlert`。 ### 3.5 拖拽锁屏 在每个编辑器区域 `editorDiv` 上绑定了 `drop` 事件监听器。一旦触发拖拽操作,立即创建黑色全屏遮罩并禁用所有交互: ```javascript editorDiv.addEventListener('drop', (e) => { e.preventDefault(); const block = document.createElement('div'); block.style = `position:fixed; top:0; left:0; width:100%; height:100%; background:black; color:white; z-index:99999; ...`; block.innerHTML = '🚫 违规操作!页面已锁定...'; document.body.appendChild(block); // 禁用一切交互 document.addEventListener('click', e => e.preventDefault(), true); document.addEventListener('keydown', e => e.preventDefault(), true); }); ``` 这种设计的意图是阻止学生通过拖拽文件到编辑器来导入代码。 ### 3.6 编辑器粘贴限制与行宽检测 除了普通的 `Ctrl+V` / `Shift+Insert` 拦截,考试2还增加了**行字符宽度检测**: ```javascript const charLimitPerLine = 80; function getStringWidth(str) { let width = 0; for (let i = 0; i < str.length; i++) { if (str.charAt(i).match(/[^\x00-\xff]/)) { width += 2; // 中文字符算 2 宽度 } else { width += 1; } } return width; } function handlePaste(e) { const pasteData = e.clipboardData.getData('text'); const pasteWidth = getStringWidth(pasteData); if (currentLineWidth + pasteWidth > charLimitPerLine) { e.preventDefault(); myAlert(`粘贴内容会导致行超过 ${charLimitPerLine} 字符宽度限制`); } } ``` ### 3.7 fetch() 全局劫持 考试2最高级的防护是**劫持 `window.fetch`**,对所有发往后端的请求自动注入认证信息: ```javascript const originalFetch = window.fetch; window.fetch = async function (url, options = {}) { if (typeof url === 'string' && url.includes(BASE_URL)) { options.credentials = "include"; // 自动注入认证头 const headers = { ...createAuthHeaders(), // Authorization, X-Auth-Token, X-Student-ID ...options.headers }; options.headers = headers; // 自动往 body 里注入 student_id 和 token if (options.method === 'POST' && options.body) { const bodyObj = JSON.parse(options.body); bodyObj.student_id = bodyObj.student_id || localStorage.getItem('student_id'); bodyObj.token = bodyObj.token || localStorage.getItem('token'); options.body = JSON.stringify(bodyObj); } } const response = await originalFetch.call(this, url, options); if (response.status === 401) { // 自动登出 location.href = 'login.html'; } return response; }; ``` 这意味着如果我们想用不同的 `student_id` 调用 API(比如下载别的同学或老师的报告),必须绕过这个劫持。 --- ## 四、油猴脚本编写 ### 4.1 注入时机:document-start 的重要性 整个绕过方案的基石是 `@run-at document-start`: ```javascript // ==UserScript== // @name 考试系统反调试绕过 // @version 2.0 // @match http://47.100.117.38/* // @match http://47.100.117.38:81/* // @match http://47.100.117.38:5105/* // @match http://47.100.117.38:5106/* // @grant none // @run-at document-start // ==/UserScript== ``` `document-start` 确保我们的脚本在**页面的任何 ` 发表评论 取消回复 联系方式请填写QQ邮箱以便博主可以联系您,以及使用您的QQ头像评论 评论 * 私密评论 名称 * 🎲 邮箱 * 地址 发表评论 提交中...