Day02 - 綠鬣蜥初級兵新手訓練營:Zig 基礎語法(上)

走在時代前沿的前言

歡迎回來小蜥蜴們,我是 CX330!昨天已經讓大家把環境給安裝好了,今天就會正式進入我們的暗影綠鬣蜥訓練!語法的部分我總共打算分為 3 天,也就是 3 篇文章來講解。今天的話會說明一下我對於 Zig 語言的認識以及他的一些特性、構建系統、變數宣告、基礎變數型別等。前面的三天都是語法居多,也滿多內容和範例都是來自於 Zig 語言聖經[1],滿推薦可以從這裡入門的。

如果有學過 C 語言的話,應該會覺得 Zig 寫起來手感滿像的,上手起來應該會比較快(相較於隔壁的 Rust XD)。

疊甲

中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」

本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!

Zig 版本

本系列文章中使用的 Zig 版本號為 0.14.1。

我眼中的 Zig

比起很多其他的語言宣稱要成為一個 Better C 來說,於我而言 Zig 更為謙遜且現代,無論是從語法上或是從官方實作的許多功能來看皆是如此。在我眼裡 Zig 從未想要「取代」C 這個古老、偉大且幾乎不可撼動的語言,相反,它更傾向於與 C 語言合作。舉幾個例子,像是 zig cc 完全兼容了 gccclang 的用法,可以直接作為一個 C/C++ 的編譯器,甚至讓跨平台編譯(Cross compilation)變成內建支援的功能。除此之外,Zig 和 C 也可以直接呼叫彼此的函數庫、標頭檔等(此部分後續也會在實作中遇到),甚至可以用 zig translate-c翻譯用 C 寫的原始程式碼。

由上述例子我們可以很好的發現,Zig 跟 C 更像是一個「互助合作」的關係,而非競爭。透過 Zig 編寫的程式也可以更好的避免一些原先在 C 語言中容易發生的錯誤與漏洞,使得程式更加的安全且 Robust(不想講魯棒…)。那如果我們以惡意程式開發的角度思考,為什麼我們要選擇 Zig 這門語言來寫 Malware 呢?我的答案包括但不限於以下幾點。

  1. 惡意程式中有許多 Code snippet 是 C 語言撰寫,可以互相調用
  2. 足夠底層,需要寫 Inline assembly 的時候很方便
  3. 語言現代且編譯器優化導致逆向工程師分析較為困難,同時現有的分析工具也較少
  4. 構建系統很方便
  5. 原生高度支援跨平台編譯
  6. 寫起來比寫 Rust 快樂 XD

聽起來很不錯!說了這麼多,我們快點來正式看看 Zig 語言吧!我們先從構建系統開始說起,來說明一個 Zig 專案的目錄結構以及如何編譯等等,開始嘍!

構建系統

要在這邊講解完 Zig 的構建系統是不可能的,畢竟我只安排了三天的時間讓大家可以快速上手 Zig 這門語言,重點在於後面惡意程式技術的實作。所以我們在這個章節只會簡單說明我們需要知道的一些內容,如果想要知道更詳細的其他細節,歡迎直接閱讀官方文檔

畢竟是個現代語言,在 Zig 中,我們也可以用類似 Rust 的 cargo init 和 Go 的 go mod init 來初始化一個專案。具體做法如下:

mkdir my_project && cd my_project  # create a prject directory and cd into it
zig init                           # this will init the project "my_project"

完成後,我們便可以獲得一個 my_project 的 Zig 專案了。在裡面我們會看到很多檔案,他的專案結構長得像下面這樣。

.
├── build.zig
├── build.zig.zon
└── src
    ├── main.zig
    └── root.zig

我們由上至下來看一下這些檔案分別是在幹嘛的。

  • build.zig
    • 整份專案的構建腳本
    • 設定編譯目標
    • 設定編譯選項
    • 新增外部模組與函式庫
  • build.zig.zon
    • 這是 Zig 的依賴項及版本描述檔,有點類似於 package.json
  • main.zig
    • 應該猜得到,就是主要的入口點
    • 裡面有個 main 函數
  • root.zig
    • 這是一個範例的函式庫,可以用來寫全域共用的模組、常數、函式等等

既然有 zig init 可以初始化專案,那肯定也有 zig build 去構建專案對吧,當我們今天不只一個程式碼檔案的時候,build.zig 的功能便得以彰顯。而如同上面所說,整份專案的構建腳本為 build.zig,所以當我們使用 zig build 命令去構建專案時,他就會讀取這份腳本並執行。我們先來看一下原本透過 zig init 創建的 build.zig 長怎樣吧。

其中,所有的內容都會被放在這個名為 build 的 public 的函數中,等等執行 zig build 時就會呼叫這個函數。

pub fn build(b: *std.Build) void {
 ...
}

在裡面,會先宣告兩個變數用來設定一些編譯選項:編譯目標以及編譯器優化設定。

const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});

這邊顧名思義,target 指的是要編譯為哪種主機架構的檔案;而 optimize 則是可以設置不同的編譯優化,有四種預設提供的建構模式(Build mode),分別為:

  • Debug
  • ReleaseFast
  • ReleaseSafe
  • ReleaseSmall

接著會建立兩個模組,用於設定要編譯的單位。

const lib_mod = b.createModule(.{
    .root_source_file = b.path("src/root.zig"),
    .target = target,
    .optimize = optimize,
});

const exe_mod = b.createModule(.{
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});

exe_mod.addImport("foo_lib", lib_mod);

這兩個模組將會是編譯器要編譯的檔案,最後一句的 exe_mod.addImport() 會把 lib_modfoo_lib 的名稱匯入到 exe_mod 中,讓 exe_mod 可以使用 @import("foo_lib") 來引用 lib_mod 的內容。

接著會建立靜態的函式庫。

const lib = b.addLibrary(.{
    .linkage = .static,
    .name = "foo",
    .root_module = lib_mod,
});

b.installArtifact(lib);

這邊會把先前建立好的模組建立成靜態函式庫,會先定義要把它加入為一個 library 然後使用 installArtifact 來安裝其到專案的安裝目錄。

然後對執行檔模組建立成執行檔。

const exe = b.addExecutable(.{
    .name = "foo",
    .root_module = exe_mod,
});

b.installArtifact(exe);

和上面差不多的概念,只不會不是函式庫而是安裝為可執行文件。

後續的步驟,就是建立當使用 zig build run 的時候要執行的步驟,還有一些測試等等,這邊就不細講了。如果有興趣歡迎去看一下裡面的註解,一開始用 zig init 出來的裡面都會有完整的註解可以看。

變數和常數宣告

變數

在 Zig 語言裏,我們會用 var 關鍵字來宣告一個變數,他的格式為:

var variable_name: Type = init_value;

舉例而言,要宣告一個初始化值為 1337 的 16-bit unsigned integer,我們可以使用:

var num: u16 = 1337;

在 Zig 語言中,我們會盡量遵循「非必要不使用變數」的原則,盡可能地使用常數去將可變性局限在需要的最小範圍內。

同時,Zig 語言要求所有非最上層所定義的常數和變數都必須被使用,如果未被使用,則編譯器會噴錯。不過可以像 Python 一樣透過賦值給 _ 來將其值拋棄。

常數

而至於常數的話也十分簡單,是使用 const 關鍵字來宣告的。和所有語言一樣,常數宣告之後不可再變更其值,只能在初次宣告的時候對其賦值。舉例而言,要宣告一個初始化值為 1337 的 16-bit unsinged integer 常數,我們可以使用:

const num: u16 = 1337;

標示符命名

先來講講變數和常數名名稱的命名吧!在 Zig 語言中,一個變數或常數的標示符必須透過字母或底線開頭,後面可以有字母、數字、或底線,同時不得語關鍵字重複。並且,也禁止存在變數遮蔽(Variable shadowing),內部作用域的變數名稱不得與外部的相同,我們看一下以下案例。

const std = @import("std");

const a = 5;

pub fn main() void {
    const a = 6;
}

我們可以發現在最上層和 main 函數中,都有一個稱為 a 的變數,那這時候 Zig 就會在編譯期報錯,說發現了有變數遮蔽的問題,如下。

test.zig:6:11: error: local constant shadows declaration of 'a'
    const a = 6;
          ^
test.zig:3:1: note: declared here
const a = 5;
^~~~~~~~~~~

如果真的硬要使用這些不合規範的變數名稱,像是要連結外部的函式庫的時候,可以使用 @"" 的語法,例如:

pub extern "c" fn @"error"() void;

未初始化的變數

如果說,想像 C 語言一樣先宣告一個變數,晚一點再來賦值,我們可以使用 undefined 關鍵字來讓變數保持在未初始化的狀態。

var num: u16 = undefined;

使用 undefined 初始化的變數不會有任何的初始化操作(不會初始化為 0),常常用於初始值不重要的情境底下,例如用作 buffer 的變數通常會被傳遞給另一個函數進行寫入,會覆蓋原有的內容,故初始值在此情境下就不重要。

解構賦值

這就很 Javascript,如果寫過 JS 的應該會超級熟悉。解構賦值(Destructing assignment)就是允許對可索引的複雜結構進行解構,並賦值給指定的變數(這樣有解釋到嗎 XD)。我們看個例子就明白了。

var a: u32 = undefined;
var b: u32 = undefined;
var c: u32 = undefined;

const tuple = .{ 1, 2, 3 };  // here!
x, y, z = tuple;

// Result:
// x = 1
// y = 2
// z = 3

在這個例子中,第 5 行就是解構賦值發生的地方,會直接把元組中的資料一一對應的賦值給三個變數。

註解

Zig 有三種註解模式:

  1. 普通註解
  2. 文檔註解
  3. 頂層文檔註解

普通註解

普通註解跟 C 語言一樣是用 // 的符號表示,挺常見的。不過值得一提的是,Zig 中並沒有像 C 的 /* */ 或是 Python 的 """ """ 這種多行註解符號的存在,所以當註解很多行的時候會在每行的開頭使用 //

文檔註解

使用 /// 就可以寫文檔註解,這東西就是比如你寫程式,游標 hover 某個變數名稱的時候會提示這個函數的功能在做什麼,這就是文檔註解。他會寫在函數、類型、變數等等標示符的上方,以說明該標示符的作用。

頂層文檔註解

頂層文檔註解的話用的是 //! 的符號,它就是整個檔案的註解,通常用於記錄檔案的作用,必須放在作用域的最上層,否則編譯器會噴錯。

基礎變數

數值類型

整數

在 Zig 中宣告一個整數類型非常的自由,可以用 i 表示有符號整數;用 u 來表示無符號整數,並且這個 iu 後面可以接任意的數字,來表示位元長度。舉例來說,我想宣告一個 1337 位元的無符號整數,就可以使用 u1337 來表示,酷吧!對了,這個寬度的最大上限是 65535。

然後還有 isizeusize 兩種型別,這兩種型別的大小取決於目標的 CPU 架構,在 32 位元的系統中就是 32,64 位元亦然。

進位制

如同許多其他的程式語言,Zig 也可以簡單使用不同的方式表達不同的整數進位制。

寫法範例
10 進位1337
16 進位0x539
8 進位0o2471
2 進位0b10100111001

浮點數

Zig 的浮點數有 f16f32f64f80f128c_longdouble(對應 C ABI 的 long double)。

另外,Zig 沒有提供像其他語言那樣的 NaN、無限大、負無限大等這種語法,若需要使用它們,需要透過標準函式庫獲取:

const std = @import("std");

const infinite = std.math.inf(f32);
const neg_infinite = -std.math.inf(f64);
const nan = std.math.nan(f128);

運算子

哎呀什麼加減乘除大於等於小於的我就不講了,和其他程式語言都一樣,我主要講一些和其他語言不同的部分。

邏輯的與、或、非使用的是 andor! 來表示,而位元操作的與、或、非則是使用 &|~。而按位的 XOR 也是和其他語言差不多,是用 ^ 符號。

除了這些之外,Zig 還有一些很酷的運算,例如飽和運算。飽和運算指的是,運算過後的值不會超過該類型的極值。他的語法就是在一般的操作後面加上 | 符號,例如飽和加法就是 +| 而飽和乘法就是 *|。舉例來說,一個 u8 類型的 255 透過飽和加法加 1 之後仍然會是 255。

那飽和運算的相反,就是環繞運算了。在 C 語言中 Integer overflow 的預設行為,就是會從另一端環繞回來。舉例來說,一個 8-bit unsigned integer 的 255 再加上 1 就會變回 0,這在 C 語言中是預設的行為。但為了保障數值的安全性,這種行為在 Zig 中卻需要特別去處理。如果希望數字可以 overflow 回來,要用「環繞運算子」,語法就是在普通運算子的最後面加上 % 符號,比如 +%*% 等等。

字串與布林值

字符串字面量(String Literal)

字符串字面量就是 String Literal,雖說是中國的翻譯,但我在台灣比較少看到這詞的翻譯,故用之,不過我以下會用英文來取代「字符串字面量」這個詞。

寫過 C 的大家應該都知道,String literal 就是字元的陣列,這個概念在 Zig 裡面也是一樣的。和 C 語言一樣,字串是一個字元指針,指向一個字元陣列的開頭,並由 Null terminator 結束(0)。不過既然因為 String literal 是字元的陣列,因此字符串可以隱式轉型為 u8 的切片,或是 0 結尾的指針。我們可以看一下以下的程式碼,我們先把 bytes 的型別打印出來,發現是 *const [12:0]u8 的型別。

const std = @import("std");
const print = std.debug.print;

pub fn main() void {
    // Storing UTF-8 encoding sequence
    const bytes = "你好, Zig!";

    print("{}\n", .{@TypeOf(bytes)}); // *const [12:0]u8
    print("{}\n", .{bytes.len}); // 12

    print("{x}\n", .{bytes[0]}); // 'e4'
    print("{x}\n", .{bytes[1]}); // 'bd'
    print("{x}\n", .{bytes[2]}); // 'a0'

    // Ended by NUL
    print("{d}\n", .{bytes[12]}); // 0
}

而上面的 e4 bd a0 就是 的 UTF-8 的三個位元組,並且也可以看到最後一位確實是 0,也就是 Null terminator。

多行字符串

在 Zig 語言中的多行字符串是用 \\ 在每一行的開頭,不會有任何轉義,也不包含最後一行的換行符。

const multi_line_str =
 \\Hi,
 \\My name is CX330.
;

C 語言的 char

就像我說過的,Zig 和 C 有著很和諧的合作關係,因此它也提供了一個類型叫 c_char 來提供 C 語言的字元型別。即便 c_charu8 都是 8 個位元,但是他們兩個不完全等價,這是因為 c_char 是否有號是取決於目標機器的。

因此,除非是與 C 語言做交互,平常時間還是更推薦使用 u8 型別來操作字串和字元等相關型別。

布林值

這就很簡單,就是 truefalse 而已。

函數

初見 Function

寫過 C 的在看 Zig 語言的函數應該很容易看懂,我舉個例子。

pub fn add(a: u8, b: u8) u8 {
 return a + b;
}

這個函數就是一個兩數相加的函數,具體說明如下:

  1. pub 是表示這個函數可以被公共存取,代表其他文件 import 之後可以直接使用
  2. fn 是宣告函數的關鍵字
  3. add 是標示符,就是函數名稱
  4. a: u8, b: u8 是參數的名稱和類型,有 ab 兩個參數,並都是 u8 型別
  5. u8 是函數的返回值類型

像 C 語言一樣,若沒有返回值,應該使用 void 關鍵字。

參數傳遞

在 Zig 裡面的參數傳遞如下:

  • 原始類型(Primitive type)
    • Pass by value
    • 整數
    • 布林值
  • 複合類型(Composite type)

可能很難捉摸,但我們只需要記住,函數的參數是不可變的!所以當需要變更傳遞的參數的時候,請使用指針。

鐵人賽期 PoPoo,你今天轉 Po 了嗎?

好啦,恭喜大家第二天結束囉!今天帶大家看了 Zig 的一些特性還有他的構建系統、變數宣告、基礎型別等等。明天的一開始會帶大家寫一個 Hello world 的程式,同時介紹一些複合類型和流程控制等。那就請大家期待新手訓練營的第二天啦!

如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!


  1. https://course.ziglang.cc/ ↩︎