Phân tích phần mềm thi trực tuyến ở Đại học FPT

Dạo gần đây mình thấy có nhiều bạn chia sẻ bài viết trên Viblo: Tôi đã hack hệ thống điểm danh của trường mình như thế nào?. Mình cũng định viết bài này từ lâu rồi, cơ mà mình muốn chờ một thời gian sau khi rời trường vì mình không muốn liên lụy với phía nhà trường. Phía nhà trường cũng không dùng phần mềm FPT-Exam nữa nên mình không nghĩ bài viết của mình sẽ có ảnh hưởng gì.

Chú thích

Bài viết này phản ánh quá trình reverse engineering phần mềm FPT-Exam vào khoảng thời gian mình còn học ở trường đại học FPT (tháng 9-12 năm 2018) nên có nhiều thông tin ở đây sẽ không còn đúng nữa (ví dụ trong kì xuân năm 2020 trường không sử dụng phần mềm FPT-Exam nữa

Motivation

Dự định của mình sau khi tốt nghiệp THPT là đi du học, tuy nhiên sau khi tốn thời gian vô bổ vào VOI và ViSEF thì mình quyết định dành một năm để chuẩn bị cho việc du học (nói cao cả là thế, chứ mình cần thời gian để ôn thi SAT và TOEFL). Trong thời gian đó mình có học một kì tại trường đại học FPT vì:

Một trong những điểm làm mình tò mò là việc kì thi cuối kì được diễn ra sử dụng phần mềm FPT-Exam, chạy trên máy tính của học sinh. Mình tò mò không biết là phần mềm này có những biện pháp nào để chống các hành vi gian lận, vì mình không tin có một giải pháp phần mềm nào có thể chống 100% các hành vi gian lận. Nhưng động lực lớn hơn của mình là vì mình dùng Linux, và mình không chạy được phần mềm thi trên Linux (sử dụng Wine). Cộng với việc mình Ở đây thì học sinh làm bài kiểm tra cuối kì trên máy tính, sử dụng phần mềm FPT-Exam. Mình lại dùng Linux và FPT-Exam chỉ chạy trên Windows, và khi mình chạy trên Linux, sử dụng Wine thì không chạy được. Vì chương trình năm đầu cũng khá dễ, và mình cũng chẳng có việc gì để làm, và có vẻ như phần mềm FPT-Exam được viết bằng ngôn ngữ C#, một ngôn ngữ khá dễ để decompile, nên mình quyết định reverse engineer phần mềm này, với mục tiêu cuối cùng là viết lại một phần mềm thi đa nền tảng.

Và đây là thành quả của mình: https://gitlab.com/tuankiet65/fpt-exam-qt5. Sử dụng phần mềm này, mình đã thi thành công 5/5 môn học trong kì học duy nhất của mình mà không bị lập biên bản. À thật ra là có, mình bị lập biên bản về việc sử dụng Google sau khi nộp bài nhưng chưa rời khỏi phòng thi.

FPT-Exam

Mọi người có thể tải phần mềm FPT-Exam để tự nghiên cứu ở đây:

Bài phân tích này sẽ dự trên phiên bản ngày 05/12/2018 vì mình không decompile phiên bản ngày 10/12/2018 được.

Giao diện phần mềm FPT-Exam
Giao diện phần mềm FPT-Exam

Nhìn qua các file trong phần mềm, chúng ta có thể thấy ngay đây là một phần mềm viết bằng C# vì sự xuất hiện của thư viện CefSharp, cho phép nhúng trình duyệt Chromium vào một ứng dụng C#. May thay các ứng dụng C# khá dễ để decompile nên chúng ta có thể truy ngược lại mã nguồn từ file executable (nếu chương trình không bị obfuscated). Ban đầu mình sử dụng ILSpy để decompile, tuy nhiên sau đó mình chuyển sang dùng dnSpy vì dnSpy còn cho phép mình trực tiếp thay đổi mã nguồn chương trình.

Kết quả sau khi sử dụng dnSpy để truy ngược mã nguồn:

Uh oh, có vẻ như mã nguồn đã bị obfuscated, một điều không ngạc nhiên cho lắm. Lẽ ra ngay lúc này mình đã có thể dùng một deobfuscator nhưng mình lại quyết định trace bằng tay bắt đầu từ hàm Main(), và mình không khuyến khích các bạn làm vậy :(.

Sau khoảng vài ngày trace bằng tay trong vô vọng thì mình chuyển sang bắt gói tin bằng Wireshark. Vì chức năng duy nhất của phần mềm thi là mở một trang web Moodle để dự thi, nên nếu mình có thể bắt được yêu cầu HTTP thì mình có thể thi trên một trình duyệt khác. Không may thay khi Wireshark đang bắt gói thì phần mềm thi sẽ báo lỗi và không khởi động, có thể đây là một biện pháp để chống bắt gói. Sau một thời gian thì mình nhận ra Wireshark có thể dùng hai backend khác nhau trên Windows để bắt gói: winpcapnpcap. Có vẻ như phần mềm thi chỉ phát hiện được winpcap chứ không phát hiện được npcap, nên mình chuyển sang dùng npcap và có thể bắt gói được.

Sau khi bắt gói thì mình đã tìm ra domain của trang thi dựa theo yêu cầu DNS, tuy nhiên khi thử đăng nhập bằng Google Chrome thì mình không thể đăng nhập được. Có thể là trang web thi chỉ cho phép đăng nhập khi User-Agent của người dùng trùng với một xâu nào đó, hoặc chỉ khi có một cookie nào đó, nhưng mình không thể coi được yêu cầu HTTP vì trang web thi sử dụng HTTPS. Mình nhận ra certificate của trang web thi không hợp lệ, nên vào lúc đó mình có thể sử dụng MITMproxy để MITM các yêu cầu HTTPS. Mình không nhớ là mình đã thử dụng MITMproxy chưa, nhưng những lần sử dụng MITMproxy trước không làm mình có thiện cảm với nó lắm :(

Thêm một vài ngày nữa và mình tìm ra de4dot:

Voià, much better! Now we actuall have something we can work with.

Việc đầu tiên cần làm là tìm entry point, hàm đầu tiên trong chương trình được chạy. Mọi chương trình C# đều có entry point là hàm Main(), nên chúng ta có thể chạy grep để tìm mọi hàm có tên là Main:

$ grep -r Main
[...]
ns0/Class6.cs:    private static void Main()

Chúng ta hãy xem định nghĩa của class ns0.Class6:

using System;
using System.Windows.Forms;
using TestMetro;

namespace ns0 {
    internal static class Class6 {
        [STAThread]
        private static void Main() {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new Form1());
        }
    }
}

Hàm Main() này khá khiêm tốn, nhiệm vụ duy nhất của nó là tạo một Form, hay một cửa sổ chương trình và chạy nó. Ở đây thì TestMetro.Form1 là cửa sổ sẽ được chạy, nên chúng ta sẽ phân tích cửa sổ này.

Theo lifecycle của form thì method form1_load sẽ được chạy để khởi tạo các thành phần của form:

// Token: 0x06000028 RID: 40 RVA: 0x00003348 File Offset: 0x00001548
private void Form1_Load(object sender, EventArgs e)
{
    Form1.Class3 @class = new Form1.Class3();
    @class.form1_0 = this;
    Form1.DisableTask(0);
    base.BringToFront();
    this._XYZ += this.lblinfoIPMac.Tag.ToString();
    this.method_14("cmd.exe", "net stop npf");
    Form1._Model = Form1.ExecuteCommandAsAdmin("systeminfo | find \"System\"");
    if (Form1._Model.ToString().ToUpper().Contains("VIRTUAL") || Form1._Model.ToString().Contains("Virtual") || Form1._Model.ToString().Contains("VMware") || Form1._Model.ToString().ToUpper().Contains("VMWARE"))
    {
        if (MessageBox.Show("Chương trình không hoạt động trên máy ảo?", "Confirm", MessageBoxButtons.OK) == DialogResult.OK)
        {
            this.int_2 = 1;
        }
        this.int_2 = 1;
        Environment.Exit(0);
        Application.Exit();
    }
    if (!Environment.OSVersion.ToString().ToUpper().Contains("MICROSOFT WINDOWS NT"))
    {
        if (MessageBox.Show("Chương trình chỉ hoạt động trên windows 7, 8, 10?", "Confirm", MessageBoxButtons.OK) == DialogResult.OK)
        {
            this.int_2 = 1;
        }
        this.int_2 = 1;
        Environment.Exit(0);
        Application.Exit();
    }
    this.method_13("cmd.exe", "netsh advfirewall reset");
    this.FirewallDefault();
    try
    {
        Class7.smethod_0("10.9.9.12", "ExamClient", "FPTDN", this._XYZ);
        if (Class7.string_1 != "")
        {
            MessageBox.Show(Class7.string_1 + " Không kết nối được hệ thống!");
            this.int_2 = 1;
            Environment.Exit(0);
            Application.Exit();
            return;
        }
        Form1.Write_Log("Confirm: Model" + Form1._Model.ToString());
        Form1.Write_Log("Confirm: System" + Environment.OSVersion.ToString());
        Form1.Write_Log("Confirm: Connected!");
        string str = Class10.smethod_9();
        Form1.Write_Log("Start: " + str);
        DataTable dataTable = new DataTable();
        try
        {
            dataTable = Class7.smethod_1("SP_SelectExamConfig", new object[0]);
        }
        catch (Exception ex)
        {
            MessageBox.Show(ex.ToString());
            this.int_2 = 1;
            Environment.Exit(0);
            Application.Exit();
        }
        if (dataTable.Columns.Count < 3)
        {
            Form1.Write_Log("Info: IP address is wrong!");
            MessageBox.Show(dataTable.Rows[0][1].ToString());
            this.int_2 = 1;
            Environment.Exit(0);
            Application.Exit();
        }
        this.int_6 = int.Parse(dataTable.Rows[20][1].ToString());
        bool flag;
        if (bool.TryParse(dataTable.Rows[18][1].ToString(), out flag))
        {
            base.TopMost = flag;
        }
        int fileExc;
        int fileLocales;
        int length;
        if (int.TryParse(dataTable.Rows[22][1].ToString(), out fileExc) && int.TryParse(dataTable.Rows[23][1].ToString(), out fileLocales) && int.TryParse(dataTable.Rows[24][1].ToString(), out length))
        {
            this.FileExc = fileExc;
            this.FileLocales = fileLocales;
            this.Length = length;
        }
        else
        {
            this.FileExc = 0;
            this.FileLocales = 0;
            this.Length = 0;
        }
        if (bool.TryParse(dataTable.Rows[21][1].ToString(), out flag))
        {
            this._moveTF = flag;
        }
        else
        {
            this._moveTF = false;
        }
        if (bool.TryParse(dataTable.Rows[16][1].ToString(), out flag))
        {
            this.bool_0 = flag;
        }
        else
        {
            this.bool_0 = false;
        }
        if (bool.TryParse(dataTable.Rows[17][1].ToString(), out flag))
        {
            MouseHook.CheckkillP = flag;
        }
        else
        {
            MouseHook.CheckkillP = false;
        }
        this.string_8 = dataTable.Rows[0][1].ToString() + "." + dataTable.Rows[1][1].ToString() + ".";
        this.string_7 = dataTable.Rows[12][1].ToString();
        this.string_2 = dataTable.Rows[7][1].ToString().Trim();
        this.string_3 = dataTable.Rows[8][1].ToString().Trim();
        this.string_1 = dataTable.Rows[6][1].ToString().Trim();
        this.string_0 = "Phatdt";
        string b = dataTable.Rows[11][2].ToString();
        if (this.string_9 != b)
        {
            Form1.Write_Log("Confirm: Version is old!");
            MessageBox.Show("Phiên bản của bạn đã quá cũ! Vui lòng cập nhật phiên bản mới.\nThis version of FPT-Exam is very old! Please upgrade!");
            this.int_2 = 1;
            Environment.Exit(0);
            Application.Exit();
        }
    }
    catch (Exception ex2)
    {
        MessageBox.Show(ex2.ToString());
        this.int_2 = 1;
        Environment.Exit(0);
        Application.Exit();
    }
    Form1.interface0_0 = new Interface0[]
    {
        new Class17(),
        new Class12(),
        new Class13(),
        new Class14()
    };
    Form1.class20_0 = Form1.smethod_1<Class20>("Win32_ComputerSystem");
    Form1.class19_0 = Form1.smethod_1<Class19>("Win32_BIOS");
    Form1.class22_0 = Form1.smethod_1<Class22>("Win32_MotherboardDevice");
    Form1.ienumerable_1 = Form1.smethod_2<Class23>("Win32_PnPEntity");
    Form1.ienumerable_0 = Form1.smethod_2<Class21>("Win32_DiskDrive");
    Form1.ienumerable_2 = Form1.smethod_0();
    try
    {
        string str2 = "";
        if (ExamMachine.Assert())
        {
            Form1.Write_Log(" VIRTUAL MACHINE DETECTED !");
            string text = "";
            if (ExamMachine.Assert(out str2))
            {
                text += str2;
            }
            text += "\n==================================";
            text = string.Concat(new object[]
            {
                text,
                "MOTHERBOARD INFO: ",
                Form1.class22_0,
                "\n"
            });
            text = string.Concat(new object[]
            {
                text,
                "BIOS INFO: ",
                Form1.class19_0,
                "\n"
            });
            text = string.Concat(new object[]
            {
                text,
                "COMPUTER INFO: ",
                Form1.class20_0,
                "\n"
            });
            foreach (Class23 class2 in Form1.ienumerable_1)
            {
                text = string.Concat(new object[]
                {
                    text,
                    "DEVICES INFO: ",
                    class2,
                    " "
                });
            }
            text += "\n";
            foreach (Class21 class3 in Form1.ienumerable_0)
            {
                text = string.Concat(new object[]
                {
                    text,
                    "HARD DRIVES INFO: ",
                    class3,
                    " "
                });
            }
            text += "\n";
            foreach (Class0 class4 in Form1.ienumerable_2)
            {
                text = string.Concat(new object[]
                {
                    text,
                    "WINDOWS SERVICES: ",
                    class4,
                    " "
                });
            }
            text += "\n";
            Form1.Write_Log(" VIRTUAL MACHINE DETECTED !" + text);
        }
        else
        {
            Form1.Write_Log(" VIRTUAL MACHINE wasn't DETECTED !");
        }
    }
    catch (Exception ex3)
    {
        Form1.Write_Log(" VIRTUAL MACHINE wasn't DETECTED !" + ex3.ToString());
    }
    base.WindowState = FormWindowState.Normal;
    base.FormBorderStyle = FormBorderStyle.None;
    base.Bounds = Screen.PrimaryScreen.Bounds;
    this.InitBrowser();
    this.FirewallBlock();
    this.Firewall(Application.ExecutablePath, "EF", NET_FW_RULE_DIRECTION_.NET_FW_RULE_DIR_OUT);
    this.Firewall(Path.GetDirectoryName(Application.ExecutablePath) + "\\CefSharp.BrowserSubprocess.exe", "EF", NET_FW_RULE_DIRECTION_.NET_FW_RULE_DIR_OUT);
    this.firewallcheck();
    this.thread_1 = new Thread(new ThreadStart(@class.method_0));
    this.thread_1.IsBackground = true;
    this.thread_1.Start();
    this.thread_0 = new Thread(new ThreadStart(@class.method_1));
    this.thread_0.IsBackground = true;
    this.thread_0.Start();
    this.thread_2 = new Thread(new ThreadStart(@class.method_2));
    this.thread_2.IsBackground = true;
    this.thread_2.Start();
    this.thread_3 = new Thread(new ThreadStart(@class.method_3));
    this.thread_3.IsBackground = true;
    this.thread_3.Start();
    base.MaximizeBox = false;
    base.MinimizeBox = false;
    if (!FormInfo.DoGetHostAddresses(Environment.MachineName).ToString().Contains("10.82."))
    {
        this.lblinfoIPMac.Visible = true;
    }
    this.class5_0 = new Class5();
    this.class5_0.Event_0 += this.gHook_KeyDown;
    foreach (object obj in Enum.GetValues(typeof(Keys)))
    {
        Keys item = (Keys)obj;
        this.class5_0.list_0.Add(item);
    }
    this.class5_0.method_0();
    this.string_5 = WindowsIdentity.GetCurrent().Name;
    @class.intptr_0 = base.Handle;
    if (!Form1.SetWindowDisplayAffinity(@class.intptr_0, Form1.Enum0.const_1))
    {
        MessageBox.Show("Please, contact to ICT Department!", "Error: Monitor", MessageBoxButtons.OK, MessageBoxIcon.Hand);
        base.TopMost = true;
        Form1.Write_Log("Error: Monitor isn't set");
        this.int_2 = 1;
        Environment.Exit(0);
        Application.Exit();
    }
    else
    {
        Form1.Write_Log("Successful: Monitor was true!");
    }
    @class.intptr_0 = base.Handle;
    this.thread_5 = new Thread(new ThreadStart(@class.method_4));
    this.thread_5.IsBackground = true;
    this.thread_5.Start();
    DataTable dataTable2 = new DataTable();
    try
    {
        dataTable2 = Class7.smethod_1("SP_DMY", new object[0]);
    }
    catch (Exception ex4)
    {
        MessageBox.Show(ex4.ToString());
        this.int_2 = 1;
        Environment.Exit(0);
        Application.Exit();
    }
    int num = int.Parse(dataTable2.Rows[0][0].ToString());
    int num2 = int.Parse(dataTable2.Rows[0][1].ToString());
    int num3 = (num + num2) % 10;
    this.pictureBox2.Image = this.Base64ToImage(this.string_4[num3].ToString());
    Process currentProcess = Process.GetCurrentProcess();
    @class.string_0 = currentProcess.ProcessName;
    this.thread_4 = new Thread(new ThreadStart(@class.method_5));
    this.thread_4.IsBackground = true;
    this.thread_4.Start();
    this.thread_6 = new Thread(new ThreadStart(@class.method_6));
    this.thread_6.IsBackground = true;
    this.thread_6.Start();
}

Woah, nhiều code quá, nên chúng ta sẽ đi từ trên xuống dưới:

Sau đó

Có vẻ như sau đó nhà trường đã làm quyết liệt hơn trong việc bảo vệ phần mềm FPT-Exam. Mình không thể decompile phiên bản ngày 10/12/2018 (có thể là do de2dot của mình cũ?) và có một số phiên bản FPT-Exam sau đó được đóng gói bằng Themida nên không thể decompile trực tiếp được.