I asked for grass but got the runaround.
Run a mile in ten minutes for the flag! Android-only this year. Please be safe---participation is at your own risk. If you have any health conditions that may be exacerbated by running, do not take part. Don't forget that you can always hack this challenge instead.
The app collects some data (like location) for purposes of the challenge. Your location data will not be shared.
Reading the code in browser:
class App {
sessionState = null;
flag = null;
id = null;
lat = null;
lon = null;
steps = 0;
permission = 'bad';
map;
contentDiv;
layers = [];
saveStateChange;
constructor(p, w, u, C) {
p !== null &&
(this.id = p.id, this.steps = p.steps),
this.map = w,
this.contentDiv = u,
this.saveStateChange = C
}
rerender(p) {
if (this.permission !== 'good') (this.permission = 'no-accelerometer') ? this.contentDiv.innerHTML = `
Accelerometer not found. Are you on Chrome Android?
` : this.contentDiv.innerHTML = `
Permissions required.
`;
else if (this.lat === null || this.lon === null) this.contentDiv.innerHTML = `
Awaiting location data...
`;
else if (
this.id === null ||
this.sessionState === null ||
this.sessionState.failed
) this.id === null ? this.contentDiv.innerHTML = `
<button class="start-button">
Start run
</button>
` : this.sessionState === null ? this.contentDiv.innerHTML = `
Logging in...
` : this.sessionState.failed &&
(
this.contentDiv.innerHTML = `
<div class="failed">
<div>
${ this.sessionState.reason }
</div>
<button class="restart-button">
Restart run
</button>
</div>
`
);
else {
const w = path(this.sessionState, [
this.lat,
this.lon
]),
u = Math.max(0, MAX_TIME * 1000 - (Date.now() - this.sessionState.start)),
C = Math.floor(u / 60000),
f = Math.floor(u % 60000 / 1000),
M = (length(w) * 0.0006213712).toPrecision(3);
this.contentDiv.innerHTML = `
<div class="status">
<div>
Remaining time
${ C }:${ f.toString().padStart(2, '0') }
</div>
<div>
Total distance
${ M } miles
</div>
<div>
Travelled
${ this.steps } steps
</div>
<button class="restart-button">
Restart run
</button>
</div>
`;
const mt = this.drawMap(leafletSrcExports.polyline(w, {
color: 'red',
weight: 12
}));
p &&
this.map.fitBounds(mt.getBounds())
}
this.flag !== null &&
(this.contentDiv.innerHTML += this.flag),
this.contentDiv.querySelector('.start-button') ?.addEventListener('click', async() => {
await this.start()
}),
this.contentDiv.querySelector('.restart-button') ?.addEventListener(
'click',
async() => {
this.saveStateChange(null),
await new Promise(w => setTimeout(w, 100)),
window.location.reload()
}
)
}
drawMap(p) {
return this.layers.forEach(w => w.remove()),
this.layers = [
p
],
p.addTo(this.map)
}
addSteps(p) {
this.sessionState &&
!this.sessionState.failed &&
(this.steps += p, this.broadcastSave(), this.rerender())
}
setPosition(p, w) {
this.lat = p,
this.lon = w,
this.rerender()
}
setPermission(p) {
this.permission = p,
this.rerender()
}
broadcastSave() {
this.id !== null &&
this.saveStateChange({
id: this.id,
steps: this.steps
})
}
async start() {
if (this.lat === null || this.lon === null) return;
const authCheck = await fetch('/oauth-check');
if (authCheck.status === 401) {
window.location.href = '/oauth';
return
}
const request = {
lat: this.lat,
lon: this.lon
},
res = await fetch(
'/start',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
}
);
if (res.status === 401) {
window.location.href = '/oauth';
return
}
const data = await res.text(),
response = eval(`(${ data })`);
this.id = response.id,
this.broadcastSave(),
this.sessionState = response.state,
this.steps = 0,
this.rerender(!0)
}
async login() {
if (
this.id === null ||
this.sessionState !== null ||
this.lat === null ||
this.lon === null
) return;
const p = {
id: this.id
},
u = await(
await fetch(
'/login',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(p)
}
)
).text(),
C = JSON.parse(u);
this.flag = C.flag ?? null,
this.rerender(),
await new Promise(f => setTimeout(f, 1000)),
this.sessionState = C.state,
this.rerender(!0),
this.sync()
}
async sync() {
if (
this.id === null ||
this.sessionState === null ||
this.lat === null ||
this.lon === null
) return;
const p = {
id: this.id,
lat: this.lat,
lon: this.lon
},
u = await(
await fetch(
'/update',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(p)
}
)
).text(),
C = JSON.parse(u);
this.sessionState = C.state,
this.flag = C.flag ?? null,
this.rerender()
}
}
It periodically reports your location to the server, and the server gives the flag back. I do not have Android phone, so I just wrote a Python script to fake it.
Steps:
/oauth
and grab cookie from the website/start
/update
, not too fast, not too slowIt appears that, after ~20 requests, the flag becomes:
"\n <img\n style=\"display: none\"\n id=\"asdf\"\n src=x onerror=eval(atob('CiAgd2luZG93LmFwcC5zeW5jID0gYXN5bmMgZnVuY3Rpb24gKCkgewogICAgaWYgKHdpbmRvdy5hcHAuaWQgPT09IG51bGwpIHJldHVybgogICAgaWYgKHdpbmRvdy5hcHAubGF0ID09PSBudWxsIHx8IHRoaXMubG9uID09PSBudWxsKSByZXR1cm4KCiAgICBjb25zdCByZXF1ZXN0ID0gewogICAgICBpZDogdGhpcy5pZCwKICAgICAgbGF0OiB0aGlzLmxhdCwKICAgICAgbG9uOiB0aGlzLmxvbiwKICAgICAgc3RlcHM6IHRoaXMuc3RlcHMsCiAgICB9CgogICAgY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goJy91cGRhdGUnLCB7CiAgICAgIG1ldGhvZDogJ1BPU1QnLAogICAgICBoZWFkZXJzOiB7CiAgICAgICAgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJywKICAgICAgfSwKICAgICAgYm9keTogSlNPTi5zdHJpbmdpZnkocmVxdWVzdCksCiAgICB9KQogICAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy50ZXh0KCkKICAgIGNvbnN0IHJlc3BvbnNlID0gSlNPTi5wYXJzZShkYXRhKQogICAgdGhpcy5zZXNzaW9uU3RhdGUgPSByZXNwb25zZS5zdGF0ZQogICAgdGhpcy5mbGFnID0gcmVzcG9uc2UuZmxhZyA/PyBudWxsCiAgICB0aGlzLnJlcmVuZGVyKCkKICB9Cg=='))\n >\n"
The decoded javascript is:
window.app.sync = async function () {
if (window.app.id === null) return
if (window.app.lat === null || this.lon === null) return
const request = {
id: this.id,
lat: this.lat,
lon: this.lon,
steps: this.steps,
}
const res = await fetch('/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
})
const data = await res.text()
const response = JSON.parse(data)
this.sessionState = response.state
this.flag = response.flag ?? null
this.rerender()
}
Therefore, we need to add steps
parameter if we see this. After ~290 requests, another new flag field appears:
"\n <img\n style=\"display: none\"\n id=\"asdf\"\n src=x onerror=eval(atob('CiAgd2luZG93LmFwcC5zeW5jID0gYXN5bmMgZnVuY3Rpb24gKCkgewogICAgaWYgKHdpbmRvdy5hcHAuaWQgPT09IG51bGwpIHJldHVybgogICAgaWYgKHdpbmRvdy5hcHAubGF0ID09PSBudWxsIHx8IHRoaXMubG9uID09PSBudWxsKSByZXR1cm4KICAgIGlmICh0aGlzLnBob3RvID09PSBudWxsKSByZXR1cm4KCiAgICBjb25zdCByZXF1ZXN0ID0gewogICAgICBpZDogdGhpcy5pZCwKICAgICAgbGF0OiB0aGlzLmxhdCwKICAgICAgbG9uOiB0aGlzLmxvbiwKICAgICAgcGhvdG86IHRoaXMucGhvdG8sCiAgICB9CgogICAgdGhpcy5mbGFnID0gJ0xvYWRpbmcuLi4nCiAgICB0aGlzLnJlcmVuZGVyKCkKCiAgICBjb25zdCByZXMgPSBhd2FpdCBmZXRjaCgnL3VwZGF0ZScsIHsKICAgICAgbWV0aG9kOiAnUE9TVCcsCiAgICAgIGhlYWRlcnM6IHsKICAgICAgICAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nLAogICAgICB9LAogICAgICBib2R5OiBKU09OLnN0cmluZ2lmeShyZXF1ZXN0KSwKICAgIH0pCiAgICBjb25zdCBkYXRhID0gYXdhaXQgcmVzLnRleHQoKQogICAgY29uc3QgcmVzcG9uc2UgPSBKU09OLnBhcnNlKGRhdGEpCiAgICB0aGlzLnNlc3Npb25TdGF0ZSA9IHJlc3BvbnNlLnN0YXRlCiAgICB0aGlzLmZsYWcgPSByZXNwb25zZS5mbGFnID8/IG51bGwKICAgIHRoaXMucGhvdG8gPSBudWxsCiAgICB0aGlzLnJlcmVuZGVyKCkKICB9CgogIHdpbmRvdy5hcHAucmVyZW5kZXIgPSBhc3luYyBmdW5jdGlvbiAoKSB7CiAgICBpZiAoIXRoaXMuaW1hZ2VDYXB0dXJlKSB7CiAgICAgIHRoaXMuY29udGVudERpdi5pbm5lckhUTUwgPSBgCiAgICAgICAgPGJ1dHRvbiBpZD0icGhvdG8tYnV0dG9uIj5FbmFibGUgYmFjayBjYW1lcmE8L2J1dHRvbj4KICAgICAgICAke3RoaXMuZmxhZyA/PyAnJ30KICAgICAgYAoKICAgICAgdGhpcy5jb250ZW50RGl2CiAgICAgICAgLnF1ZXJ5U2VsZWN0b3IoJyNwaG90by1idXR0b24nKQogICAgICAgIC5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIGFzeW5jICgpID0+IHsKICAgICAgICAgIGNvbnN0IHN0cmVhbSA9IGF3YWl0IG5hdmlnYXRvci5tZWRpYURldmljZXMuZ2V0VXNlck1lZGlhKHsKICAgICAgICAgICAgdmlkZW86IHsgZmFjaW5nTW9kZTogJ2Vudmlyb25tZW50JyB9LAogICAgICAgICAgfSkKICAgICAgICAgIHRoaXMuaW1hZ2VDYXB0dXJlID0gbmV3IEltYWdlQ2FwdHVyZSgKICAgICAgICAgICAgc3RyZWFtLmdldFZpZGVvVHJhY2tzKClbMF0sCiAgICAgICAgICApCiAgICAgICAgICB0aGlzLnJlcmVuZGVyKCkKICAgICAgICB9KQogICAgfSBlbHNlIHsKICAgICAgdGhpcy5jb250ZW50RGl2LmlubmVySFRNTCA9IGAKICAgICAgICA8YnV0dG9uIGlkPSJjYXB0dXJlLWJ1dHRvbiI+VGFrZSBwaG90byBmb3IgZmxhZzwvYnV0dG9uPgogICAgICAgIDxkaXY+CiAgICAgICAgICAgSU1QT1JUQU5UOiBhbnkgcGhvdG9zIHVwbG9hZGVkIHRvIHRoaXMgc2VydmljZSB3aWxsIGJlIHZpZXdlZCBieQogICAgICAgICAgIENURiBvcmdhbml6ZXJzIGFuZCBtYXkgYmUgc2hhcmVkIHdpdGggb3RoZXIgcGFydGljaXBhbnRzLiBBdm9pZAogICAgICAgICAgIHVwbG9hZGluZyBzZW5zaXRpdmUgb3IgcGVyc29uYWxseSBpZGVudGlmaWFibGUgaW5mb3JtYXRpb24uCiAgICAgICAgPC9kaXY+CiAgICAgICAgPGJyPgogICAgICAgICR7dGhpcy5mbGFnID8/ICcnfQogICAgICBgCgogICAgICB0aGlzLmNvbnRlbnREaXYKICAgICAgICAucXVlcnlTZWxlY3RvcignI2NhcHR1cmUtYnV0dG9uJykKICAgICAgICAuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCBhc3luYyAoKSA9PiB7CiAgICAgICAgICBjb25zdCBibG9iID0gYXdhaXQgdGhpcy5pbWFnZUNhcHR1cmUudGFrZVBob3RvKHsKICAgICAgICAgICAgaW1hZ2VIZWlnaHQ6IDQ4MCwKICAgICAgICAgICAgaW1hZ2VXaWR0aDogNjQwLAogICAgICAgICAgfSkKICAgICAgICAgIGNvbnN0IHJlYWRlciA9IG5ldyBGaWxlUmVhZGVyKCkKICAgICAgICAgIHJlYWRlci5vbmxvYWRlbmQgPSAoKSA9PiB7CiAgICAgICAgICAgIHRoaXMucGhvdG8gPSByZWFkZXIucmVzdWx0CiAgICAgICAgICAgIHRoaXMuc3luYygpCiAgICAgICAgICB9CiAgICAgICAgICByZWFkZXIucmVhZEFzRGF0YVVSTChibG9iKQogICAgICAgIH0pCiAgICB9CiAgfQo='))\n >\n"
Base64 decoded:
window.app.sync = async function () {
if (window.app.id === null) return
if (window.app.lat === null || this.lon === null) return
if (this.photo === null) return
const request = {
id: this.id,
lat: this.lat,
lon: this.lon,
photo: this.photo,
}
this.flag = 'Loading...'
this.rerender()
const res = await fetch('/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
})
const data = await res.text()
const response = JSON.parse(data)
this.sessionState = response.state
this.flag = response.flag ?? null
this.photo = null
this.rerender()
}
window.app.rerender = async function () {
if (!this.imageCapture) {
this.contentDiv.innerHTML = `
<button id="photo-button">Enable back camera</button>
${this.flag ?? ''}
`
this.contentDiv
.querySelector('#photo-button')
.addEventListener('click', async () => {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
this.imageCapture = new ImageCapture(
stream.getVideoTracks()[0],
)
this.rerender()
})
} else {
this.contentDiv.innerHTML = `
<button id="capture-button">Take photo for flag</button>
<div>
IMPORTANT: any photos uploaded to this service will be viewed by
CTF organizers and may be shared with other participants. Avoid
uploading sensitive or personally identifiable information.
</div>
<br>
${this.flag ?? ''}
`
this.contentDiv
.querySelector('#capture-button')
.addEventListener('click', async () => {
const blob = await this.imageCapture.takePhoto({
imageHeight: 480,
imageWidth: 640,
})
const reader = new FileReader()
reader.onloadend = () => {
this.photo = reader.result
this.sync()
}
reader.readAsDataURL(blob)
})
}
}
This time, we need to pass a data url as the photo field. After that, we are able to get the real flag.
The attack script (the cookie value and the photo need to be replaced):
import requests
import time
import base64
r = requests.post(
"https://touch-grass-3.ctfi.ng/start",
headers={
# visit https://touch-grass-3.ctfi.ng/oauth and get cookie from browser
"Cookie": "connect.sid=REDACTED"
},
json={"lat": 0.0, "lon": 0.0},
)
print(r.text)
resp = r.json()
id = resp["id"]
log = open("log", "w")
has_steps = False
has_photo = False
for i in range(10000):
json = {
"id": id,
"lat": 5 * i / 100000, # speed
"lon": 0.0,
}
if has_steps and not has_photo:
# avoid "Not enough/many steps."
json["steps"] = i * 10
elif has_photo:
# some photo
json["photo"] = "data:image/jpeg;base64,REDACTED"
r = requests.post(
"https://touch-grass-3.ctfi.ng/update",
headers={
# visit https://touch-grass-3.ctfi.ng/oauth and get cookie from browser
"Cookie": "connect.sid=REDACTED"
},
json=json,
)
print(i, has_steps, has_photo)
print(r.text, file=log)
log.flush()
resp = r.json()
if "flag" in resp and "atob" in resp["flag"]:
b64 = resp["flag"].split("'")[1]
decoded = base64.b64decode(b64)
if b"photo" in decoded:
has_photo = True
elif b"steps" in decoded:
has_steps = True
else:
print(resp["flag"])
time.sleep(0.2)
Get flag: corctf{e8be77aaf9c6ac78876c}
.