Опубликовав свой первый проект в Steam «любовался» достаточно неплохим количеством скачиваний. Только какой в этом толк, если вся эта движуха происходила на торрент-трекерах...

Поэтому всерьез задумался о защите своих коммерческих проектов от пиратов.

Конечно, универсального способа защиты от пиратов не существует и тема защиты от пиратства является как-никак актуальной: темой постоянных дискуссий и споров.

В рамках данной статьи рассмотрим вариант дополнительной защиты Unity-проекта (под Windows) с использованием библиотеки kernel32.dll. А использовать данный способ защиты в своем проекте, либо не использовать – решать вам. И так приступим.

На нулевой нашей сцене создадим объект с названием SecurityManager и повесим на него скрипт с названием Security.

Подключаем необходимые библиотеки:

using UnityEngine;
using System;
using System.Text;
using System.IO;

Объявим необходимые переменные.

private string sn = "";
public string folder = "/Data";
public string fileName = "Settings.dat";
public string code = "AG7XpyPfmwN28193";

Значение переменной code придумываем любое. Желательно с помощью генератора паролей.

Создаем функцию с именем DebugSave() и пишем следующий код.

private void DebugSave()
    {

        try //обработка исключений
        {

            sn = code;

            //кодируем в двоичный код
            byte[] buf = Encoding.UTF8.GetBytes(sn);
            StringBuilder sb = new StringBuilder(buf.Length * 8);
            foreach (byte b in buf)
            {
                sb.Append(Convert.ToString(b, 2).PadLeft(8, '0'));
            }
            string binaryStr = sb.ToString();


            //создаем папку в директории проекта
            Directory.CreateDirectory(Application.dataPath + folder);

            //сохраняем в файл
            using (var stream = File.Open(Application.dataPath + folder + "/" + fileName, FileMode.Create))
            {
                using (var writer = new BinaryWriter(stream, Encoding.UTF8, false))
                {
                    
                    writer.Write(binaryStr);
                    writer.Close();
                }
            }


        }
        catch
        {
            Debug.Log(message: "Ошибка чтения в файл"); //выводим сообщение об ошибке
        }

    }

И вызовем ее при старте сцены:

    void Start()
    {
        DebugSave();
    }

При выполнении функции DebugSave() в директории проекта создается папка с именем Data. В папке Data создается бинарный файл с именем Settings.dat. Значение переменной code кодируется в двоичный код и записывается в файл Settings.dat. Кодируем в двоичный код для того, чтобы обычный юзер не смог прочитать значение в файле с помощью блокнота

Теперь приступим к реализации непосредственно самой защиты. Код работы с библиотекой kernel32.dll подсмотрел ТУТ.

Конечно, данную защиту можно реализовать с помощью стандартной юнитовской команды SystemInfo.deviceUniqueIdentifier, но в случае апгрейда ПК (замены процессора, прошивки BIOS) наш проект будет ругаться о пиратстве. Нас такой расклад не устраивает.

Подключаем следующую библиотеку:

using System.Runtime.InteropServices;

И объявляем необходимые переменные:

[DllImport("kernel32.dll")]
private static extern long GetVolumeInformation(
string PathName,
StringBuilder VolumeNameBuffer,
UInt32 VolumeNameSize,
ref UInt32 VolumeSerialNumber,
ref UInt32 MaximumComponentLength,
ref UInt32 FileSystemFlags,
StringBuilder FileSystemNameBuffer,
UInt32 FileSystemNameSize);

public string disk; // задать в инспекторе  "C:\"

Для переменной disk в инспекторе указываем "C:\". В самом скрипте не получается – ругается IDE.

Создаем функцию с именем Getvolumeinformation() и пишем следующий код:

    private void Getvolumeinformation() //считываем системную информацию
    {
        string drive_letter = disk;
        drive_letter = drive_letter.Substring(0, 1) + ":\\";

        uint serial_number = 0;
        uint max_component_length = 0;
        StringBuilder sb_volume_name = new StringBuilder(256);
        UInt32 file_system_flags = new UInt32();
        StringBuilder sb_file_system_name = new StringBuilder(256);

        if (GetVolumeInformation(drive_letter, sb_volume_name,
            (UInt32)sb_volume_name.Capacity, ref serial_number,
            ref max_component_length, ref file_system_flags,
            sb_file_system_name,
            (UInt32)sb_file_system_name.Capacity) == 0)
        {
            Debug.Log(message: "Error getting volume information.");
        }
        else
        {
            sn = serial_number.ToString(); //серийный номер
            Debug.Log(message: sn);
        }
    }

Сразу добавим функцию, отвечающую за вывод системных сообщений:

public static class NativeWinAlert
    {
        [System.Runtime.InteropServices.DllImport("user32.dll")]
        private static extern System.IntPtr GetActiveWindow();

        public static System.IntPtr GetWindowHandle()
        {
            return GetActiveWindow();
        }

        [DllImport("user32.dll", SetLastError = true)]
        static extern int MessageBox(IntPtr hwnd, String lpText, String lpCaption, uint uType);

        /// <summary>
        /// Shows Error alert box with OK button.
        /// </summary>
        /// <param name="text">Main alert text / content.</param>
        /// <param name="caption">Message box title.</param>
        public static void Error(string text, string caption)
        {
            try
            {
                MessageBox(GetWindowHandle(), text, caption, (uint)(0x00000000L | 0x00000010L));
                Debug.Log("Игра закрылась");
                Application.Quit();    // закрыть приложение
            }
            catch (Exception ex) { }
        }
    }

Код функции, отвечающую за вывод системных сообщений, позаимствовал ТУТ.

Создаем функцию с именем Save() и пишем следующий код:

    private void Save()
    {

        try //обработка исключений
        {

            //кодируем в двоичный код
            byte[] buf = Encoding.UTF8.GetBytes(sn);
            StringBuilder sb = new StringBuilder(buf.Length * 8);
            foreach (byte b in buf)
            {
                sb.Append(Convert.ToString(b, 2).PadLeft(8, '0'));
            }
            string binaryStr = sb.ToString();

            //сохраняем в файл
            using (var stream = File.Open(Application.dataPath + folder + "/" + fileName, FileMode.Create))
            {
                using (var writer = new BinaryWriter(stream, Encoding.UTF8, false))
                {

                    writer.Write(binaryStr);
                    writer.Close(); //закрываем файл
                }
            }


        }
        catch
        {
            Debug.Log(message: "Ошибка чтения в файл"); //выводим сообщение об ошибке
        }

    }

Функция перевода двоичного когда в текст:

public static string BinaryToString(string data) 
    {
        List<Byte> byteList = new List<Byte>();

        for (int i = 0; i < data.Length; i += 8)
        {
            byteList.Add(Convert.ToByte(data.Substring(i, 8), 2));
        }

        return Encoding.ASCII.GetString(byteList.ToArray());
    }

Создаем функцию с именем Set() и пишем следующий код:

private void Set()
    {
        if (File.Exists(Application.dataPath + folder + "/" + fileName)) //проверяем наличие файла, если его нет выводим сообщение о приатстве.
        {
            using (var stream = File.Open(Application.dataPath + folder + "/" + fileName, FileMode.Open))
            {
                using (var reader = new BinaryReader(stream, Encoding.UTF8, false))
                {
                    string binaryStr = reader.ReadString();
                    reader.Close(); //закрываем файл

                    //двоичный код переобразовываем в строку
                    string resultText = BinaryToString(binaryStr);
                    
                    Getvolumeinformation(); //читаем серийный номер диска

                    if ((resultText == code) || (resultText == sn))
                    {
                        if (resultText == code)
                        {
                            Save();
                        }
                            

                    }
                    else
                    {
                        Debug.Log(message: "Пират!"); //выводим сообщение об ошибке
                        NativeWinAlert.Error("This copy of game is not genuine.", "Error");
                    }

                }
            }

        }
        else
        {
            Debug.Log(message: "Пират!"); //выводим сообщение об ошибке
            NativeWinAlert.Error("This copy of game is not genuine.", "Error");
        }

    }

При выполнении функции Set() вначале проверяем наличие файла Settings.dat, если его нет, то выводим сообщение о пиратстве (вызываем функцию NativeWinAlert). Если файл Settings.dat существует, читаем его, двоичный код преобразовываем в текст, считываем значение серийного номера тома. И выполняем очередную проверку, если значение не равняется значению переменной code или значению серийному номеру тома С, то выводим сообщение о пиратстве (вызываем функцию NativeWinAlert), в противном случае в файл Settings.dat записываем значение серийного номера тома С.

В функции Start() закоментируем вызов функции DebugSave() (она нужна только нам) и добавим вызов функции Set().

    void Start()
    {
        Set(); 
        //DebugSave(); //записываем в файл значение переменной code
    }

Теперь при первом запуске нашего проекта, значение в файле Settings.dat перезаписывается на значение серийного номера тома С. После данной процедуры копию проекта нельзя будет запустить на другой машине.

Исходный файл проекта располагается на сервисе GitHub по следующей ссылке.

Комментарии (24)


  1. Alexsey
    30.01.2022 04:38
    +5

    Все это бесполезно если не использовать IL2CPP. Две минуты и ваша защита уже отломана.

    Да и возникает вопрос вполне справедливых наездов пользователей из-за ложных срабатываний - переустановка или клонирование винды (а иногда и даже банальный апгрейд на новую версию десятки) практически наверняка закончится изменением серийника тома и отваливанием защиты.


    1. FranzDev Автор
      30.01.2022 05:21
      -1

      Насчёт шифрования это понятно. Меня вот мучает другой вопрос, как с помощью Unity выдрать серийный номер самого жёсткого диска? Т.к. с библиотекой System.Management Unity не умеет работать... :(


      1. horror_x
        30.01.2022 06:12
        +3

        Вряд ли поможет, но под админом можно спросить у диска напрямую:

        Скрытый текст
        [StructLayout(LayoutKind.Sequential, Size = 12)]
        private class DRIVERSTATUS
        {
        	public byte DriveError;
        	public byte IDEStatus;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
        	public byte[] Reserved;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
        	public int[] Reserved2;
        	public DRIVERSTATUS()
        	{
        		Reserved = new byte[2];
        		Reserved2 = new int[2];
        	}
        }
        
        [StructLayout(LayoutKind.Sequential)]
        private class IDSECTOR
        {
        	public short GenConfig;
        	public short NumberCylinders;
        	public short Reserved;
        	public short NumberHeads;
        	public short BytesPerTrack;
        	public short BytesPerSector;
        	public short SectorsPerTrack;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)]
        	public short[] VendorUnique;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 20)]
        	public char[] SerialNumber;
        	public short BufferClass;
        	public short BufferSize;
        	public short ECCSize;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
        	public char[] FirmwareRevision;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
        	public char[] ModelNumber;
        	public short MoreVendorUnique;
        	public short DoubleWordIO;
        	public short Capabilities;
        	public short Reserved1;
        	public short PIOTiming;
        	public short DMATiming;
        	public short BS;
        	public short NumberCurrentCyls;
        	public short NumberCurrentHeads;
        	public short NumberCurrentSectorsPerTrack;
        	public int CurrentSectorCapacity;
        	public short MultipleSectorCapacity;
        	public short MultipleSectorStuff;
        	public int TotalAddressableSectors;
        	public short SingleWordDMA;
        	public short MultiWordDMA;
        	[MarshalAs(UnmanagedType.ByValArray, SizeConst = 382)]
        	public byte[] Reserved2;
        	public IDSECTOR()
        	{
        		VendorUnique = new short[3];
        		Reserved2 = new byte[382];
        		FirmwareRevision = new char[8];
        		SerialNumber = new char[20];
        		ModelNumber = new char[40];
        	}
        }
        
        [StructLayout(LayoutKind.Sequential)]
        private class SENDCMDOUTPARAMS
        {
        	public int BufferSize;
        	public DRIVERSTATUS Status;
        	public IDSECTOR IDS;
        	public SENDCMDOUTPARAMS()
        	{
        		Status = new DRIVERSTATUS();
        		IDS = new IDSECTOR();
        	}
        }
        
        [DllImport("kernel32.dll")]
        private static extern int CloseHandle(int hObject);
        
        [DllImport("kernel32.dll")]
        private static extern int CreateFile(
        	string lpFileName,
        	uint dwDesiredAccess,
        	int dwShareMode,
        	int lpSecurityAttributes,
        	int dwCreationDisposition,
        	int dwFlagsAndAttributes,
        	int hTemplateFile
        );
        
        [DllImport("kernel32.dll")]
        private static extern int DeviceIoControl(
        	int hDevice,
        	int dwIoControlCode,
        	[In(), Out()] SENDCMDINPARAMS lpInBuffer,
        	int lpInBufferSize,
        	[In(), Out()] SENDCMDOUTPARAMS lpOutBuffer,
        	int lpOutBufferSize,
        	ref int lpBytesReturned,
        	int lpOverlapped
        );
        
        private const int OPEN_EXISTING = 3;
        
        private const uint GENERIC_READ = 0x80000000;
        private const uint GENERIC_WRITE = 0x40000000;
        
        private const int FILE_SHARE_READ = 0x1;
        private const int FILE_SHARE_WRITE = 0x2;
        
        private const int INVALID_HANDLE_VALUE = -1;
        
        private const int DFP_RECEIVE_DRIVE_DATA = 0x7C088;
        
        public static string getSerial()
        {
        	int returnSize = 0;
        	int driveNumber = 0;
        	SENDCMDINPARAMS sci = new SENDCMDINPARAMS();
        	SENDCMDOUTPARAMS sco = new SENDCMDOUTPARAMS();
        
        	if (System.Environment.OSVersion.Platform != PlatformID.Win32NT)
        	{
        		return null;
        	}
        
        	int handle = CreateFile(@"\\.\PhysicalDrive0", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, 0, OPEN_EXISTING, 0, 0);
        	if (handle == INVALID_HANDLE_VALUE)
        	{
        		return null;
        	}
        
        	sci.DriveNumber = (byte)driveNumber;
        	sci.BufferSize = Marshal.SizeOf(sco);
        	sci.DriveRegs.DriveHead = (byte)(0xA0 | driveNumber << 4);
        	sci.DriveRegs.Command = 0xEC;
        	sci.DriveRegs.SectorCount = 1;
        	sci.DriveRegs.SectorNumber = 1;
        
        	var result = DeviceIoControl(handle, DFP_RECEIVE_DRIVE_DATA, sci, Marshal.SizeOf(sci), sco, Marshal.SizeOf(sco), ref returnSize, 0);
        	CloseHandle(handle);
        
        	if (result != 0)
        	{
        		// Fix byte order
        		char[] buf = new char[sco.IDS.SerialNumber.Length];
        		buf[sco.IDS.SerialNumber.Length - 1] = sco.IDS.SerialNumber[sco.IDS.SerialNumber.Length - 1];
        		for (int i = 0; i < sco.IDS.SerialNumber.Length; i += 2)
        		{
        			buf[i] = sco.IDS.SerialNumber[i + 1];
        			buf[i + 1] = sco.IDS.SerialNumber[i];
        		}
        
        		return new string(buf);
        	}
        
        	return null;
        }
        


        1. FranzDev Автор
          30.01.2022 23:07

          Получилось решить данную задачу чуть-чуть проще: написанием отдельной библиотеки dll и подключением ее к Unity

          https://github.com/FranzDevBy/Serial-Number-Disk-in-Unity


          1. qw1
            31.01.2022 01:02
            +1

            Ну, такое себе. У меня WMI-запрос «Select * from Win32_DiskDrive» первым выдал съёмный диск PhysicalDrive7 (и вообще порядок дисков разве чем-гарантируется?). А вы берёте первый диск из выдачи и привязываетесь к его серийнику. Это может кончится тем, что при вставке новой флешки игра начнёт обвинять легального пользователя в пиратстве )))


            1. FranzDev Автор
              31.01.2022 02:05

              Гарантируется латинским алфавитом.


              1. qw1
                31.01.2022 10:07

                Вы не читали, что я выше написал? У меня первым выпал PhysicalDrive7, на котором назначена буква диска E:


                1. FranzDev Автор
                  31.01.2022 19:57

                  А PhysicalDrive7 в какой разъём SATA (порядковый номер) на материнской плате приходит? Или вообще в разъем M.2?


                  1. qw1
                    31.01.2022 22:33

                    Если вам интересно (хотя к проблеме не имеет никакого отношения), системный M2 SSD (диск C:) Windows 10 видит как PhysicalDrive5, а WMI в списке дисков его показывает последним. Тут полный рандом. Есть диск, подключенный через SATA-контроллер в PCI-e слоте, его номер PhysicalDrive4. И до него по нумерации, и после него, есть диски из штатных SATA-линий с материнки.

                    При чём здесь SATA? Я втыкаю флешку — появляется PhysicalDrive8, и в списке «Select * from Win32_DiskDrive» он где-то в середине (а мог бы и первым встать, и попасть вашей «защите» на проверку серийника — гарантий никаких нет, что так не будет).


                    1. FranzDev Автор
                      31.01.2022 22:52

                      Ок, спасибо


  1. BugM
    30.01.2022 04:59
    +14

    Это так наивно. Ваша защита ломается даже интересующимися студентом за часы. Не говоря уже о людях с опытом.


    1. horror_x
      30.01.2022 06:14
      +18

      Мне наивным больше кажется очередной пересчёт скачиваний торрента в упущенную прибыль…


      1. Dreablin
        30.01.2022 06:44
        +2

        Ну какой-то убыток вполне может быть от этого.

        Я бы, если игра хорошая получилась, сам бы сделал демку на 10-20% геймлея и первый залил на популярные трекеры прямо в момент релиза. С возможностью переноса сейвов, конечно.


  1. napa3um
    30.01.2022 09:23
    +7

    Насколько увеличился доход от продаж после внедрения вами этой защиты в свой продукт?


  1. uncle_doc
    30.01.2022 10:10
    +7

    Вместо этой защиты, как мне кажется, больший результат принесли бы другие действия, например: Показ сообщения что вы типа пират и это не хорошо, купите игру со скидкой например или задонатьте для отключения этого сообщения ну или что-то в этом роде. В игру играть, я бы на вашем месте разрешил, даже в пиратскую.


  1. Akuma
    30.01.2022 11:33

    По мне так гуманнее будет сделать внутриигровую авторизацию через стимовский аккаунт. Удобнее и надежнее


    1. qw1
      30.01.2022 12:00

      Против этого есть стандартные эмуляторы стим, которые подсунут игре аккаунт с любым заранее настроенным именем (например, CODEX :) ) и скажут игре, что она куплена на этом аккаунте.


      1. Akuma
        30.01.2022 14:24
        +2

        Так против всего есть методы. Денува ломается так же как и все остальное.

        Просто то, что в статье - какой-то гемор для пользователя.


  1. qw1
    30.01.2022 11:59
    +6

    То есть, пират может легко выложить свою копию игры на трекер, если после установки из steam он ни разу не запускал игру, и файл settings.dat ещё не перезаписан серийным номером диска. Даже код ковырять не надо.


  1. ZhilkinSerg
    30.01.2022 12:21

    Что прямо вот правда "Кому" "воруют"? На рутрекере нет ее.


    1. qw1
      30.01.2022 14:59
      +2

      Видите, защита работает!


  1. Northbound
    30.01.2022 13:15

    Ну ведь хоть какая-то защита, но она будет


  1. igor_alt
    31.01.2022 02:22

    Вспомнил, как в начале карьеры не получил ни копей прибыли от готового продукта, так как сильно боялся, что его будут воровать, а сообразить должную защиту я не смог :)


  1. KGeist
    31.01.2022 23:02
    +1

    Вызовы Close внутри using выглядят лишними.