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:
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 timerlet duration = 30 * 60let remaining = durationfunction 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 = 0function 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:
-
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.
-
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
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:
1 2 3 4 5 6 7
// validate email and mssvfunction 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
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.
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.
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
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 !?
1 2 3 4 5
let questions = [] // chứa danh sách câu hỏilet answers = [] // chứa câu trả lời của thí sinhlet timerlet duration = 30 * 60let remaining = durationMình đã tìm đoạn code nó render câu hỏi xem như thế nào và phát hiện:
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);
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:
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:
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:
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.
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âu4. 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 =))
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.
- 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.
- Sửa timer dưới client, kéo dài thời gian làm bài hơn 30p.
- Server ngoài trả câu hỏi ra thì trả luôn đáp án đúng xuống client.
- Input/button chỉ bị khóa bằng
disabled, màdisabledchỉ 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 ;-;
