본문 바로가기
분류 전/CTF

[DownUnderCTF 2025] mini-me 풀이

by jwcs 2025. 7. 22.
728x90
반응형

client-side 취약점 문제다. 기능 분석은 스킵하고 바로 문제 풀이로 넘어가겠다.

 

from flask import Flask, render_template, send_from_directory, request, redirect, make_response
from dotenv import load_dotenv

import os
load_dotenv()
API_SECRET_KEY = os.getenv("API_SECRET_KEY")
FLAG = os.getenv("FLAG")

app = Flask(__name__, static_folder="static", template_folder="templates")

@app.after_request
def add_header(response):
    response.cache_control.no_store = True
    response.cache_control.must_revalidate = True
    return response

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/login", methods=["POST"])
def login():
    return redirect("/confidential.html")

@app.route("/confidential.html")
def confidential():
    return render_template("confidential.html")


@app.route("/admin/flag", methods=["POST"])
def flag():
    key = request.headers.get("X-API-Key")
    if key == API_SECRET_KEY:
        return FLAG
    return "Unauthorized", 403

app.py의 코드다. `/admin/flag`에 접근하면 flag를 얻을 수 있다. 근데 X-API-KEY가 필요하다. 

 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Confidential</title>
  <link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
  <div id="audio-warning">
    ⚠️ This experience contains sound. Please ensure your volume is adjusted appropriately.
  </div>

  <button id="start-btn">Begin Experience</button>

  <div id="dancer"></div>
  
  <img id="dancer-img" src="/static/ballerina-cappucina/ballerina-cappucina.png" alt="Ballerina" />

  <audio id="balletAudio" preload="auto">
    <source src="/static/ballerina-cappucina/ballerina-cappucina.mp3" type="audio/mpeg" />
  </audio>

  <script src="/static/js/main.min.js"></script>
</body>
</html>

templates 폴더의 confidential.html 파일이다. 밑에 script 태그가 눈에 띈다. 접근해보자.

 

/static/js/main.min.js

그럼 위와 같은 코드를 볼 수 있다.

 

function pingMailStatus() { fetch("/api/mail/status") } function fetchInboxPreview() { fetch("/api/mail/inbox?limit=5") } pingMailStatus(), fetchInboxPreview(), document.getElementById("start-btn")?.addEventListener("click", () => { document.getElementById("balletAudio").play(), document.getElementById("start-btn").style.display = "none", document.getElementById("audio-warning").style.display = "none"; let i = document.getElementById("dancer"), o = document.getElementById("dancer-img"), l = (i.style.display = "block", o.style.display = "block", 0), a = window.innerWidth / 2, d = window.innerHeight / 2; !function e() { l += .05; var t = a + 100 * Math.cos(l), n = d + 100 * Math.sin(l); i.style.left = t + "px", i.style.top = n + "px", o.style.left = t + "px", o.style.top = n + "px", requestAnimationFrame(e) }() });
//test map file -> test-main.min.js.map, remove in prod

맨 밑에 주석으로 테스트 파일이 있다는 것을 알려주고 있다. 똑같이 접근해보자.

 

{"version":3,"file":"main.min.js.map","sources":["main.js"],"sourcesContent":["function pingMailStatus() {\r\n  fetch(\"/api/mail/status\");\r\n}\r\n\r\nfunction fetchInboxPreview() {\r\n  fetch(\"/api/mail/inbox?limit=5\");\r\n}\r\n\r\npingMailStatus();\r\nfetchInboxPreview();\r\n\r\ndocument.getElementById(\"start-btn\")?.addEventListener(\"click\", () => {\r\n  const audio = document.getElementById(\"balletAudio\");\r\n  audio.play();\r\n\r\n  document.getElementById(\"start-btn\").style.display = \"none\";\r\n  document.getElementById(\"audio-warning\").style.display = \"none\";\r\n\r\n  const dancer = document.getElementById(\"dancer\");\r\n  const dancerImg = document.getElementById(\"dancer-img\"); // Get the image element\r\n\r\n  dancer.style.display = \"block\";\r\n  dancerImg.style.display = \"block\"; // Show the image\r\n\r\n  let angle = 0;\r\n  const radius = 100;\r\n  const centerX = window.innerWidth / 2;\r\n  const centerY = window.innerHeight / 2;\r\n\r\n  function animate() {\r\n    angle += 0.05;\r\n    const x = centerX + radius * Math.cos(angle);\r\n    const y = centerY + radius * Math.sin(angle);\r\n    dancer.style.left = x + \"px\";\r\n    dancer.style.top = y + \"px\";\r\n\r\n    dancerImg.style.left = x + \"px\"; // Sync image movement\r\n    dancerImg.style.top = y + \"px\";\r\n\r\n    requestAnimationFrame(animate);\r\n  }\r\n  animate();\r\n});\r\n\r\nfunction qyrbkc() { \r\n    const xtqzp = [\"85\"], vmsdj = [\"87\"], rlfka = [\"77\"], wfthn = [\"67\"], zdqo = [\"40\"], yclur = [\"82\"],\r\n          bpxmg = [\"82\"], hkfav = [\"70\"], oqzdu = [\"78\"], nwtjb = [\"39\"], sgfyk = [\"95\"], utxzr = [\"89\"],\r\n          jvmqa = [\"67\"], dpwls = [\"73\"], xaogc = [\"34\"], eqhvt = [\"68\"], mfzoj = [\"68\"], lbknc = [\"92\"],\r\n          zpeds = [\"84\"], cvnuy = [\"57\"], ktwfa = [\"70\"], xdglo = [\"87\"], fjyhr = [\"95\"], vtuze = [\"77\"], awphs = [\"75\"];\r\n        const dhgyvu = [xtqzp[0], vmsdj[0], rlfka[0], wfthn[0], zdqo[0], yclur[0], \r\n                    bpxmg[0], hkfav[0], oqzdu[0], nwtjb[0], sgfyk[0], utxzr[0], \r\n                    jvmqa[0], dpwls[0], xaogc[0], eqhvt[0], mfzoj[0], lbknc[0], \r\n                    zpeds[0], cvnuy[0], ktwfa[0], xdglo[0], fjyhr[0], vtuze[0], awphs[0]];\r\n\r\n    const lmsvdt = dhgyvu.map((pjgrx, fkhzu) =>\r\n        String.fromCharCode(\r\n            Number(pjgrx) ^ (fkhzu + 1) ^ 0 \r\n        )\r\n    ).reduce((qdmfo, lxzhs) => qdmfo + lxzhs, \"\"); \r\n    console.log(\"Note: Key is now secured with heavy obfuscation, should be safe to use in prod :)\");\r\n}\r\n\r\n"],"names":["pingMailStatus","fetch","fetchInboxPreview","qyrbkc","map","pjgrx","fkhzu","String","fromCharCode","Number","reduce","qdmfo","lxzhs","console","log","document","getElementById","addEventListener","play","style","display","dancer","dancerImg","angle","centerX","window","innerWidth","centerY","innerHeight","animate","x","Math","cos","y","sin","left","top","requestAnimationFrame"],"mappings":"AAAA,SAASA,iBACPC,MAAM,kBAAkB,CAC1B,CAEA,SAASC,oBACPD,MAAM,yBAAyB,CACjC,CAsCA,SAASE,SAKc,CAJJ,KAAgB,KAAgB,KAAgB,KAAe,KAAgB,KAC/E,KAAgB,KAAgB,KAAgB,KAAgB,KAAgB,KAChF,KAAgB,KAAgB,KAAgB,KAAgB,KAAgB,KAChF,KAAgB,KAAgB,KAAgB,KAAgB,KAAgB,KAAgB,MAMzFC,IAAI,CAACC,EAAOC,IAC9BC,OAAOC,aACHC,OAAOJ,CAAK,EAAKC,EAAQ,EAAK,CAClC,CACJ,EAAEI,OAAO,CAACC,EAAOC,IAAUD,EAAQC,EAAO,EAAE,EAC5CC,QAAQC,IAAI,mFAAmF,CACnG,CApDAd,eAAe,EACfE,kBAAkB,EAElBa,SAASC,eAAe,WAAW,GAAGC,iBAAiB,QAAS,KAChDF,SAASC,eAAe,aAAa,EAC7CE,KAAK,EAEXH,SAASC,eAAe,WAAW,EAAEG,MAAMC,QAAU,OACrDL,SAASC,eAAe,eAAe,EAAEG,MAAMC,QAAU,OAEzD,IAAMC,EAASN,SAASC,eAAe,QAAQ,EACzCM,EAAYP,SAASC,eAAe,YAAY,EAKlDO,GAHJF,EAAOF,MAAMC,QAAU,QACvBE,EAAUH,MAAMC,QAAU,QAEd,GAENI,EAAUC,OAAOC,WAAa,EAC9BC,EAAUF,OAAOG,YAAc,EAcrCC,CAZA,SAASA,IACPN,GAAS,IACT,IAAMO,EAAIN,EANG,IAMgBO,KAAKC,IAAIT,CAAK,EACrCU,EAAIN,EAPG,IAOgBI,KAAKG,IAAIX,CAAK,EAC3CF,EAAOF,MAAMgB,KAAOL,EAAI,KACxBT,EAAOF,MAAMiB,IAAMH,EAAI,KAEvBX,EAAUH,MAAMgB,KAAOL,EAAI,KAC3BR,EAAUH,MAAMiB,IAAMH,EAAI,KAE1BI,sBAAsBR,CAAO,CAC/B,EACQ,CACV,CAAC"}

 

그럼 파일이 다운로드 되는데 파일 내용은 위와 같다.

function pingMailStatus() {
  fetch("/api/mail/status");
}

function fetchInboxPreview() {
  fetch("/api/mail/inbox?limit=5");
}

pingMailStatus();
fetchInboxPreview();

document.getElementById("start-btn")?.addEventListener("click", () => {
  const audio = document.getElementById("balletAudio");
  audio.play();

  document.getElementById("start-btn").style.display = "none";
  document.getElementById("audio-warning").style.display = "none";

  const dancer = document.getElementById("dancer");
  const dancerImg = document.getElementById("dancer-img"); // Get the image element

  dancer.style.display = "block";
  dancerImg.style.display = "block"; // Show the image

  let angle = 0;
  const radius = 100;
  const centerX = window.innerWidth / 2;
  const centerY = window.innerHeight / 2;

  function animate() {
    angle += 0.05;
    const x = centerX + radius * Math.cos(angle);
    const y = centerY + radius * Math.sin(angle);
    dancer.style.left = x + "px";
    dancer.style.top = y + "px";

    dancerImg.style.left = x + "px"; // Sync image movement
    dancerImg.style.top = y + "px";

    requestAnimationFrame(animate);
  }
  animate();
});

function qyrbkc() { 
    const xtqzp = ["85"], vmsdj = ["87"], rlfka = ["77"], wfthn = ["67"], zdqo = ["40"], yclur = ["82"],
          bpxmg = ["82"], hkfav = ["70"], oqzdu = ["78"], nwtjb = ["39"], sgfyk = ["95"], utxzr = ["89"],
          jvmqa = ["67"], dpwls = ["73"], xaogc = ["34"], eqhvt = ["68"], mfzoj = ["68"], lbknc = ["92"],
          zpeds = ["84"], cvnuy = ["57"], ktwfa = ["70"], xdglo = ["87"], fjyhr = ["95"], vtuze = ["77"], awphs = ["75"];
        const dhgyvu = [xtqzp[0], vmsdj[0], rlfka[0], wfthn[0], zdqo[0], yclur[0], 
                    bpxmg[0], hkfav[0], oqzdu[0], nwtjb[0], sgfyk[0], utxzr[0], 
                    jvmqa[0], dpwls[0], xaogc[0], eqhvt[0], mfzoj[0], lbknc[0], 
                    zpeds[0], cvnuy[0], ktwfa[0], xdglo[0], fjyhr[0], vtuze[0], awphs[0]];

    const lmsvdt = dhgyvu.map((pjgrx, fkhzu) =>
        String.fromCharCode(
            Number(pjgrx) ^ (fkhzu + 1) ^ 0 
        )
    ).reduce((qdmfo, lxzhs) => qdmfo + lxzhs, ""); 
    console.log("Note: Key is now secured with heavy obfuscation, should be safe to use in prod :)");
}

보기 좋게 json 파서로 파싱하고 unescape 처리한 것이다. 

난독화되어 있는 qyrbkc 함수를 볼 수 있다. 근데 그 결과를 출력해주지 않고 있다. 그 부분만 수정해서 출력해보도록 하겠다.

function qyrbkc() { 
    const xtqzp = ["85"], vmsdj = ["87"], rlfka = ["77"], wfthn = ["67"], zdqo = ["40"], yclur = ["82"],
          bpxmg = ["82"], hkfav = ["70"], oqzdu = ["78"], nwtjb = ["39"], sgfyk = ["95"], utxzr = ["89"],
          jvmqa = ["67"], dpwls = ["73"], xaogc = ["34"], eqhvt = ["68"], mfzoj = ["68"], lbknc = ["92"],
          zpeds = ["84"], cvnuy = ["57"], ktwfa = ["70"], xdglo = ["87"], fjyhr = ["95"], vtuze = ["77"], awphs = ["75"];
        const dhgyvu = [xtqzp[0], vmsdj[0], rlfka[0], wfthn[0], zdqo[0], yclur[0], 
                    bpxmg[0], hkfav[0], oqzdu[0], nwtjb[0], sgfyk[0], utxzr[0], 
                    jvmqa[0], dpwls[0], xaogc[0], eqhvt[0], mfzoj[0], lbknc[0], 
                    zpeds[0], cvnuy[0], ktwfa[0], xdglo[0], fjyhr[0], vtuze[0], awphs[0]];

    const lmsvdt = dhgyvu.map((pjgrx, fkhzu) =>
        String.fromCharCode(
            Number(pjgrx) ^ (fkhzu + 1) ^ 0 
        )
    ).reduce((qdmfo, lxzhs) => qdmfo + lxzhs, ""); 
    console.log(lmsvdt);
}

위는 수정한 코드다.

 

Token

그럼 위와 같은 값을 얻을 수 있다. 이것이 토큰일 것이다.

 

curl -X POST -H "X-API-Key: TUNG-TUNG-TUNG-TUNG-SAHUR" https://web-mini-me-ab6d19a7ea6e.2025-us.ductf.net/admin/flag

 

flag

그럼 flag를 얻을 수 있다.

 

DUCTF{Cl13nt-S1d3-H4ck1nG-1s-FuN}

 

728x90
반응형

'분류 전 > CTF' 카테고리의 다른 글

[ShaktiCTF25] FRIENDS 풀이  (2) 2025.07.30
[DownUnderCTF 2025] rocky 풀이  (0) 2025.07.22
[L3ak CTF 2025] babyrev 풀이  (1) 2025.07.16
[L3ak CTF 2025] BrainCalc 풀이  (0) 2025.07.15
[L3ak CTF 2025] Flag L3ak 풀이  (0) 2025.07.15