iT邦幫忙

2022 iThome 鐵人賽

DAY 23
2
Modern Web

30個遊戲程設的錦囊妙計系列 第 23

Trick 22: 遊戲的正義由數字保安來維護

  • 分享至 

  • xImage
  •  

有製作過遊戲的朋友,肯定有過這樣的經驗,明明計算好這一關最高只可能得到300分,卻總是有人可以一下突破天際得到30000分。而這個情況不是只發生在網頁遊戲,電腦上或任何主機上的遊戲也都有類似的作弊軟硬體,可以偵測遊戲中儲存數值的記憶體位置,並加以修改。

作弊軟體的原理

這種作弊軟體的操作方法,通常需要玩家先找到遊戲中的某個數字,比如說目前的血量、目前的金幣、目前的經驗值等等。假設這位玩家想要獲得更多金幣,那麼他就要告訴作弊軟體,目前的金幣量,假設是100。

接下來玩家要進行個一兩次的戰鬥,讓金幣量稍微增加一些。假設戰鬥過後的金幣增加到了120,接著再請作弊軟體去記憶體內搜尋,看看哪一塊記憶體上的數字由先前的100變成了現在的120。如果找到的記憶體位置不只一個,那就再戰鬥個兩場讓金幣量再次改變,並請作弊軟體接著搜尋,直到某一塊記憶體位置上的數字,完全照著玩家發現的數字變化在改變。

找到儲存金幣的記憶體位置後,就可以靠著作弊軟體任意調整記憶體上儲存的數字,玩家立馬變成大富翁,商店裏最強的傳說武器和超貴的復活藥水都被搜括一空。但很快地,這個遊戲也就會被玩家給廢了,而且可能還會在論壇中表示這遊戲太簡單,三小時全破。

數字保安

因為作弊是很多玩家的天性,所以保護好遊戲內的數字就變成一項必要的工作。將數字保護起來的方法,可以分成兩個部分:

  1. 隨時可檢查數字是不是被改變了。
  2. 復原被篡改的數字。

第一項工作的實作方法,就是在設定數值的時候,將數值以Hash函式產生一個檢查碼。在之後檢查時,把數值再次Hash,看看新的Hash和舊的是否吻合。這個Hash函式的效率必須要高,因為這項檢查工作可能每幀都會做個幾百次。

Hash函式可以將資料利用複雜的計算式轉換成另一組資料,這個轉換的結果有兩個特性:

  1. 抗碰撞性:不一樣的資料轉換後也會不一樣。
  2. 不可逆性:轉換後的資料無法轉換回原本的資料。

忘記什麼是Hash的同學可以回到《Trick 15: 把Hash函數帶進遊戲玩》複習一下。

第二項工作是在發現數字被篡改後,要將數字復原。這項工作的要點則是要把數字藏起來,並且保證被藏起來的數字備份不會在玩家作弊時一起被篡改。

寫程式時,要先決定採用哪兩個技術來對應數字保安的兩個工作,檢查與復原。在把數字保全起來的類別中,每次取值出來用的時候,就要先比對一下hash是否保持相同,如果發現hash不同,我們就要先將數值還原,再把還原後的值傳出去。

在實作的時候,我們要先寫以下三個函式,定義Hash和備份用的演算法。

// 用value來產生hash
function getHash(value: number): string|number {
    let hash = ...
    return hash;
}
// 將value藏在backup裏
function getBackup(value: number): string {
    let backup = ...
    return backup;
}
// 將backup還原成value
function restoreValue(backup: string): number {
    let value = ...
    return value;
}

有了Hash、備份和還原三個函式,就能把數字保安的類別寫出來了。

// 定義一個數字保安類別
class SecureNumber {
    // 用來檢查數字篡改的hash
    hash: string|number;
    // 用來還原數字的資料
    backup: string;
    /**
     * 建構子,要給一個需要保安的數字
     * 我們會用一個隱私的_value屬性來儲存
     */
    constructor(private _value: number) {
        // 產生hash
        this.hash = getHash(_value);
        // 建立數字還原資料
        this.backup = getBackup(_value);
    }
    // 取值
    getValue(): number {
        // 用現在的_value產生目前應有的hash
        let hash = getHash(this._value);
        // 檢查目前的hash和之前的是否吻合
        if(hash != this.hash) {
            // hash不合,就要復原值
            this._value = restoreValue(this.backup);
        }
        return this._value;
    }
    // 更新值
    setValue(value: number) {
        // 先取得目前的的value
        let currentValue = this.getValue();
        // 如果新的值不同才要設定
        if(currentValue != value) {
            this._value = value;
            // 更新hash
            this.hash = getHash(value);
            // 更新還原資料
            this.backup = getBackup(value);
        }
    }
}

現在我們只要決定getHash(value)、getBackup(value)以及restoreValue(backup)這三個保安函式要怎麼寫,這個SecureNumber類別就可以拿來用了。

其實不只數字需要保護,遊戲中有些重要的字串、布林值,甚至一整個Json都可以用這種方法保護起來。

簡易版的加密、解密

先提供同學們一組簡易版的保安函式,用同一個方法加密與備份,方便快速理解與測試。

加密時,先將原資料加鹽(salt),再轉成一個隨機進位的字串。這個方法的好處是運算速度快,缺點是只能處理整數。

// 先隨機製造一把鹽(salt), -5000~5000
let salt = Math.round((Math.random() - 0.5) * 10000);
// 隨機選一個進位(16~36)
let carry = 16 + Math.round(Math.random() * 20);

// 用value來產生hash
function getHash(value: number): string {
    // 將值加上salt,然後變成carry進位的字串
    let hash = (value + salt).toString(carry);
    return hash;
}
// 將value藏在backup裏
function getBackup(value: number): string {
    // 用同樣的方法來藏value
    let backup = getHash(value);
    return backup;
}
// 將backup還原成value
function restoreValue(backup: string): number {
    // 先把backup用carry進位轉回十進位的數字,然後減去salt
    let value = parseInt(backup, carry) - salt;
    return value;
}

如果我們進行一次如下的實驗,

let value = 100;
let backup = getBackup(value);
console.log('salt = ' + salt);
console.log('carry = ' + carry);
console.log('backup = ' + backup);
console.log('restore = ' + restoreValue(backup));

會得到這樣的結果:|
:---------|:---------
salt = 4823|亂數鹽
carry = 19|隨機選的進位數
backup = dc2|原值被轉換後的備份字串
restore = 100|從備份字串轉回來的原值
如此一來,玩家即使找得到儲存100這個數字的記憶體位置,但是他再怎麼改來改去,遊戲也會一直把改過的值還原。諒他猜也猜不到,放dc2的記憶體才是真正我們儲存值的地方。

進階版的加密、解密

這邊列出些常用的Hash函式,以及加密工具,同學可以自行選擇適合的來用。

Hash函式

加密函式

在今天的示範程式中,使用CRC32為Hash函式,並使用Base64為加密函式。

// 這是CG給的亂數產生工具
let rng = CG.Base.utils.systemRandomGenerator;
// 產生8個字元長度的隨機字串鹽(salt)
let salt = rng.generateRandomString(8);
// 產生另一個隨機字串鹽(salt)給backup用
let backupSalt = rng.generateRandomString(8);

// 用value來產生hash
function getHash(value: number): string | number {
    // 使用CG工具來進行CRC32的Hash運算
	let hash = StringUtil.crc32(value + salt)
	return hash;
}
// 將value藏在backup裏
function getBackup(value: number): string {
	// btoa()是一個把字串變成base64碼的系統函式
	let backup = btoa(value + backupSalt);
	return backup;
}
// 將backup還原成value
function restoreValue(backup: string): number {
	// atob()是一個把base64碼還原成原始字串的系統函式
	let value = atob(backup);
    // 去掉value最後面的backupSalt
	value = value.substring(0, value.length - backupSalt.length);
	// 將字串轉回數字
	return Number(value);
}

同樣地,我們也來做一次作弊的實驗。

let originalValue = 100;
console.log("原始值 = " + originalValue);
// 建立數字保安
let num = new SecureNumber(originalValue);
console.log("數字保安已建立:");
console.log(" Hash   = " + num.hash);
console.log(" Backup = " + num.backup);
// 篡改數字為3000, 使用['']可以手動改變隱私屬性(建議少用)
num['_value'] = 3000;
console.log("數字已被篡改為 " + num['_value']);
console.log("getValue() = " + num.getValue());

然後得到這樣的結果
原始值 = 100
數字保安已建立:
 Hash = 1226020697
 Backup = MTAwTkNBR1lXWEk=
數字已被篡改為 3000
getValue() = 100
在數字被篡改後,我們再使用num.getValue()還是可以得到原始設定的100。

CG示範專案
專案有使用到StringUtil裏的crc32()函式,原始碼: StringUtil.ts

JSON保安

最後我們用同樣的方法來實作一個保護JSON的類別,讓同學們知道不論什麼樣的資料型態都可以用這樣的方法加以保護。

首先改一下用來加密解密的函式。

let rng = CG.Base.utils.systemRandomGenerator;
// 產生8個字元長度的隨機字串鹽(salt)
let salt = rng.generateRandomString(8);
// 產生另一個隨機字串鹽(salt)給backup用
let backupSalt = rng.generateRandomString(8);

// 用value來產生hash
function getHash(value: string): string | number {
    let hash = StringUtil.crc32(value + salt)
    return hash;
}
// 將value藏在backup裏
function getBackup(value: string): string {
    // btoa()是一個把字串變成base64碼的系統函式
    let backup = btoa(value + backupSalt);
    return backup;
}
// 將backup還原成value
function restoreValue(backup: string): {[key: string]: any} {
    // atob()是一個把base64碼還原成原始字串的系統函式
    let value = atob(backup);
    // 去掉value最後面的backupSalt
    value = value.substring(0, value.length - backupSalt.length);
    return value;
}

然後來寫Json的保安類別

class SecureJson {
    // 用來檢查有無被篡改的hash
    hash: string | number;
    // 用來還原Json的資料
    backup: string;
    /**
     * 建構子,要給一個需要保安的數字
     * 我們會用一個隱私的_value屬性來儲存
     */
    constructor(private _data: {[key: string]: any} = {}) {
        let json = JSON.stringify(_data);
        // 產生hash
        this.hash = getHash(json);
        // 建立還原資料
        this.backup = getBackup(json);
    }
    // 取值的函式
    getValue(key: string): any {
        // 再用現在的_data產生一次hash
        let json = JSON.stringify(this._data);
        let hash = getHash(json);
        // 檢查目前的hash和之前的是否吻合
        if (hash != this.hash) {
            // hash不合,就要復原Json
            this._data = JSON.parse(restoreValue(this.backup));
        }
        return this._data[key];
    }
    // 更新值的函式
    setValue(key: string, value: any) {
        // 先取得目前的的value
        let currentValue = this.getValue(key);
        // 如果新的值不同才要設定
        if (currentValue !== value) {
            this._data[key] = value;
            // 更新hash
            let json = JSON.stringify(this._data);
            this.hash = getHash(json);
            // 更新還原資料
            this.backup = getBackup(json);
        }
    }
}

系統機制的公平,影響著玩家在心理層面對一個遊戲的定義。如果是即時連線的競技遊戲,或是遊戲中有分數比較、團體對抗等離線的排行榜,那麼玩家作弊就是不可容忍之惡,需要儘一切力量去防堵。

但如果是RPG、解謎等單人遊戲,那麼留著一些後門讓玩家去胡搞瞎搞,也可能增添遊戲設計以外的樂趣與談資。

在遊戲釋出之後,觀察玩家的生態,時不時進場攪亂一池春水,就是我們遊戲設計師最大的樂趣。


上一篇
Trick 21: 如何畫出貝茲那曼妙的曲線
下一篇
Trick 23: 大型垃圾不要丟,資源回收再利用
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言