> cừn.
HomeBlogNewsProjectsAbout
cừn.
~/blog
article.mdx

Lỗ hổng trong một cuộc thi trực tuyến của trường

Tôi đã lấy được đáp án của toàn bộ bài thi trước khi nộp bài !? (how)

16/11/2025cừn.11 phút2,005 từ

Helo mn, chuyện là gần đây tui tìm ra một thứ khá là thú vị trong một online contest của một khoa bên trường tôi (Nó đã kết thúc hồi 13/11/2025 rồi).

Cụ thể hơn, toi có tham gia một online contest với tên là "Sinh viên Trường ĐH KHTN tìm hiểu pháp luật 2025" như ảnh bên dưới.

Cũng có giải thưởng (tiền mặt đó), nhma ban đầu tôi không tham gia để lấy giải thường mà chỉ để tích lũy chuyên đề B thôi. Vì bên trường tôi yêu cầu sinh viên mỗi kì phải đi workshop hoặc tham gia các hoạt động gì đó của trường để tích lũy chuyên đề B, nếu không là sẽ bị trừ 20 đrl ;-;

Thế là tôi cũng tham gia. Với ý định là sẽ vừa làm vừa search để có thể làm đúng ít nhất 21/30 câu để tích cái chuyên đề B đó. Nhưng mà ngay từ lần đầu đăng nhập vào, tôi đã thấy nó có 1 cái gì đó khá là... lỏ lỏ. Và nó làm trí tò mò của tôi tăng cao =))))

Bạn có tin nếu tôi nhập như này nhma app vẫn nhận thông tin không =)))) Yes, nó nhận đấy =)) Tại sao thì một chút xuống dưới sẽ có lời giải đáp nhé. Sau đó tôi bấm "Bắt đầu làm bài"

Một phần thường vì mấy contest dạng này cũng không có quy mô quá lớn, thường tôi sẽ sài cái trò tạo 1 tài khoản clone (nếu không bắt xác thực email sinh viên) để login vào nhằm làm thử + check format đề để lúc nộp không biết bên đó có tiết lộ đáp án khi làm xong hay không (Chiêu này chắc không lạ với nhiều bạn). Rồi đi chụp, cap màn hình gửi cho mấy đứa khác làm, kiểu vậy.

Vì app dùng để thi là Google Apps Script, tôi đã mở F12 lên, sau một tí ngồi nghiên cứu và xem thử thì tôi tìm thấy 1 thứ khá là thú vị ở phần script gần cuối trang.

Đây là 1 đoạn script, ngay sau đó tôi đã copy nó vào VSCode để format lại và xem thử thì tôi nhận được kết quả như thế này:

javascript
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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
let questions = []
let answers = []
let timer
let duration = 30 * 60
let remaining = duration
function startQuiz() {
const name = document.getElementById('name').value.trim()
const studentId = document.getElementById('studentId').value.trim()
const faculty = document.getElementById('faculty').value.trim()
const email = document.getElementById('email').value.trim()
const phone = document.getElementById('phone').value.trim()
if (!name || !studentId || !faculty || !email || !phone) {
alert('Vui lòng nhập đầy đủ thông tin.')
return
}
document.getElementById('startBtn').disabled = true
const inputs = document.querySelectorAll(
'#name, #studentId, #faculty, #email, #phone',
)
inputs.forEach((input) => (input.disabled = true))
document.getElementById('loadingOverlay').classList.remove('hidden')
google.script.run
.withFailureHandler((e) => {
alert(e.message)
document.getElementById('startBtn').disabled = false
document.getElementById('loadingOverlay').classList.add('hidden')
})
.withSuccessHandler(() => {
google.script.run
.withSuccessHandler((qs) => {
renderQuestions(qs)
startCountdown()
document.getElementById('startBtn').style.display = 'none'
document.getElementById('submitBtn').style.display = 'block'
document.getElementById('timer').style.display = 'block'
document.getElementById('loadingOverlay').classList.add('hidden')
})
.getRandomQuestions(30)
})
.logStartTime({
studentId,
name,
faculty,
email,
phone,
})
}
function renderQuestions(qs) {
questions = qs
answers = new Array(qs.length).fill(null)
const quizDiv = document.getElementById('quiz')
quizDiv.innerHTML = ''
qs.forEach((q, idx) => {
const div = document.createElement('div')
div.className = 'question'
div.innerHTML = `
<p><strong>Câu ${idx + 1}:</strong> ${q.question}</p>
<div class="answers">
${['A', 'B', 'C', 'D']
.map(
(letter) => `
<label>
<input type="radio" name="q${idx}" value="${letter}" onchange="chooseAnswer(${idx}, '${letter}')">
<span>${letter}. ${q[letter]}</span>
</label>`,
)
.join('')}
</div>`
quizDiv.appendChild(div)
})
}
function chooseAnswer(idx, ans) {
answers[idx] = {
answer: ans,
question: questions[idx].question,
A: questions[idx].A,
B: questions[idx].B,
C: questions[idx].C,
D: questions[idx].D,
correct: questions[idx].correct,
}
renderQuestionMap() // 🟢 cập nhật lại màu câu đã chọn
}
let currentQuestion = 0
function renderQuestionMap() {
const map = document.getElementById('questionMap')
map.innerHTML = ''
const maxCells = 30
const total = Math.min(maxCells, questions.length)
for (let i = 0; i < total; i++) {
const btn = document.createElement('button')
btn.textContent = i + 1
if (answers[i] && answers[i].answer) btn.classList.add('answered')
if (i === currentQuestion) btn.classList.add('active')
btn.onclick = () => {
currentQuestion = i
const questionDivs = document.querySelectorAll('.question')
if (questionDivs[i]) {
questionDivs[i].scrollIntoView({ behavior: 'smooth', block: 'center' })
}
renderQuestionMap()
}
map.appendChild(btn)
}
}
function startCountdown() {
const timerDiv = document.getElementById('timer')
function updateTimer() {
const minutes = Math.floor(remaining / 60)
const seconds = remaining % 60
timerDiv.textContent = `⏰ Còn: ${minutes} phút ${seconds < 10 ? '0' + seconds : seconds} giây`
if (remaining <= 0) {
clearInterval(timer)
submitQuiz()
}
remaining--
}
updateTimer()
timer = setInterval(updateTimer, 1000)
}
function submitQuiz(force = false) {
const unanswered = answers.filter((a) => a === null).length
if (unanswered > 0 && !force) {
if (confirm(`Bạn còn ${unanswered} câu chưa chọn. Nộp luôn?`)) {
submitQuiz(true)
} else {
return
}
}
clearInterval(timer)
document.getElementById('submitBtn').disabled = true
const name = document.getElementById('name').value.trim()
const studentId = document.getElementById('studentId').value.trim()
const faculty = document.getElementById('faculty').value.trim()
const email = document.getElementById('email').value.trim()
const phone = document.getElementById('phone').value.trim()
google.script.run
.withSuccessHandler((result) => {
document.getElementById('quiz').style.display = 'none'
document.getElementById('questionMap').style.display = 'none'
document.getElementById('timer').style.display = 'none'
document.getElementById('startBtn').style.display = 'none'
document.getElementById('submitBtn').style.display = 'none'
document
.querySelectorAll('.input-group')
.forEach((el) => (el.style.display = 'none'))
showPopup(`
✅ Bài đã nộp thành công!<br><br>
<strong>Họ và tên:</strong> ${name}<br>
<strong>MSSV:</strong> ${studentId}<br>
<strong>Điểm:</strong> ${result.score} / ${result.total}
`)
})
.saveToSheet({
name: name,
studentId: studentId,
faculty: faculty,
email: email,
phone: phone,
answers: answers,
})
}
function showPopup(message) {
document.getElementById('popupMessage').innerHTML = message
document.getElementById('popup').classList.remove('hidden')
}
function closePopup() {
document.getElementById('popup').classList.add('hidden')
}

Đây là một đoạn script xử lý toàn bộ logic bài thi trắc nghiệm: kiểm tra thông tin sinh viên, lấy câu hỏi từ server, hiển thị câu hỏi và bản đồ câu hỏi, đếm ngược thời gian, ghi nhận đáp án, xác nhận nộp bài khi còn câu chưa làm và gửi kết quả về server để tính điểm. Nếu bạn lười thì bạn có thể mang nó lên mấy con AI như ChatGPT hay Gemini nó giải thích cho, hoặc là đọc luôn cũng được vì flow hoạt động khá dể hiểu.

Trước khi đi vào mấy chỗ lỏ, mình nói sơ qua kiến trúc của cái app này một chút.

Vì nó được viết bằng Google Apps Script, nên đại khái nó sẽ có 2 phần:

  1. Server-side code (Code.gs)

    • Chạy trên server của Google
    • Browser không xem trực tiếp được source code phần này
    • Thường dùng để đọc/ghi Google Sheet, lấy câu hỏi, lưu bài làm, tính điểm, check thời gian, check MSSV đã làm hay chưa, v.v.
  2. Client-side code (HTML/JavaScript)

    • Chạy trong browser của người dùng
    • Có thể xem bằng DevTools/F12
    • Dùng để render giao diện, nhận input, gọi hàm server thông qua google.script.run

Điểm quan trọng nằm ở chỗ này: bất cứ thứ gì nằm ở client-side thì người dùng đều có thể xem, sửa, gọi lại, hoặc bypass. Cho nên nếu logic quan trọng bị đặt ở client, hoặc server tin dữ liệu client gửi lên quá nhiều, thì app sẽ rất dễ toang.

Đó là lý do có câu nói: Never trust the client.

Và tôi sẽ bắt đầu moi ra các điểm khá là lỏ trong đoạn script này:

1. Quản lí thông tin đăng nhập đầu vào

javascript
1
2
3
4
5
6
7
8
9
10
const name = document.getElementById('name').value.trim();
const studentId = document.getElementById('studentId').value.trim();
const faculty = document.getElementById('faculty').value.trim();
const email = document.getElementById('email').value.trim();
const phone = document.getElementById('phone').value.trim();
if (!name || !studentId || !faculty || !email || !phone) {
alert('Vui lòng nhập đầy đủ thông tin.');
return;
}

Đây chính là lý do tại sao tôi nhập thông tin như này nhma app vẫn nhận thông tin =)) Vì app validate khá là cùi mía, nó chỉ check xem là thông tin có rỗng hay không chứ không phải là check đúng format email sinh viên hay MSSV có đủ 8 chữ số chẳng hạn.

Ví dụ nên check:

javascript
1
2
3
4
5
6
7
// validate email and mssv
function checkValidate(email, mssv) {
const mssvRegex = /^\d{8}$/;
if (!mssvRegex.test(mssv)) return false; // mssv không hợp lệ
const expectedEmail = `${mssv}@student.hcmus.edu.vn`;
return email.toLowerCase() === expectedEmail.toLowerCase();
}

=> Chỉ cần bạn nhập đầy đủ thông tin là được. Không quan trọng nó là thông tin gì (@gmail.com hay mail ảo dạng temp mail hay dãy số xàm nào đó cũng sẽ được nhận). Và đương nhiên là lúc mà xét duyệt thì sẽ không hợp lệ (vì nhập dô xàm xí mà :v)

Nhưng chỗ nguy hiểm hơn không chỉ nằm ở format email hay MSSV. Vấn đề lớn hơn là app gần như không xác thực danh tính người làm bài. Như tôi đã test, phần mssv chỉ có thể nhập duy nhất 1 lần để làm bài với đúng mã số sinh viên đó. Và không biết bên khoa tổ chức contest này dùng gì để đảm bảo mssv đó thuộc về đúng người đã làm bài thi nhỉ ??? =))) Câu trả lời là không có.

Vì MSSV chỉ được dùng 1 lần trong bài thi + không xác thực danh tính người thi. Bạn chỉ cần nhập 1 mssv nào đó của 1 sinh viên trong trường và bùm, bạn sẽ thi với danh tính của người đó luôn. Đương nhiên đến lúc xét duyệt giải thưởng hoặc chuyên đề B sẽ xét cho cái MSSV kia chứ éo phải mình =)). Nhưng mà cái mssv này sẽ không thể dùng được nữa trong cuộc thi (bạn có thể xem MSSV là primary key luôn). Đây là 1 cách phá cho người khác không được tham gia =))))) mă (đừng nói toi phá nhé, tui chỉ test 1 vài mã số "ngẫu nhiên" tí thôi 🐧). Maybe là 80% tụi nó thi cái này để tích chuyên đề B, đỡ phải đi workshop (ngồi nghe thuyết trình gần 3 4 tiếng phiền chết được).

Factos: vì cái db của contest này được lưu bằng google sheet, nên chắc có đứa nào báo là mssv bị sử dụng rồi dù nó chưa từng thi thì chắc là bên ban tổ chức sẽ xóa cái MSSV đó trong google sheet rồi báo nó làm lại hả 🐧🐧🐧

Tóm lại: Thiếu một lớp xác thực để chứng minh người nhập MSSV thật sự là chủ của MSSV đó.

2. Sửa đổi các thiết lập thời gian đầu vào

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let timer;
let duration = 30 * 60;
let remaining = duration;
// ...
function startCountdown() {
const timerDiv = document.getElementById('timer')
function updateTimer() {
const minutes = Math.floor(remaining / 60)
const seconds = remaining % 60
timerDiv.textContent = `⏰ Còn: ${minutes} phút ${seconds < 10 ? '0' + seconds : seconds} giây`
if (remaining <= 0) {
clearInterval(timer)
submitQuiz()
}
remaining--
}
updateTimer()
timer = setInterval(updateTimer, 1000)
}

Điều gì sẽ xảy ra nếu bạn sửa đổi các thiết lập đầu vào này bên console =)))

Bạn chỉ cần qua tab console, sửa remaining = 9999999 và bấm enter. Và bạn sẽ thấy được điều gì =)))

Bạn sẽ có thời gian vô hạn =))))

Cách hoạt động của cái đồng hồ đếm ngược thời gian thi trên là dùng biến remaining lưu số giây còn lại, và cứ mỗi giây setInterval sẽ giảm nó đi 1. Khi remaining về 0, tự động gọi submitQuiz(). mọi biến JavaScript chạy trong browser đều nằm trong bộ nhớ RAM mà người dùng có thể can thiệp trực tiếp qua DevTools Console.

Vì vậy khi gõ remaining = 9999999 là đang gán lại giá trị cho phần thời gian còn lại, đồng hồ sẽ nhảy lên mấy trăm ngàn phút gì đấy, nói chung là dư dả thời gian.

Cái này không phải sửa text html bằng F12 đâu nhé =))))

Bạn có thể dùng luôn clearInterval(timer) để dừng thời gian lại luôn, hàm updateTimer() sẽ không bao giờ được gọi nữa. Vô hạn Rồi.

javascript
1
clearInterval(timer)

Tuy nhiên vì server sẽ nhận request và tự ghi lại thời điểm lúc lấy câu hỏi (bằng new Date() hoặc tương tự) vào Google Sheet hoặc một nơi lưu trữ, kèm theo thông tin sinh viên. Client không gửi timestamp lên mà server tự ghi nhận ngay lúc nhận request nên sửa đổi thời gian này chỉ giúp bạn kéo dài remaining ra để thi chứ thời gian làm bài thực tế vẫn sẽ được lưu.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.logStartTime({
studentId,
name,
faculty,
email,
phone,
})
.saveToSheet({
studentId,
name,
faculty,
email,
phone
})

Nhma lúc check kết quả thì nếu so thời gian làm bài với nhau thì đứa LIÊM sẽ dưới 30p, còn đứa LIẾM trên cả trăm phút thì =))))

Nhưng chưa chắc dưới 30p là LIÊM đâu =))) phần dưới là lỗ hổng khủng nhất của bài.

3. Lấy bộ câu hỏi (cả đáp án) hiện tại của bài làm

javascript
1
2
3
4
5
6
7
8
9
10
11
12
function chooseAnswer(idx, ans) {
answers[idx] = {
answer: ans,
question: questions[idx].question,
A: questions[idx].A,
B: questions[idx].B,
C: questions[idx].C,
D: questions[idx].D,
correct: questions[idx].correct
};
renderQuestionMap(); // 🟢 cập nhật lại màu câu đã chọn
}

Đoạn code này sẽ chạy khi thí sinh chọn 1 đáp án, bất ngờ là nó có cả đáp án đúng (correct) được gán là questions[idx].correct. Mà questions chính là biến let questions = [] được khai báo ở đầu script ta đã thấy. Tức là lúc này questions chứa bộ câu hỏi và chứa cả đáp án !?

javascript
1
2
3
4
5
let questions = [] // chứa danh sách câu hỏi
let answers = [] // chứa câu trả lời của thí sinh
let timer
let duration = 30 * 60
let remaining = duration

Mình đã tìm đoạn code nó render câu hỏi xem như thế nào và phát hiện:

javascript
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
function renderQuestions(qs) {
questions = qs
answers = new Array(qs.length).fill(null)
const quizDiv = document.getElementById('quiz')
quizDiv.innerHTML = ''
qs.forEach((q, idx) => {
const div = document.createElement('div')
div.className = 'question'
div.innerHTML = `
<p><strong>Câu ${idx + 1}:</strong> ${q.question}</p>
<div class="answers">
${['A', 'B', 'C', 'D']
.map(
(letter) => `
<label>
<input type="radio" name="q${idx}" value="${letter}" onchange="chooseAnswer(${idx}, '${letter}')">
<span>${letter}. ${q[letter]}</span>
</label>`,
)
.join('')}
</div>`
quizDiv.appendChild(div)
})
}

Ở đây questions nhận giá trị qs được truyền từ bên ngoài vào hàm. Sau khi tìm kiếm, mình cũng biết được qs là danh sách các câu hỏi được google.script.run trả về thông qua renderQuestions(qs) và getRandomQuestions(30);

javascript
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
google.script.run
.withFailureHandler(e => {
alert(e.message);
document.getElementById('startBtn').disabled = false;
document.getElementById('loadingOverlay').classList.add('hidden');
})
.withSuccessHandler(() => {
google.script.run
.withSuccessHandler(qs => {
renderQuestions(qs);
startCountdown();
document.getElementById('startBtn').style.display = 'none';
document.getElementById('submitBtn').style.display = 'block';
document.getElementById('timer').style.display = 'block';
document.getElementById('loadingOverlay').classList.add('hidden');
})
.getRandomQuestions(30);
})
.logStartTime({
studentId,
name,
faculty,
email,
phone
});

Tức là: Server sẽ trả về 30 câu hỏi random cho mỗi thí sinh, và mỗi câu hỏi trả về đều có kèm đáp án đúng trong đó. Và BTC trả thẳng toàn bộ cả đáp án xuống client !?

Vì lúc này biến questions đã chứa hết 30 câu hỏi cho thí sinh, bạn chỉ việc qua console và:

1 Object của 1 câu hỏi gồm:

javascript
1
2
3
4
5
6
7
8
{
"A": "Hai người.",
"B": "Một người.",
"C": "Tùy thuộc vào tải trọng của xe.",
"D": "Không có đáp án nào đúng.",
"correct": "B",
"question": "Người điều khiển xe đạp, xe đạp máy được phép chở mấy người (trừ trường hợp chở thêm một trẻ em dưới 07 tuổi)?"
}

=))))))))))) Vậy là bạn đã có tất cả đáp án của bài thi mà chưa cần nộp bài. Để format đẹp hơn, mình in nó dưới dạng table:

javascript
1
2
3
4
5
6
console.table(questions.map((q, i) => ({
'Câu': i + 1,
'Câu hỏi': q.question,
'Đáp án đúng': q.correct,
'Nội dung đáp án': q[q.correct]
})));

Cứ thế, bạn có thể dò từng câu với phần đáp án trả về đó và làm tiếp thôi =))) Còn muốn nhanh hơn thì viết hẳn 1 đoạn code automation để điền toàn bộ trong 1s luôn:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
questions.forEach((q, idx) => {
answers[idx] = {
answer: q.correct,
question: q.question,
A: q.A,
B: q.B,
C: q.C,
D: q.D,
correct: q.correct
};
});
renderQuestionMap();
setTimeout(() => submitQuiz(true), 1000);

Và bạn là người chiến thắng cuộc thi vì trả lời nhanh nhất và chính xác nhất (🐧)

Không có nhé, tôi ko lấy giải thưởng, chỉ tích chuyên đề B thôi 🐧🐧🐧

À ngoài ra ở đoạn code trên, bạn có thể dùng gọi lại server để lấy thêm bộ câu hỏi kèm đáp án, vì mỗi thí sinh lúc vào thi sẽ được server random cho 30 câu hỏi thôi.

javascript
1
2
3
4
5
6
7
8
9
10
11
google.script.run
.withSuccessHandler(qs => {
console.log(qs.map((q, i) => ({
'Câu': i + 1,
'Câu hỏi': q.question,
'Đáp án đúng': q.correct,
'Nội dung đáp án': q[q.correct]
})));
window.newSet = qs;
})
.getRandomQuestions(50); // lấy hẳn 50 câu

4. Disabled phần thông tin

Cả button nộp bài cũng như thông tin đăng nhập đầu vào đều chỉ bị block = disabled Bạn có thể sửa lại chúng bên console hoặc vào tab html xóa disabled luôn, lúc này bạn có thể sửa lại thông tin bình thường và nộp bài bình thường.

Kể cả lúc nộp bài lần 1 rồi, bạn cũng có thể mở lại button nộp bài, sửa câu hỏi và nộp lại được luôn =)) Vì sau khi nộp thì web ko có log out ra ngay mà vẫn hiện như vậy =))

javascript
1
document.getElementById('name').disabled = false;

Chắc end ở đây dc rùi, cuộc thi này dù sao cũng đã kết thúc nên các submit hiện tại cũng không còn hợp lệ nữa.

Tóm lại, đây là một lỗ hổng khá là lớn tôi đã phát hiện được ở online contest này. Ban tổ chức quăng hết đáp án dưới client rồi.

  1. Không xác thực danh tính người làm bài, chỉ cần nhập đủ thông tin là vào được.
  2. Sửa timer dưới client, kéo dài thời gian làm bài hơn 30p.
  3. Server ngoài trả câu hỏi ra thì trả luôn đáp án đúng xuống client.
  4. Input/button chỉ bị khóa bằng disabled, mà disabled chỉ là UI state. Nộp lại bài thi hơn 1 lần.

Cuoi cung thi toi cung da tich luy duoc 1 cai chuyen de B roi, con 1 cai nua nha ;-;

Thanks for your reading!

16/11/2025

Cừn.

Chia sẻ bài viết

FacebookLinkedInX
$cd ..