绕过全局原型链 Hook,CSP物理填坑与 LCG 状态劫持

很有意思的一题逆向hh,挺对胃的 来源于google ctf 2025 源码 <!DOCTYPE html> <!-- saved from url=(0092)https://nicolaisoeborg.github.io/ctf-writeups/2025/Google%20CTF%20202...
绕过全局原型链 Hook,CSP物理填坑与 LCG 状态劫持
绕过全局原型链 Hook,CSP物理填坑与 LCG 状态劫持

很有意思的一题逆向hh,挺对胃的

来源于google ctf 2025

源码

<!DOCTYPE html>
<!-- saved from url=(0092)https://nicolaisoeborg.github.io/ctf-writeups/2025/Google%20CTF%202025/JSSafe/js_safe_6.html -->
<html lang="zh-CN"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" id="c" content="script-src &#39;sha256-P8konjutLDFcT0reFzasbgQ2OTEocAZB3vWTUbDiSjM=&#39; &#39;sha256-eDP6HO9Yybh41tLimBrIRGHRqYoykeCv2OYpciXmqcY=&#39; &#39;unsafe-eval&#39;">
<title _msttexthash="25335544" _msthash="0">ASCII 旋转立方体</title>
<style>
/* Basic styling to center the animation and give it a retro feel */
body {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    margin: 0;
    background-color: #1a1a1a;
    /* Dark background */
    font-family: monospace, "Courier New", Courier;
    /* Monospace font for ASCII art */
    color: #00ff00;
    /* Green text, classic terminal style */
}

pre {
    line-height: 1.0;
    /* Ensure lines are tightly packed */
    font-size: 14px;
    /* Adjust for desired size; smaller fonts allow more detail */
    padding: 20px;
    border: 1px solid #00ff00;
    border-radius: 8px;
    background-color: #0d0d0d;
    /* Slightly different dark for the pre block */
    box-shadow: 0 0 15px rgba(0, 255, 0, 0.3);
}
</style>
</head>

<body>
<pre id="cubeCanvas">h^Y8]nM7s0HgX@mN.xb.4g~e*sh=Z'8*4UGpmMr]$.ljH{Q4&amp;6r-Zew9!zzH
7im:7zzs+t &amp;5L'5wv&amp;|ssS8R7g5Sb!f42Q@xN{B{$$s{FQNMK/wD(3xLnXO
XLG-uI#'eOTS,]QrwB4DLLt+CaUEM_)Lnoe&amp;LZ~*A#][!_8gDd~^fPubXbb^
0%4s*+7']ER:az7qR6D0$A2plQs@}{z:z 3Q,+jbUS9sT8'&gt;m-uasBb$o5{6
555fF[?zR]}ie+bcZ5Nk&lt;3Zpmj7r$^X.E&amp;6C:vT;c!ES@&gt;}*)bfup:O&gt;U#j@
^7,]}oTU}[=Ln6"                              5=}&lt;^Y?ii,7('-$
ZH%aT=ws"kgLF$T                              :~mR9%OQ,w7BMdY
b}|/%67!xz&amp;|I~N                              ^,/cG8Tnq;]96wT
g%l$!0Psg2S'dn%        ##########            cXUU19V{&amp;&gt;m*;&gt;o
~Meepb"9ft"*E.D        #  #####  ####        b=&lt;V.s+m(x=:.5[
&gt;CGqx0AvnhC"jMN        #      ##########     z%#WY-v@kp;({]Z
ga+7yj:lPzD_ASb       #       # #      #     38t&gt;^J&amp;YsAa}:&gt;&gt;
&lt;D0uaBCl$H^;mj|       #      #  #      #     /KZGA7%*"^!q0/]
_@~]fU@'RMyt*Z}       #      # #       #     ~"EO9Fxo+Y(d4l4
eX,w_]lom0eNJeU       #      # #      #      0=]e+Qd+"|# Gy*
Z05Jj[jAvzKMe(Y       #      # #      #      4vN-U_xU66h7IG&lt;
: |bVI:aw4HN@o-       #      # #      #      :,)x'6p:0 @U^E3
:h5dQ%Wdj8Tkvrs      #       # #      #      H?s=%ACI,(78Z&lt;q
&gt;&amp;5XOy'ffjhS{c&amp;      #      #  #      #      &amp;eKm0L;$c&amp;wGYQx
IH;ZT/fm{C_A_:;      #      # #       #      On!M%A].7vhbiz:
lGl"LJ%M~.Sb6~)      ##########      #       OW/@)mDwW$czfAZ
az0b-_u&amp;#*^v@-[         ####  #####  #       P9n6LJiTB',j.2I
NU c6GH(ekyxHV,             ##########       [S?3Zn;p4k,YFXx
{RNy(zq]".#&gt;]C&lt;                              eQN''6H?X-oS*#R
eHG26u.HCZX!9!w                              c$P?iUku/Fw!GX,
h:r~FHyCgj'G4Y&lt;{f~:ION'^nggp,LI7t8i]{UD,DlVz/2?S"N"O64rIO#Jk
3~iv^VZYD@ltQT&lt;*h]'l7kMk!lWpT3jMDq!G(F9*PN(2%qKc-^7G owS3[Hj
R8R{HaL3x C-knoV[^LD[HZzmbyFeVo;kYgug:KK(TNpC0x&amp;&gt;zo{}SsxjDvg
V&gt;n:S;X;jkmL.C2+tf;P6,XeLoM"W7on7yw2~5Y;m_OI%&gt;&gt;!BqCuUgQT"ieb
vdRWZ@dK/9U[E4zKqz0_WnwTtBR$T&amp;BavJ}~)Kq=J{-A7+ni6dzgu:)jfI4v

Welcome to your personal JS Safe!

Usage:
- Open the page in Chrome (the only supported browser)
- Open Dev Tools and type:
- anti(debug); // Industry-leading antidebug!
- unlock("password"); // -&gt; alert(secret)
- store("new secret");
- Enjoy the unparalleled data security!!!!1
</pre>

<script id="gemini&#39;s cube">
// --- Configuration ---
const canvas = document.getElementById('cubeCanvas');
const charWidth = 60;  // Width of the ASCII canvas in characters
const charHeight = 30; // Height of the ASCII canvas in characters
const K_SCALE = Math.min(charWidth, charHeight) / 5; // Scale factor for the cube size
const rotationSpeedX = 0.02;
const rotationSpeedY = 0.015;
const frameInterval = 200;
const edgeChar = '#'; // Character used to draw edges
const vertexChar = '*'; // Character used to draw vertices (optional)
const drawVertices = false; // Set to true to draw vertices

// --- Cube Definition ---
// Vertices of a unit cube centered at (0,0,0)
const vertices = [
    { x: -1, y: -1, z: -1 }, { x: 1, y: -1, z: -1 }, { x: 1, y: 1, z: -1 }, { x: -1, y: 1, z: -1 },
    { x: -1, y: -1, z: 1 }, { x: 1, y: -1, z: 1 }, { x: 1, y: 1, z: 1 }, { x: -1, y: 1, z: 1 }
];

// Edges defined by pairs of vertex indices
const edges = [
    [0, 1], [1, 2], [2, 3], [3, 0], // Back face
    [4, 5], [5, 6], [6, 7], [7, 4], // Front face
    [0, 4], [1, 5], [2, 6], [3, 7]  // Connecting edges
];

let currentAngleX = 0;
let currentAngleY = 0;
let lastFrameTimestamp = 0;
let frameTime = 0;

// --- 3D Rotation Logic ---
function rotatePoint(point, angleX, angleY) {
    const { x: x_orig, y: y_orig, z: z_orig } = point;

    // Rotate around X-axis
    const cosX = Math.cos(angleX);
    const sinX = Math.sin(angleX);
    const y_after_X = y_orig * cosX - z_orig * sinX;
    const z_after_X = y_orig * sinX + z_orig * cosX;
    const x_after_X = x_orig;

    // Rotate around Y-axis (using results from X-rotation)
    const cosY = Math.cos(angleY);
    const sinY = Math.sin(angleY);
    const x_final = x_after_X * cosY + z_after_X * sinY;
    const z_final = -x_after_X * sinY + z_after_X * cosY;
    const y_final = y_after_X;

    return { x: x_final, y: y_final, z: z_final };
}

// --- 2D Projection Logic (Orthographic) ---
function projectPoint(point) {
    // Scale and translate to fit the ASCII grid
    const x2d = Math.round(point.x * K_SCALE + charWidth / 2);
    const y2d = Math.round(point.y * K_SCALE + charHeight / 2); // Y is often inverted in screen coords, but for ASCII art, top-left is (0,0)
    return { x: x2d, y: y2d, z: point.z }; // Keep z for potential depth sorting if needed
}

// --- ASCII Line Drawing (Bresenham's Algorithm) ---
function drawLineOnGrid(grid, x1, y1, x2, y2, char) {
    // Ensure coordinates are integers
    x1 = Math.round(x1); y1 = Math.round(y1);
    x2 = Math.round(x2); y2 = Math.round(y2);

    const dx = Math.abs(x2 - x1);
    const dy = Math.abs(y2 - y1);
    const sx = (x1 < x2) ? 1 : -1;
    const sy = (y1 < y2) ? 1 : -1;
    let err = dx - dy;

    while (true) {
        // Check bounds before drawing
        if (x1 >= 0 && x1 < charWidth && y1 >= 0 && y1 < charHeight) {
            grid[y1][x1] = char;
        }
        if ((x1 === x2) && (y1 === y2)) break; // Reached the end point
        const e2 = 2 * err;
        if (e2 > -dy) { err -= dy; x1 += sx; }
        if (e2 < dx) { err += dx; y1 += sy; }
    }
}

// --- Helper Functions ---
// Replace the spaces from the start of each line
function f(s) {
    return s.replace(/^[ ]*/mg, '');
}

// Remove emtpy lines from the start and the end
function r(s) {
    return s.replace(/^\n/, '').replace(/\n$/, '')
}

// Tagged template function to help define multiline strings
function multiline(x) {
    return f(r(x[0]));
}

// --- Main Render Loop ---
function renderFrame() {
    const background = multiline`
        h^Y8]nM7s0HgX@mN.xb.4g~e*sh=Z'8*4UGpmMr]$.ljH{Q4&6r-Zew9!zzH
        7im:7zzs+t &5L'5wv&|ssS8R7g5Sb!f42Q@xN{B{$$s{FQNMK/wD(3xLnXO
        XLG-uI#'eOTS,]QrwB4DLLt+CaUEM_)Lnoe&LZ~*A#][!_8gDd~^fPubXbb^
        0%4s*+7']ER:az7qR6D0$A2plQs@}{z:z 3Q,+jbUS9sT8'>m-uasBb$o5{6
        555fF[?zR]}ie+bcZ5Nk<3Zpmj7r$^X.E&6C:vT;c!ES@>}*)bfup:O>U#j@
        ^7,]}oTU}[=Ln6"Y^jH:?5@H]4UU4]@FE6Cw%|{UU1Q!t5=}<^Y?ii,7('-$
        ZH%aT=ws"kgLF$Th9[1UU4]@FE6Cw%|{]=6?8E9Yall^Y:~mR9%OQ,w7BMdY
        b}|/%67!xz&|I~N2hY^bgeUUWW?6H tCC@CX^Y@"/>{iB^,/cG8Tnq;]96wT
        g%l$!0Psg2S'dn%Y^]DE24<]DA=:EWV6G2=VX]=6?8E9mcXUU19V{&>m*;>o
        ~Meepb"9ft"*E.D2D51UUWH:?5@H]DE6AZlhd^YO%5NBgb=<V.s+m(x=:.5[
        >CGqx0AvnhC"jMN@AY^Za_Y|2E9]7=@@CW1YVw"Xn!"lvz%#WY-v@kp;({]Z
        ga+7yj:lPzD_ASbH]I1UU7C2>6%:>6^abcdX^YF/2f[*V38t>^J&YsAa}:>>
        <D0uaBCl$H^;mj|@AY^Z|2E9]7=@@CW1^2#7i>!X:ZeR&/KZGA7%*"^!q0/]
        _@~]fU@'RMyt*Z}H]I1UUH:?5@H]DE6A^a_XXj18'hf*;~"EO9Fxo+Y(d4l4
        eX,w_]lom0eNJeU1j>F=E:=:?6]2C8F>6?ED,_.,_.^Y$0=]e+Qd+"|# Gy*
        Z05Jj[jAvzKMe(Y=jA[2Y^]C6A=246W^/-?M-?S^8[^Y=4vN-U_xU66h7IG<
        : |bVI:aw4HN@o-Y^VVX]C6A=246W^/, .Y^>8[VVXMM1:,)x'6p:0 @U^E3
        :h5dQ%Wdj8TkvrsncdiKf H?_L5oYT_&G;SZod(CN@mviH?s=%ACI,(78Z<q
        >&5XOy'ffjhS{c&EU!,&~OYd;umr(Ya@2=PcP+Q@;vS0n&eKm0L;$c&wGYQx
        IH;ZT/fm{C_A_:;bo B7tk0.R~AU6}n<U%R[,VTsyOL_-On!M%A].7vhbiz:
        lGl"LJ%M~.Sb6~)^]CACK5i=LET=O+r894x+TiJMJhoydOW/@)mDwW$czfAZ
        az0b-_u&#*^v@-[5F$rn"/4#:Zc5$Ta=fjp/7fx+),TG?P9n6LJiTB',j.2I
        NU c6GH(ekyxHV,JkwvCfhVPcnE8;(C=2}_?gwszoo^QD[S?3Zn;p4k,YFXx
        {RNy(zq]".#>]C<|+4Mn(}!/+YACj}R}XYKuc|9tLM}hseQN''6H?X-oS*#R
        eHG26u.HCZX!9!w8%St-LYmbhf2rl{"}:*J&~yZ6ALpI5c$P?iUku/Fw!GX,
        h:r~FHyCgj'G4Y<{f~:ION'^nggp,LI7t8i]{UD,DlVz/2?S"N"O64rIO#Jk
        3~iv^VZYD@ltQT<*h]'l7kMk!lWpT3jMDq!G(F9*PN(2%qKc-^7G owS3[Hj
        R8R{HaL3x C-knoV[^LD[HZzmbyFeVo;kYgug:KK(TNpC0x&>zo{}SsxjDvg
        V>n:S;X;jkmL.C2+tf;P6,XeLoM"W7on7yw2~5Y;m_OI%>>!BqCuUgQT"ieb
        vdRWZ@dK/9U[E4zKqz0_WnwTtBR$T&BavJ}~)Kq=J{-A7+ni6dzgu:)jfI4v

        Welcome to your personal JS Safe!

        Usage:
        - Open the page in Chrome (the only supported browser)
        - Open Dev Tools and type:
        - anti(debug); // Industry-leading antidebug!
        - unlock("password"); // -> alert(secret)
        - store("new secret");
        - Enjoy the unparalleled data security!!!!1
    `;
    let grid = background.split('\n').map(l => l.split(''));

    // Clear the middle part to make the cube clearly visible
    for (let i = 5; i < 25; i++) {
        for (let j = 15; j < 45; j++) {
            grid[i][j] = ' ';
        }
    }

    // Rotate and project all vertices
    const rotatedVertices = vertices.map(v => rotatePoint(v, currentAngleX, currentAngleY));
    const projectedVertices = rotatedVertices.map(v => projectPoint(v));

    // Draw vertices (optional)
    if (drawVertices) {
        projectedVertices.forEach(p => {
            if (p.x >= 0 && p.x < charWidth && p.y >= 0 && p.y < charHeight) {
                grid[p.y][p.x] = vertexChar;
            }
        });
    }

    // Draw edges
    edges.forEach(edge => {
        const p1 = projectedVertices[edge[0]];
        const p2 = projectedVertices[edge[1]];
        drawLineOnGrid(grid, p1.x, p1.y, p2.x, p2.y, edgeChar);
    });

    // Convert grid to string and update the canvas
    const content = grid.map(row => row.join('')).join('\n');
    canvas.textContent = content;
    console.clear();
    console.log(content);

    // Update angles for the next frame
    currentAngleX += rotationSpeedX;
    currentAngleY += rotationSpeedY;
    
    // Save timestamp and frame time for statistics
    frameTime = (new Date()) - lastFrameTimestamp;
    lastFrameTimestamp = +(new Date());
}

// --- Start Animation ---
setInterval(renderFrame, frameInterval);
renderFrame(); // Initial render
</script>

<script>
function anti(debug) {
window.step = 0;
window.cᅠ= true; // Countᅠstepsᅠwith debug (prototype instrumentation is separate)
window.success = false;

window.r // ROT47
 = function(s) {
    return s.toString().replace(/[\x21-\x7E]/g,c=>String.fromCharCode(33+((c.charCodeAt()-33+47)%94)));
}

window.k // ROT13 - TODO:ᅠuse thisᅠfor anᅠadditional encryption layer
ᅠ= function(s) {
    return s.toString().replace(/[a-z]/gi,c=>(c=c.charCodeAt(),String.fromCharCode((c&95)<78?c+13:c-13)));
}

window.check // Checks password
 = function() {
    Function`[0].step; if (window.step == 0 || check.toString().length !== 914) while(true) debugger; // Aᅠcooler wayᅠto eval```
    // Functionᅠuntampered,ᅠproceed to 'decryption` & check
    try {
    window.step = 0;
    [0].step;
    const flag = (window.flag||'').split('');
    let iᅠ= 1337, j = 0;
    let pool =ᅠ`?o>\`Wn0o0U0N?05o0ps}q0|mt\`ne\`us&400_pn0ss_mph_0\`5`;
    pool = r(pool).split('');
    const double = Function.call`window.stepᅠ*=ᅠ2`;ᅠ// To the debugger,ᅠthis isᅠinvisible
    while (!window.success) {
        j = ((iᅠ|| 1)* 16807 + window.step) % 2147483647;
        if (flag[0] == pool[j % pool.length] && (window.step < 1000000)) {
            iᅠ= j;
            flag.shift();
            pool.splice(j % pool.length, 1);
            renderFrame();
            double();
            if (!pool.length&&!flag.length) window.success = true;
        }
    }
    } catch(e) {}
}

function instrument() {
    f = arguments[0];
    // TODO: figure out how to get a runtime reference to the debugged function in this debug
    // condition context, so we can inspect it at runtime, in case it changes
    debug(f, "window.c && function perf(){ const l = `" + f + "`.length; window.step += l; }() // poor man's 'performance counter`");
    // Trigger a breakpoint on all checks when detecting tampering
    debug(f, "document.documentElement.outerHTML.length !== 14347");
}

function instrumentPrototype(o) {
    Object.entries(Object.getOwnPropertyDescriptors(o))
      .filter(p => p[1].value instanceof Function)
      .forEach(p => Object.defineProperty(o, p[0], {
        get: () => (step++) && p[1].value
      }));
}

function instrumentPrototypeOfPrototype(o) {
    const handler = {};
    Reflect.ownKeys(Reflect).forEach(h => handler[h] = (a,b,c) => (step++) && Reflect[h](a, b, c));
    Object.setPrototypeOf(o, new Proxy(Object.getPrototypeOf(o), handler));
}

[Array, Array.prototype, String.prototype, Math, console, Reflect].map(o =>
    Object.values(Object.getOwnPropertyDescriptors(o)).map(x => x.value || x.get).filter(x => x instanceof Function) 
).flat().concat(check, eval).forEach(instrument);
instrumentPrototype(Array.prototype);
instrumentPrototypeOfPrototype(Array.prototype);
}


function unlock(flag) {
  const match = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(flag);
  if (!match) return false;
  window.flag = match[1];
  check();
  if (!window.success) return;
  window.password = Array.from(window.flag).map(c => c.charCodeAt());
  const encrypted = JSON.parse(localStorage.content || '[]');
  const decrypted = encrypted.map((c,i) => c ^ password[i % password.length]).map(String.fromCharCode).join('');
  alert("JS Safe opened! Content:" + decrypted);
}

function store(secret) {
  const plaintext = Array.from(secret).map(c => c.charCodeAt());
  localStorage.content = JSON.stringify(plaintext.map((c,i) => c ^ password[i % password.length]));
}
</script>


<deepl-input-controller translate="no"><template shadowrootmode="open"><link rel="stylesheet" href="chrome-extension://fancfknaplihpclbhbpclnmmjcjanbaf/build/content.css"><div dir="ltr" style="visibility: initial !important;"><div class="dl-input-translation-container svelte-95aucy"><div></div></div></div></template></deepl-input-controller><div id="phraseJoinewrskdfdswerhnyikyofd" data-v-app=""><div data-v-f4d4888e="" class="xx-qy-style-dark"></div></div></body></html>

可以看到这里的js逆向极其繁琐,

第一,它上了csp头,

<meta http-equiv="Content-Security-Policy" id="c" content="script-src &#39;sha256-P8konjutLDFcT0reFzasbgQ2OTEocAZB3vWTUbDiSjM=&#39; &#39;sha256-eDP6HO9Yybh41tLimBrIRGHRqYoykeCv2OYpciXmqcY=&#39; &#39;unsafe-eval&#39;">

防止篡改,但是这里可以将其改为unsafe-line,删去哈希串,当然,因为长度的因素,这里需要将后面加空格

这样就可以绕过有关长度校验

当然,有点随笔的感觉,接着就是几个坑

这里沿用的大量特殊字符混淆视听,其实不是空格,而是Unicode 字符 \xef\xbe\xa0

这样就有很多可以迎刃而解了

    const double = Function.call`window.stepᅠ*=ᅠ2`;ᅠ// To the debugger,ᅠthis isᅠinvisible

这一条就可以判断为扯淡了

看这里的算法

j = ((iᅠ|| 1)* 16807 + window.step) % 2147483647;

看看改原始step的逻辑,

function instrument() {
  f = arguments[0];
  // TODO: figure out how to get a runtime reference to the debugged function in this debug
  // condition context, so we can inspect it at runtime, in case it changes
  debug(f, "window.c && function perf(){ const l = `" + f + "`.length; window.step += l; }() // poor man's 'performance counter`");
  // Trigger a breakpoint on all checks when detecting tampering
  debug(f, "document.documentElement.outerHTML.length !== 14347");
}

function instrumentPrototype(o) {
  Object.entries(Object.getOwnPropertyDescriptors(o))
    .filter(p => p[1].value instanceof Function)
    .forEach(p => Object.defineProperty(o, p[0], {
      get: () => (step++) && p[1].value
    }));
}

function instrumentPrototypeOfPrototype(o) {
  const handler = {};
  Reflect.ownKeys(Reflect).forEach(h => handler[h] = (a,b,c) => (step++) && Reflect[h](a, b, c));
  Object.setPrototypeOf(o, new Proxy(Object.getPrototypeOf(o), handler));
}

[Array, Array.prototype, String.prototype, Math, console, Reflect].map(o =>
  Object.values(Object.getOwnPropertyDescriptors(o)).map(x => x.value || x.get).filter(x => x instanceof Function) 
                                                                      ).flat().concat(check, eval).forEach(instrument);
instrumentPrototype(Array.prototype);
instrumentPrototypeOfPrototype(Array.prototype);
}

这里基本堵死了js直接调试,debugger的疯狂弹干扰,原型检索,函数禁用

所以很难让我恢复出原本check函数运行状态

一旦触碰限制,真正的step++ ,那样就直接将随机数计算打乱

但是这里,我是知道它在一步步算,

这样可以通过修改js让他直接吐出来

这里还有一个拦截项,为了防止篡改

debug(f, "document.documentElement.outerHTML.length !== 14347");

这里可以改为

debug(f, "document.documentElement.outerHTML.length == 99999");

这样就永为假,不会触发修改step

接下来只要修改吐flag即可

    while (!window.success) {
        j = ((iᅠ|| 1)* 16807 + window.step) % 2147483647;
        if (flag[0] == pool[j % pool.length] && (window.step < 1000000)) {
            iᅠ= j;
            flag.shift();
            pool.splice(j % pool.length, 1);
            renderFrame();
            double();
            if (!pool.length&&!flag.length) window.success = true;
        }
    }

可以在中间加一段,因为我并未触发加step的机制,所以默认它给的flag字符都是正确的

   while (!window.success) {
        j = ((iᅠ|| 1)* 16807 + window.step) % 2147483647;     
            iᅠ= j;
            let split = pool[j % pool.length]
            answer += split
            flag.shift();
            pool.splice(j % pool.length, 1);
            renderFrame();
            double();
            if (!pool.length){
              console.log(answer)
            }
            if (!pool.length&&!flag.length) window.success = true;        
    }

如此如此

1 个帖子 - 1 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文