CTF入門したのでwriteup書いてみた

[iRidge Advent Calendar 2020](https://qiita.com/advent-calendar/2020/iridge) 4日目の記事です。

2020年はCTFを始めました。と言っても始めたのは10月頃なのですが、今でも常設問題をちょこちょこ解いています。

今回は印象に残っている問題のWriteupを残しておこうと思います。自分用の忘備録ですが、あわよくばやったことない人向けに面白さが伝わればいいなとも。

# picoCTF [Web Exploitation] Java Script Kiddie

picoCTFというのはカーネギーメロン大学のセキュリティ専門チームによって作成されている常設CTFサイトです。中高生向けの教育コンテンツらしいのですが、秒で解けるものから中高生時代の自分だったら絶対解けないだろうなというものまで、数多くの問題が揃っています。

その中のWeb Exploitationというwebサービスを題材にしたジャンルの、Java Script Kiddieという問題について取り上げます。興味ある人はぜひ先に解いてみてください。(アカウント作成が必要になります)

[Java Script Kiddie \- picoGym](https://play.picoctf.org/practice?category=1&page=1)　

## 問題

![スクリーンショット 2020-11-30 16.50.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93202/9518511c-3b31-9c08-8692-9d98efc28529.png)

用意されているリンクを踏むと入力フォーム一つが置かれたシンプルなサイトにアクセスします。適当に入力してsubmitすると表示の崩れた画像が表示されます。

![スクリーンショット 2020-11-30 16.55.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93202/2e4cb64b-8c72-d283-349f-5f08dd6a7ccd.png)

正しい文字列を入力したらちゃんとした画像が表示されるので、その文字列を探していきます。

## 解答解説

### ソースコードの解説

とりあえずソースを確認します。すると以下のようなjsがあります。

```html

<script src="jquery-3.3.1.min.js"></script>
<script>
  // 1
  var bytes = []; 
  $.get("bytes", function(resp) {
    bytes = Array.from(resp.split(" "), x => Number(x));
  });

  function assemble_png(u_in){
    var LEN = 16; 
    var key = "0000000000000000";
    var shifter;
    if(u_in.length == LEN){
      key = u_in;
    }
    // 2
    var result = [];
    for(var i = 0; i < LEN; i++){
      shifter = key.charCodeAt(i) - 48;
      for(var j = 0; j < (bytes.length / LEN); j ++){
        result[(j * LEN) + i] = bytes[(((j + shifter) * LEN) % bytes.length) + i]
      }
    }
    while(result[result.length-1] == 0){
      result = result.slice(0,result.length-1);
    }
    // 3
    document.getElementById("Area").src = "data:image/png;base64," + btoa(String.fromCharCode.apply(null, new Uint8Array(result)));
    return false;
  }
</script>
```

大まかに説明すると以下のようなことをしています。

1.カレントディレクトリにbytesというファイルがあり、中身の数字を取得してスペース区切りで配列に置き換る

```text
155 130 147 244 175 67 0 0 217 92 31 42 174 9 0 78 245 80 191 36 249 87 26 10 243 52 73 52 66 68 68 63 175 0 142 121 58 183 1 114 0 215 0 19 255 0 95 127 51 0 0 223 229 42 68 65 0 0 0 106 78 72 138 66 216 16 78 63 206 127 47 219 0 44 0 112 0 192 194 0 239 55 1 199 116 59 4 212 1 206 156 145 73 77 150 82 160 73 0 243 0 220 133 234 84 0 248 69 0 205 81 108 96 38 70 38 13 0 239 194 144 0 139 0 154 238 121 219 137 127 144 0 0 10 172 7 75 0 217 13 210 65 99 28 0 127 102 71 137 0 230 59 133 0 231 0 139 222 26 229 164 3 222 114 69 73 252 71 193 120 96 237 241 235 30 191 64 249 141 2 226 2 85 205 24 3 68 40 119 0 134 231 41 70 162 95 198 3 253 39 73 67 126 47 6 46 216 253 144 109 141 142 198 35 159 172 235 108 182 111 45 63 13 152 93 136 179 136 114 47 194 224 172 121 126 157 62 250 178 202 157 206 214 115 231 90 172 20 235 207 127 118 231 224 186 151 175 57 129 183 73 55 227 252 199 210 190 54 222 4 10 66 77 151 56 30 158 143 113 236 36 113 178 99 107 22 220 2 108 74 191 36 9 178 213 16 204 125 246 30 122 135 123 55 166 252 151 187 51 210 120 6 70 113 113 91 215 158 63 9 119 37 155 67 198 13 137 247 231 249 201 227 187 13 37 18 34 182 245 112 31 75 223 209 164 235 48 195 91 207 78 252 253 255 159 155 207 205 26 162 194 52 249 111 36 227 62 211 128 234 59 137 56 213 150 231 166 124 104 223 26 244 106 16 140 53 152 207 176 91 157 253 78 233 54 126 241 160 9 161 116 29 202 137 245 126 65 60 9 236 204 157 69 110 30 245 239 226 57 135 151 223 107 82 62 4 239 183 48 78 5 194 204 226 146 79 169 197 126 91 215 223 165 55 201 174 46 68 33 82 51 143 30 228 227 243 73 130 186 15 249 126 76 235 59 215 87 217 156 90 235 231 238 96 231 129 122 4 223 231 220 144 215 41 114 223 237 254 190 124 120 243 101 204 213 52 55 94 14 122 151 17 88 23 145 243 172 129 114 230 206 55 67 90 151 148 149 155 54 161 227 3 126 103 94 115 123 67 55 177 22 143 233 70 164 126 123 143 170 239 181 215 30 98 9 143 107 245 43 113 107 234 59 198 106 144 127 254 223 76 73 93 242 103 82 208 203 99 33 249 181 146 249 251 23 186 108 63 111 210 174 194 54 198 204 242 246 125 228 127 242 60 208 196 135 132 52 78 238 84 59 76 94 233 114 255 194 141 40 213 111 239 144 131 72 232 49 246 246 223 36 99 223 159 63 105 121 125 165 29 251 169 227 36 201 187 59 255 90 254 126 118 106 239 165 74 241 65 153 186 119 250 216 160 42 75 71 3 235 173 64 85 191 154 229 231 16 61 243 238 171 135 44 231 127 149 227 108 179 63 101 242 128 245 15 170 174 249 190 78 167 72 92 165 239 188 42 121 141 253 178 77 110 215 127 83 191 63 73 18 0 0 111 78 77 227 148 60 174 127
```


2.bytesの配列を並び替えたものをresultに入れる

3.resultの値からBase64でエンコードされた文字列を生成し画像のデータを表示

2の部分に関してもう少し詳しく書きます。

```javascript

shifter = key.charCodeAt(i) - 48;
```

上記の部分は引数で渡した位置にある文字のASCIIコード表で対応する数値を返します。例えば`"a".charCodeAt(0)`の場合、aに割り当てられている「97(10進数)」になります。この48というのは0を表すのに割り当てられている数値になります。
要するにこの部分ではkey = '135' だった場合、
i=0の時は key.charCodeAt(0)は 1 に対する49になり、49-48で shifter=1
i=1の時は key.charCodeAt(1)は 3 に対する51になり、51-48で shifter=3
i=2の時は key.charCodeAt(2)は 5 に対する53になり、53-48で shifter=5 
と言った具合にshfterにkeyのi番目の数字がそのまま入るようになっています。

```javascript

for(var j = 0; j < (bytes.length / LEN); j ++){
  result[(j * LEN) + i] = bytes[(((j + shifter) * LEN) % bytes.length) + i]
}
```

上記の部分は、文章での説明が難しいので具体例から出してしまうと以下のような処理になっています。

i = 0, j=0, shifter=0の時
result[(0 * 16) + 0] = bytes[(((0 + 0) * 16) % 704) + 0]
result[0] = bytes[(0 % 704) + 0] 
result[0] = bytes[0]  →  shifterが0なのでシフトが起きず、bytesの値がそのままresultに入る

i = 0, j=0, shifter=1の時
result[(0 * 16) + 0] = bytes[(((0 + 1) * 16) % 704) + 0]
result[0] = bytes[(16 % 704) + 0] 
result[0] = bytes[16]  → shifterが1につき16個ずれた値が入る

i=0, j=1, shifter=0の時
result[(1 * 16) + 0] = bytes[(((1 + 0) * 16) % 704) + 0]
result[16] = bytes[(16 % 704) + 0] 
result[16] = bytes[16] → jが増えるごとにresult[j * 16]にbytes[16の倍数]の数字が渡される

このような形でshifterが働いて、画像のデータの元となる配列が生成されていきます。

全部で16桁分のshifterとなるkeyを求めないといけないのですが、幸いにも総当たりをしなくていいようになっています。

`src = "data:image/png;base64,"`の部分から分かるように、画像のフォーマットはpngなのでpngのファイルフォーマットに合わせる形にしてあげればいいです。そしてそのファイルフォーマットの先頭16文字は値が決まっています。

参考: [PNG ファイルフォーマット](https://www.setsuki.com/hsp/ext/png.htm)

* 先頭8バイト: PNG ファイルシグネチャ。pngであることを示すもの
* 残りの8バイト: IHDRのイメージヘッダ
    * 最初の4バイト: Chunk Data のサイズ。常に0xd(10進数の13)になる
    * 残りの4バイト: Chunk Type

bytesを16列の二次元配列に並び替え、各桁の先頭の文字がpngフォーマットになるにはいくつシフトさせればいいのかを探します。

### 問題を解くためのコード
#### key値を求める

```python

import pprint

b = "155 130 147 244 175 67 0 0 217 92 31 42 174 9 0 78 245 80 191 36 249 87 26 10 243 52 73 52 66 68 68 63 175 0 142 121 58 183 1 114 0 215 0 19 255 0 95 127 51 0 0 223 229 42 68 65 0 0 0 106 78 72 138 66 216 16 78 63 206 127 47 219 0 44 0 112 0 192 194 0 239 55 1 199 116 59 4 212 1 206 156 145 73 77 150 82 160 73 0 243 0 220 133 234 84 0 248 69 0 205 81 108 96 38 70 38 13 0 239 194 144 0 139 0 154 238 121 219 137 127 144 0 0 10 172 7 75 0 217 13 210 65 99 28 0 127 102 71 137 0 230 59 133 0 231 0 139 222 26 229 164 3 222 114 69 73 252 71 193 120 96 237 241 235 30 191 64 249 141 2 226 2 85 205 24 3 68 40 119 0 134 231 41 70 162 95 198 3 253 39 73 67 126 47 6 46 216 253 144 109 141 142 198 35 159 172 235 108 182 111 45 63 13 152 93 136 179 136 114 47 194 224 172 121 126 157 62 250 178 202 157 206 214 115 231 90 172 20 235 207 127 118 231 224 186 151 175 57 129 183 73 55 227 252 199 210 190 54 222 4 10 66 77 151 56 30 158 143 113 236 36 113 178 99 107 22 220 2 108 74 191 36 9 178 213 16 204 125 246 30 122 135 123 55 166 252 151 187 51 210 120 6 70 113 113 91 215 158 63 9 119 37 155 67 198 13 137 247 231 249 201 227 187 13 37 18 34 182 245 112 31 75 223 209 164 235 48 195 91 207 78 252 253 255 159 155 207 205 26 162 194 52 249 111 36 227 62 211 128 234 59 137 56 213 150 231 166 124 104 223 26 244 106 16 140 53 152 207 176 91 157 253 78 233 54 126 241 160 9 161 116 29 202 137 245 126 65 60 9 236 204 157 69 110 30 245 239 226 57 135 151 223 107 82 62 4 239 183 48 78 5 194 204 226 146 79 169 197 126 91 215 223 165 55 201 174 46 68 33 82 51 143 30 228 227 243 73 130 186 15 249 126 76 235 59 215 87 217 156 90 235 231 238 96 231 129 122 4 223 231 220 144 215 41 114 223 237 254 190 124 120 243 101 204 213 52 55 94 14 122 151 17 88 23 145 243 172 129 114 230 206 55 67 90 151 148 149 155 54 161 227 3 126 103 94 115 123 67 55 177 22 143 233 70 164 126 123 143 170 239 181 215 30 98 9 143 107 245 43 113 107 234 59 198 106 144 127 254 223 76 73 93 242 103 82 208 203 99 33 249 181 146 249 251 23 186 108 63 111 210 174 194 54 198 204 242 246 125 228 127 242 60 208 196 135 132 52 78 238 84 59 76 94 233 114 255 194 141 40 213 111 239 144 131 72 232 49 246 246 223 36 99 223 159 63 105 121 125 165 29 251 169 227 36 201 187 59 255 90 254 126 118 106 239 165 74 241 65 153 186 119 250 216 160 42 75 71 3 235 173 64 85 191 154 229 231 16 61 243 238 171 135 44 231 127 149 227 108 179 63 101 242 128 245 15 170 174 249 190 78 167 72 92 165 239 188 42 121 141 253 178 77 110 215 127 83 191 63 73 18 0 0 111 78 77 227 148 60 174 127"
bytes = list(map(int, b.split(" ")))
png_fmt = list(
    map(lambda x: int(x, 16), "89 50 4E 47 0D 0A 1A 0A 0 0 0 D 49 48 44 52".split(" "))
)
LEN = 16
shifter = []

# shifterの候補を探す
candidate_shifter = []
for i in range(LEN):
    num = []
    for j in range(len(bytes)):
        if bytes[j] != png_fmt[i] or j % LEN != i: 
            continue
        s = (j - i) // LEN
        if s < 10:
            num.append(s)
    candidate_shifter.append(num)
print("candidate shifter:")
pprint.pprint(candidate_shifter)
```

これで出力されたキーを入力して画像が表示されればクリアだったのですが、問題がありました。

一桁に一つ以上キーとなる可能性のある数字が含まれているケースがあるみたいです。(ちなみにこの問題はご丁寧なことにbytesの値が人によって異なっているみたいで、一つしか候補がなくそのまま答えにたどり着ける人もいた)

```
$ python js_kiddie.py
[[8],
 [1],
 [4],
 [9],
 [7],
 [8],
 [1],
 [1],
 [2, 3, 4],
 [3, 6, 7, 8, 9],
 [2, 3, 4],
 [8],
 [5],
 [3],
 [1],
 [5]]
```
45パターンあるみたいなので、全パターン出していきます。

#### [以下延長戦] 正解となる入力値の候補を出す

```python

candidate_keys = []
for i, shifters in enumerate(candidate_shifter):
    keys = []
    for shifter in shifters:
        if i == 0:
            keys.append(str(shifter))
            continue
        for j, v in enumerate(candidate_keys):
            keys.append(v + str(shifter))
    candidate_keys = keys

print("candidate keys:")
pprint.pprint(candidate_keys)
```

```
candidate keys:
['8149781123285315', '8149781133285315', '8149781143285315', '8149781126285315', '8149781136285315', '8149781146285315', '8149781127285315', '8149781137285315', '8149781147285315', '8149781128285315', '8149781138285315', '8149781148285315', '8149781129285315', '8149781139285315', '8149781149285315', '8149781123385315', '8149781133385315', '8149781143385315', '8149781126385315', '8149781136385315', '8149781146385315', '8149781127385315', '8149781137385315', '8149781147385315', '8149781128385315', '8149781138385315', '8149781148385315', '8149781129385315', '8149781139385315', '8149781149385315', '8149781123485315', '8149781133485315', '8149781143485315', '8149781126485315', '8149781136485315', '8149781146485315', '8149781127485315', '8149781137485315', '8149781147485315', '8149781128485315', '8149781138485315', '8149781148485315', '8149781129485315', '8149781139485315', '8149781149485315']
```

答えの候補45通り出たので、これら全部を入力フォームに入れていきます。流石に手でやるのはしんどいのでSelenium入門しました。

#### Seleniumで答えの候補を入力していく

```python
import time
from selenium import webdriver
from selenium.webdriver.common.keys import Keys

driver = webdriver.Chrome("/usr/local/bin/chromedriver")
driver.get("https://jupiter.challenges.picoctf.org/problem/42101")
for k in candidate_keys:
    elem = driver.find_element_by_id("user_in")
    elem.clear()
    elem.send_keys(k)
    elem.send_keys(Keys.RETURN)
    time.sleep(1)
    img = driver.find_element_by_id("Area")
    if img.get_attribute("naturalWidth") != "0":
      print(k)
      break
```
答えが表示されたらそこで止まるようにしました。

壊れている画像と正常な画像をどう判別したらいいものか悩んだのですが、`naturalWidth`という画像の本来の大きさを表示する属性があり、それを使ってできそうだなと思ったのですがこれだとうまくいきません。

というのも、画像が何も表示されない状態で止まるケースがあったからです。bytesをシフトした結果フォーマットの15/16バイトくらい合っていると一応画像としての形は成すようで、6つほどサイズを持った透明な画像が表示されていました。

では透明な画像とQRコード(他の人のWriteupを既に読んでいる状態なので表示されることは知っていた)をどう見分ければいいのか。結局読み込めるか否かだろうと思い、最後までコード化することになりました。

#### 画像から答えを得る

先ほどのコードの続きにQRコード読み取る処理を追記します。

```python
import time
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from pyzbar.pyzbar import decode
from PIL import Image

driver = webdriver.Chrome("/usr/local/bin/chromedriver")
driver.get("https://jupiter.challenges.picoctf.org/problem/42101")
for k in candidate_keys:
    elem = driver.find_element_by_id("user_in")
    elem.clear()
    elem.send_keys(k)
    elem.send_keys(Keys.RETURN)
    time.sleep(1)
    img = driver.find_element_by_id("Area")
    if img.get_attribute("naturalWidth") != "0":
        # 以下追記部分
        png = img.screenshot_as_png
        file_name = f'/tmp/{k}.png'
        with open(file_name, 'wb') as f:
            f.write(png)
        data = decode(Image.open(file_name))
        if not data:
            continue
        code = data[0][0].decode('utf-8', 'ignore')
        print(code)

```

[ZBar](http://zbar.sourceforge.net/)というQRコードを読み取れるライブラリがありました。それのPython版を使っています。

ちなみにデコードしたdataの中身は以下のようになっていました。

```
[Decoded(data=b'picoCTF{xxx}', type='QRCODE', rect=Rect(left=39, top=39, width=292, height=292), polygon=[Point(x=39, y=39), Point(x=39, y=330), Point(x=331, y=331), Point(x=330, y=39)])]
```

ここまでやってようやくフラグを取得することができました...!

正直ここまでやるつもりはなかったけど、PythonでQRコード読み込ませる処理は書いたことがなかったので良い収穫でした。

## おわりに

この問題が特に印象に残っていた理由としては、いろんな解説サイトを読み漁っても結局自分で丁寧に考えていかないと理解と答えにたどり着けなかった(中にはgif作ってまで丁寧に解説してくれているサイトもあったのに)ことや、文字コードやpngフォーマットなど初めてコードで取り扱ったこと、seleniumとZBar入門したことなど1問での学びが多かったからかもしれません。

この調子で他のサイトの問題やコンテストにも挑戦できたらいいなと思っています。