Complete Guide to Writing AES-256 CBC in Rust
Introduction
Complete Guide to Writing AES-256 CBC in Rust
Introduction
On my YouTube channel I have shown and demonstrated implementations of AES-128 ECB and AES-128 GCM in Python¹. That said, I wanted to transfer this knowledge to a Rust implementation of AES-256 CBC. Working with a systems programming language, I felt, would force me to work with less abstraction and learn the algorithms better. Additionally, implementing AES-256 CBC is a prerequisite to my next goal of demonstrating a Padding Oracle attack. As an important disclaimer, my implementation should NOT be used for any production cryptography — it is simply a learning exercise.
Article Contents
- History of AES
- NIST documentation and standards
- AES Internals — Encryption 3.1 KeyExpansion() 3.2 KeyExpansion() Rust Implementation 3.3 Cipher() 3.4 AddRoundKey() 3.5 SubBytes() 3.6 ShiftRows() 3.7 MixColumns() 3.8 Cipher() Rust Implementation
- AES Internals — Decryption 4.1 InverseCipher() 4.2 Inverse AddRoundKey() 4.3 Inverse ShiftRows() 4.4 Inverse SubBytes() 4.5 InverseMixColumns() 4.6 InverseCipher() Rust Implementation
- CBC Mode of Operation
- Mathematical Prerequisites
- Rust Code Demonstration
- References
1.0 History of AES
In 2000 the National Institute of Standards and Technology (NIST) selected the Rijndael cipher as the winner of the Advanced Encryption Standard (AES) competition². Thus, Rijndael became known as AES, with its variations becoming the AES family of block ciphers. The need for AES arose due to weaknesses in the Data Encryption Standard (DES), which was published in 1977³. Because of these weaknesses, AES officially supersedes DES and all its variations (3DES etc.). To this day there are 3 members of the Rijndael family selected to be included in AES: AES-128, AES-192, and AES-256². The various numbers (128, 192, 256) denote the key length, but despite varying key lengths, each cipher encrypts 128-bit blocks. All AES variations are symmetric block ciphers, meaning the same secret key is used for encryption and decryption, and data is operated on in blocks.
2.0 NIST Documentation and Standards
What I have developed is by no means a production grade, secure, and thoroughly tested implementation. That said, to ensure that my implementation was not completely off-base, I followed the most official standards and documentation. The Federal Information Processing Standards (FIPS) are documents that outline the standards set by NIST⁴. One of these standards, FIPS-197, addresses AES. For function construction, variable naming, control flow, and data processing, I tried to follow FIPS-197. It is also important to know, that for the AES modes of operation (detailed in section 4) NIST published 800–38A⁵.
3.0 AES Internals — Encryption
As previously mentioned, AES can function with a 128, 192, or 256 bit key. Regardless of key length, the block size is always 128-bits. The number of rounds in the block cipher varies, based on the key length. The following table outlines these details about AES²:

AES Number of Rounds, Key Length, Block Size
3.1 KeyExpansion()
Since AES operates with rounds, the key is expanded into Nr round keys using the KeyExpansion() function. It is important to note that, because the block size is always 128-bits, the length of a round key is also always 128-bits — despite varying secret key lengths. KeyExpansion() generates 4-byte WORDs, with each round key being 4 WORDS (16-bytes) long. The number of round keys generated is Nr+1 . The reason for generating 1 more round key than the numbers of rounds is because an initial AddRoundKey() function is performed before the rounds start, detailed later. Because KeyExpansion() operates on WORDs, the output can be defined as 4*(Nr+1) round key WORDs². With AES-256, for example, KeyExpansion() will output 4*(14+1) = 60 WORDs. Pseudocode for KeyExpansion is given below²:

AES KeyExpansion()
The function follows the following steps:
1 — Set an index i = 0
2 — Perform a while loop with the condition i <= Nk — 1 . The variable Nkrepresents the total number of WORDs in the secret key, for AES-256 there are 8 WORDs in the secret key. Thus, the while loop stores the WORDs of the secret key in the array w . The first 2 round keys are comprised of the secret key itself. The array w will be used to store the WORDs of all round keys.
3 — Perform a second while loop with the condition i <= 4*Nr+3 . The value 4*Nr+3evaluates to 4*14+3 = 59 represents the total iterations needed to generate all round key WORDs (since i starts from 0 we have 60 iterations). In each iteration of this while loop a different value for the WORD temp is calculated. There are 2 conditions: 1) i mod Nk = 0
and Nk > 6 and i mod Nk = 4 . For the first condition, when i divides the number of WORDs in the key, the previous word w[i-1] is sent through the helper functions RotateWord(WORD w) and SubstituteWord(WORD w) . The first byte of the resulting WORD is XORd with rcon[i//Nk] , and assigned to temp. For the second condition, when Nk > 6 and i mod Nk = 4 , the WORD temp is simply the value of SubstituteWord(w[i-1]) . In any other condition, temp is simply set to w[i-Nk] XOR w[i-1] .
4 — Once the second loop is finished, the array of WORDs w can be returned.
NOTE: rconrefers to round constants. These round constants are used by KeyExpansion() to introduce non-linearity to the algorithm. Round constants can be stored as a pre-computed table, or generated through a piecewise function. The 10 round constants are constant WORDs, shown in the following table²:

AES KeyExpansion() Round Constants
3.2 KeySchedule() Rust Implementation
// substitute word helper for key expansion
fn sub_word(word: [u8; WORD_LEN]) -> [u8; WORD_LEN] {
return word.map(|x| S_BOX[x as usize]); // map(|element| <new value>), usize needed for indexing
}
// rotate word helper for key expansion
fn rot_word(word: [u8; WORD_LEN]) -> [u8; WORD_LEN] {
return [word[1], word[2], word[3], word[0]];
}
// xor words helper for key expansion
fn xor_words(a: [u8; WORD_LEN], b: [u8; WORD_LEN]) -> [u8; WORD_LEN] {
return [a[0] ^ b[0], a[1] ^ b[1], a[2] ^ b[2], a[3] ^ b[3]];
}
// group round keys into sublists of 16-bytes each
pub fn group_round_keys(w: &[[u8; WORD_LEN]; TOTAL_WORDS]) -> [[u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS] {
println!("[+] Grouping round keys into list of lists");
let mut round_keys: [[u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS] = [[0u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS];
for i in 0..NUM_ROUND_KEYS {
for j in 0..WORD_LEN {
round_keys[i][j] = w[i*4][j]; // set WORDs of the ith round key to the corresponding WORD in w
round_keys[i][j+4] = w[i*4+1][j]; // use j+4 to set the next WORD in round key i
round_keys[i][j+8] = w[i*4+2][j]; // j+8 to set the 3rd WORD in round key i
round_keys[i][j+12] = w[i*4+3][j]; // j+12 to set the last WORD
}
}
println!("[+] Grouped round keys: {:?}", round_keys);
return round_keys;
}
// expand the secret key into 15 round keys needed by AES-256, 15 round keys (each 16 bytes long since internal state is 16 bytes)
pub fn expand_key(aes_256_key: &[u8; 32]) -> [[u8; WORD_LEN]; TOTAL_WORDS]{
println!("[+] Performing AES Key Expansion Algorithm");
// expand the master key to words
let master_key_words: [[u8; WORD_LEN]; N] = key_to_words(aes_256_key);
// calculate the round constants
let r_con: [u8; 7] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40];
// calculate round keys
let mut w: [[u8; WORD_LEN]; TOTAL_WORDS] = [[0u8; WORD_LEN]; TOTAL_WORDS];
// loop i over [0, 59] calculating round key words (total 60 WORDs)
for i in 0..TOTAL_WORDS {
// the first 2 round keys are set to the halves of the master key
if i < N {
w[i] = master_key_words[i];
}
else if i >= N && i % N == 0 {
let mut temp: [u8; 4] = rot_word(w[i - 1]);
temp = sub_word(temp);
temp[0] = temp[0] ^ r_con[i / N - 1];
w[i] = xor_words(w[i - N], temp);
}
else if i >= N && N > 6 && i % N == 4 {
let temp = sub_word(w[i - 1]);
w[i] = xor_words(temp, w[i - N]);
}
else {
w[i] = xor_words(w[i - 1], w[i - N]);
}
}
println!("[+] Finished generating round keys: {:?}", w);
return w;
}
3.3 Cipher()
Cipher() is detailed by the following pseudocode². To fully understand Cipher(), the sub-functions are detailed in the following sections. The sub-functions construct a Substitution Permutation Network (SPN) that executes the principles of confusion and diffusion over the plaintext.
NOTE: the state of AES refers to a 4x4 column-major array. The state is initially set to the current plaintext block. Once Cipher() finishes, the state is returned as ciphertext.

CIPHER() FIPS Pseudocode
Cipher() follows the following steps:
1 — Perform an initialAddRoundKey() operation on the input state and the first round key w[0], w[1], w[2], w[3] . This initial AddRoundKey() is important to manipulate the state, before any of the SPN steps arecomputed. If this initial AddRoundKey() step were not performed, the input to the SPN would not be dependent on the key, and thus would become predictable. AddRoundKey() masks the plaintext bytes before being fed to the SPN, making AES operations all key-dependent.
2 — Perform all but the final round, and in each round compute SubBytes(), ShiftRows(), MixColumns(), AddRoundKey() .
3 — In the last round all the operations are performed, except MixColumns() .
4 — Once Cipher() has finished executing, the state is now returned as ciphertext.
3.4 AddRoundKey()
AddRoundKey() simply adds the value of a round key to the state matrix. Because the bytes of the round keys and the state are all in GF(2^8) , the addition of two bytes is equivalent to XORing them together, this math and an explanation of GF(2^8) is detailed in section 5.
// add round key helper function to cipher()
fn add_round_key(state: &[[u8; 4]; NB], round_key: &[u8; ROUND_KEY_LEN]) -> [[u8; 4]; NB] {
//println!("[+] Performing add round key...");
let state_block = state_to_block(state);
let mut result: [u8; BLOCK_SIZE] = [0u8; BLOCK_SIZE];
for i in 0..BLOCK_SIZE {
result[i] = state_block[i] ^ round_key[i];
}
return block_to_state(&result);
}
3.5 SubBytes()
SubBytes() or SubstituteBytes() is a function that adds further non-linearity to the transformation of the state. Because this function is non-linear and invertible, it provides resistance against linear cryptanalysis. The function works by simply taking the value of a given byte x in the state matrix and substituting it for SBOX[x]. The S-Box is treated as a pre-computed lookup table, but it is actually generated by applying an affine transformation to the multiplicative inverses of all elements in GF(2⁸). The generation of the table and the affine transformation in GF(2⁸) is detailed in section 5.
// substitute bytes helper function to cipher()
fn sub_bytes(state: &[[u8; 4]; NB]) -> [[u8; 4]; NB] {
//println!("[+] Performing substitute bytes...");
let state_block = state_to_block(state);
let mut result: [u8; BLOCK_SIZE] = [0u8; BLOCK_SIZE];
for i in 0..BLOCK_SIZE {
result[i] = S_BOX[state_block[i] as usize];
}
return block_to_state(&result);
}
3.6 ShiftRows
ShiftRows() cyclically shifts the last 3 rows of the state. The number of positions shifted depends on the row index, as seen in the figure below². In the Rust code, the cyclic shifts are hardcoded for performance, and because the number of shifts is not dynamic.

AES ShiftRows()
// shift rows helper function to cipher() -> modifications done in place
fn shift_rows(state: &mut [[u8; 4]; NB]) -> [[u8; 4]; NB] {
//println!("[+] Performing shift rows...");
state[1] = [state[1][1], state[1][2], state[1][3], state[1][0]];
state[2] = [state[2][2], state[2][3], state[2][0], state[2][1]];
state[3] = [state[3][3], state[3][0], state[3][1], state[3][2]];
return *state;
}
3.7 MixColumns
MixColumns() transforms the state by multiplying each column by a fixed matrix. The values of the fixed matrix are taken from the word [a0, a1, a2, a3] = [{02}, {01}, {01}, {03}]². The fixed matrix for MixColumns() is:

AES MixColumns() Matrix
Therefore, individual output bytes are calculated as shown in the figure below. This process is repeated for each column in the state matrix.

MixColumns() Matrix Multiplication
The code below uses a special function XTIME() to compute the multiplication of a byte by 2, and can be called repeatedly to compute multiplication by higher powers of 2. XTIME() checks the bit x⁷, if that bit is set then we know that multiplication by 2 will result in an element outside of GF(2⁸), and needs to be reduced with the AES MixColumns irreducible polynomial: m(x) = x^8 + x^4 + x^3 + x + 1 . This polynomial equates to 0x1b in hexadecimal, and the reduction is computed with XOR. Details on this polynomial and its properties are covered in section 5.
// xtime function for mix_single_col
fn xtime(b: u8) -> u8 {
if b & 0x80 != 0 {
return (b << 1) ^ 0x1b;
}
else {
return b << 1;
}
}
// mix a single column for the mix columns step
fn mix_single_col(column: &mut [u8; 4]) {
let t: u8 = column[0] ^ column[1] ^ column[2] ^ column[3];
let temp: u8 = column[0];
// compute the matrix multiplication using xtime() trick
column[0] ^= t ^ xtime(column[0] ^ column[1]);
column[1] ^= t ^ xtime(column[1] ^ column[2]);
column[2] ^= t ^ xtime(column[2] ^ column[3]);
column[3] ^= t ^ xtime(column[3] ^ temp);
}
// mix columns helper function to cipher()
fn mix_columns(state: &[[u8; 4]; NB]) -> [[u8; 4]; NB] {
//println!("[+] Performing mix columns...");
// split the state array into columns
let mut columns: [[u8; 4]; NB] = [[0u8; 4]; NB];
columns[0] = [state[0][0], state[1][0], state[2][0], state[3][0]];
columns[1] = [state[0][1], state[1][1], state[2][1], state[3][1]];
columns[2] = [state[0][2], state[1][2], state[2][2], state[3][2]];
columns[3] = [state[0][3], state[1][3], state[2][3], state[3][3]];
for i in 0..NB {
mix_single_col(&mut columns[i])
}
// convert the new column values back to a state array
let mut result: [[u8; 4]; NB] = [[0u8; 4]; NB];
for i in 0..NB {
for j in 0..NB {
result[j][i] = columns[i][j];
}
}
return result;
}
3.8 Cipher Rust Code
Now that each sub-function of the AES SPN has been outlined, the following Rust code should make sense:
// cipher function to operate on a block and produce ciphertext
pub fn cipher(state: &mut [[u8; 4]; NB], round_keys: &[[u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS]) {
*state = add_round_key(state, &round_keys[0]);
// perform the first 13 rounds
for i in 0..(N_ROUNDS - 1) {
//println!("[+] Performing round: {}", i);
*state = sub_bytes(state);
*state = shift_rows(state);
*state = mix_columns(state);
*state = add_round_key(state, &round_keys[(i+1) as usize]);
}
// perform the last round (skipping mix columns)
*state = sub_bytes(state);
*state = shift_rows(state);
*state = add_round_key(state, &round_keys[N_ROUNDS as usize]);
}
4.0 AES Internals — Decryption
The decryption logic for AES revolves around the InvCipher() function. The same round keys are used between encryption and decryption. Each ciphertext block is fed to InvCipher() as a 4x4 state matrix. Additionally, the PKCS#7 padding is removed before the plaintext is returned to the user.
4.1 InverseCipher
InvCipher() is used to decrypt ciphertext blocks, using the same key as encryption. It functions largely the same as Cipher() , but uses the inverses of the sub-functions, and performs the rounds backwards. The rounds being performed backwards means that the last round key will be used first. Pseudocode for InvCipher() is shown below².

InvCipher() Pseudocode
4.2 Inverse AddRoundKey()
The inverse of AddRoundKey() is itself, thus no additional implementation is required.
4.3 Inverse ShiftRows()
The inverse of ShiftRows() simply reverses the cyclic left rotation of ShiftRows() as seen by the following Rust code:
// inverse shift rows helper function to inverse cipher
fn inv_shift_rows(state: &mut [[u8; 4]; NB]) -> [[u8; 4]; NB] {
//println!("[+] Performing inverse shift rows...");
state[1] = [state[1][3], state[1][0], state[1][1], state[1][2]];
state[2] = [state[2][2], state[2][3], state[2][0], state[2][1]];
state[3] = [state[3][1], state[3][2], state[3][3], state[3][0]];
return *state;
}
4.4 Inverse SubBytes()
InvSubBytes() can be implemented the same way as SubBytes() , but with the Inverse S-Box, pictured below², and the Rust implementation.

INV_S_BOX
// inverse sub bytes helper function to inverse cipher
fn inv_sub_bytes(state: &[[u8; 4]; NB]) -> [[u8; 4]; NB] {
//println!("[+] Performing inverse substitute bytes...");
let state_block = state_to_block(state);
let mut result: [u8; BLOCK_SIZE] = [0u8; BLOCK_SIZE];
for i in 0..BLOCK_SIZE {
result[i] = INV_S_BOX[state_block[i] as usize];
}
return block_to_state(&result);
}
4.5 Inverse MixColumns()
InvMixColumns() uses a different fixed matrix than MixColumns() , but still uses matrix multiplication.
// inverse mix a single column
fn inv_mix_single_col(c: &mut [u8; 4]) {
let (s0, s1, s2, s3) = (c[0], c[1], c[2], c[3]); // keep originals
c[0] = TABLE_14[s0 as usize] ^ TABLE_11[s1 as usize] ^ TABLE_13[s2 as usize] ^ TABLE_9[s3 as usize];
c[1] = TABLE_9 [s0 as usize] ^ TABLE_14[s1 as usize] ^ TABLE_11[s2 as usize] ^ TABLE_13[s3 as usize];
c[2] = TABLE_13[s0 as usize] ^ TABLE_9 [s1 as usize] ^ TABLE_14[s2 as usize] ^ TABLE_11[s3 as usize];
c[3] = TABLE_11[s0 as usize] ^ TABLE_13[s1 as usize] ^ TABLE_9 [s2 as usize] ^ TABLE_14[s3 as usize];
}
// inverse mix columns helper function to inverse cipher
fn inv_mix_columns(state: &[[u8; 4]; NB]) -> [[u8; 4]; NB] {
//println!("[+] Performing inverse mix columns...");
// split the state array into columns
let mut columns: [[u8; 4]; NB] = [[0u8; 4]; NB];
columns[0] = [state[0][0], state[1][0], state[2][0], state[3][0]];
columns[1] = [state[0][1], state[1][1], state[2][1], state[3][1]];
columns[2] = [state[0][2], state[1][2], state[2][2], state[3][2]];
columns[3] = [state[0][3], state[1][3], state[2][3], state[3][3]];
// iterate over the columns and perform GF(2^8) multiplication with the inverse mix columns matrix
for i in 0..NB {
inv_mix_single_col(&mut columns[i]);
}
// convert the new column values back to a state array
let mut result: [[u8; 4]; NB] = [[0u8; 4]; NB];
for i in 0..NB {
for j in 0..NB {
result[j][i] = columns[i][j];
}
}
return result;
}
4.6 InvCipher() Rust Code
// inverse of the cipher function for decryption
pub fn inv_cipher(state: &mut [[u8; 4]; NB], round_keys: &[[u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS]) {
*state = add_round_key(&state, &round_keys[N_ROUNDS]); // start with the last round key
// perform the rounds backwards
for i in (0..(N_ROUNDS - 1)).rev() { // 0..13 -> 13 rounds [0, 13)
*state = inv_shift_rows(state);
*state = inv_sub_bytes(state);
*state = add_round_key(state, &round_keys[(i+1) as usize]);
*state = inv_mix_columns(state);
}
// perform the last round (skips mix columns)
*state = inv_shift_rows(state);
*state = inv_sub_bytes(state);
*state = add_round_key(state, &round_keys[0]);
}
5.0 CBC Mode of Operation
Block ciphers, including AES, can operate in different modes depending on the use case. For example, when there is data that needs to be left as plaintext, AES-GCM can still protect that data’s integrity and verify sender authenticity. The mode of operation used for this Rust implementation is Cipher Block Chaining (CBC) mode. The flow of CBC is shown below⁷:

Cipher Block Chaining
CBC makes use of an initialization vector (IV) to perform encryption and decryption. The IV should be randomly generated per session, even if the secret key stays the same. The IV is XORed with the first plaintext block, and that result is fed as the initial state to CIPHER() . All subsequent blocks are XORed with the previous ciphertext block before being passed to CIPHER() . Because each ciphertext block value depends on previous ciphertext blocks, this results in additional resistance to cryptanalysis.. Decryption is simply the reverse process of encryption in CBC mode. The code for the CBC logic is given below:
// encryption function for AES-256 CBC
pub fn encrypt_cbc(plaintext: String, round_keys: [[u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS], iv: [u8; BLOCK_SIZE]) -> Vec<[u8; BLOCK_SIZE]> {
println!("[+] Encrypting message: {}", plaintext);
println!("[+] Message length: {}", plaintext.len());
let padded_m = match pad(&plaintext) {
Ok(p) => {
println!("[+] Padded message: {:?}", p);
p
}
Err(e) => {
eprintln!("[+] Padding Error: {:?}", e);
return vec![];
}
};
// break the padded plaintext into blocks
let num_blocks: usize = padded_m.len() / BLOCK_SIZE;
let mut plaintext_blocks: Vec<[u8; BLOCK_SIZE]> = vec![];
for i in 0..num_blocks {
let mut block: [u8; BLOCK_SIZE] = [0u8; BLOCK_SIZE];
block.copy_from_slice(&padded_m[i*BLOCK_SIZE..(i+1)*BLOCK_SIZE]);
plaintext_blocks.push(block);
}
println!("[+] Plaintext blocks: {:?}", plaintext_blocks);
// loop over every block and encrypt with CBC chaining - do ECB first to debug
let mut ciphertext: Vec<[u8; BLOCK_SIZE]> = vec![];
for i in 0..num_blocks {
if i == 0 {
plaintext_blocks[i] = xor_blocks(&plaintext_blocks[i], &iv);
}
else {
plaintext_blocks[i] = xor_blocks(&plaintext_blocks[i], &ciphertext[i - 1]);
}
let mut state = block_to_state(&plaintext_blocks[i]);
// all the block cipher functionality is called here
cipher(&mut state, &round_keys);
ciphertext.push(state_to_block(&state));
}
println!("[+] Ciphertext: {:?}", ciphertext);
return ciphertext;
}
// decryption function for AES-256 CBC
pub fn decrypt_cbc(ciphertext: &Vec<[u8; BLOCK_SIZE]>, round_keys: [[u8; ROUND_KEY_LEN]; NUM_ROUND_KEYS], iv: [u8; BLOCK_SIZE]) -> Result<(), AesError>{
println!("[+] Decrypting ciphertext...");
// store the plaintext blocks in a vector
let mut plaintext_blocks: Vec<[u8; BLOCK_SIZE]> = vec![];
// iterate over each ciphertext block and perform inverse cipher
for i in 0..ciphertext.len() {
// convert the ciphertext block to a 4x4 state array
let mut state = block_to_state(&ciphertext[i]);
// all the inverse cipher functionality is called here
inv_cipher(&mut state, &round_keys);
// if i = 0 xor with the IV
if i == 0 {
plaintext_blocks.push(xor_blocks(&state_to_block(&state), &iv));
}
else {
plaintext_blocks.push(xor_blocks(&state_to_block(&state), &ciphertext[i - 1]));
}
}
remove_padding(&mut plaintext_blocks)?; // error propagation
// print the plaintext as ascii
println!("[+] Converting resulting plaintext to ascii");
for i in 0..plaintext_blocks.len() {
for j in 0..BLOCK_SIZE {
if plaintext_blocks[i][j] != 0 {
print!("{}", plaintext_blocks[i][j] as char);
}
}
}
println!(); // make sure terminal prompt doesn't appear to mangle stdout
Ok(())
}
6.0 Mathematical Prerequisites
It is entirely possible to implement AES without understanding any of the underlying math that makes the cipher functional and secure. The following section will explain some of the mathematical concepts that appear in the AES cipher, but can be skipped for readers who are more interested in just the code and functionality.
6.1 Representing Bytes as Polynomials
Any byte value can be represented as a polynomial. For example, 01011001 can be represented as x^6 + x^4 + x^3 + 1 . The coefficients for each term in the polynomial is an element of GF(2), and the byte values themselves are members of GF(2⁸).
6.2 Field Theory Introduction
This article will not dive deeply into abstract algebra, but for readers unfamiliar with field theory, the formal definition of a field is given below⁸:
A field is a set, F, together with two binary operations on F called addition and multiplication. A binary operation on F is a mapping F x F -> F that is, a correspondance that associates each ordered pair of elements of F a uniquely determined element of F.
These operations (addition and multiplication) are required to satisfy the following properties, referred to as field axioms: associativity, commutativity, additive and multiplicative identity, additive and multiplicative inverses, distributivity⁸.
6.2 Addition in GF(2⁸)
Addition in GF(2⁸) is equivalent to XOR. This is because the coefficients of each term in the polynomials is either 1 or 0. Adding two terms of the same order is equivalent to 2, which reduces to 0 in GF(2⁸). Thus, 1 + 1 = 0, 1 + 0 = 1, 0 + 0 = 0, and 0 + 1 = 1, which is exactly how XOR behaves. The following three representations of addition are equivalent²:

3 forms of addition
6.3 Multiplication in GF(2⁸)
Multiplication in GF(2⁸) follows two steps. First, the polynomials that represent the two bytes being multiplied are multiplied as polynomials. Second, the resulting polynomial is reduced modulo the fixed, irreducible, polynomial m(x) = x^8 + x^4 + x^3 + x + 1 . Within both of these steps, the coefficients are reduced modulo 2, as previously mentioned². Calculating b(x)*c(x) follows these steps, and the modular reduction by m(x) can be computed at all intermediate steps. This reduction ensures the resulting polynomial stays within the finite field, by XORing x^8 in case it were set to 1.
7.0 Rust Code Demonstration
The following section shows the wrapper code over all the previously shown functionality, and its output.
mod aes;
use rand::prelude::*;
pub use crate::aes::aes_256;
// main function
fn main() {
println!("[+] Starting AES-256 CBC Oracle");
// secret key - 256 bits long, 32 bytes
const AES_256_KEY: [u8; 32] = [
0x60, 0x3d, 0xeb, 0x10, 0x15, 0xca, 0x71, 0xbe, 0x2b, 0x73, 0xae, 0xf0, 0x85, 0x7d, 0x77, 0x81,
0x1f, 0x35, 0x2c, 0x07, 0x3b, 0x61, 0x08, 0xd7, 0x2d, 0x98, 0x10, 0xa3, 0x09, 0x14, 0xdf, 0xf4
];
// generate a random IV for use
let mut rng = rand::rng();
let mut iv = [0u8; 16];
rng.fill(&mut iv);
// key expansion
const NUM_ROUND_KEYS: usize = 15; // 15 round keys for AES-256
const WORD_LEN: usize = 4;
const TOTAL_WORDS: usize = NUM_ROUND_KEYS * WORD_LEN;
let w: [[u8; WORD_LEN]; TOTAL_WORDS] = aes_256::expand_key(&AES_256_KEY);
let round_keys = aes_256::group_round_keys(&w);
// encryption
let plaintext = String::from("hello world, goodbye world"); // heap-allocated string
let ciphertext = aes_256::encrypt_cbc(plaintext, round_keys, iv);
// decryption
let _ = aes_256::decrypt_cbc(&ciphertext, round_keys, iv);
}
[+] Starting AES-256 CBC Oracle
[+] Performing AES Key Expansion Algorithm
[+] Converting master key to WORDs
[+] Master key words: [[96, 61, 235, 16], [21, 202, 113, 190], [43, 115, 174, 240], [133, 125, 119, 129], [31, 53, 44, 7], [59, 97, 8, 215], [45, 152, 16, 163], [9, 20, 223, 244]]
[+] Finished generating round keys: [[96, 61, 235, 16], [21, 202, 113, 190], [43, 115, 174, 240], [133, 125, 119, 129], [31, 53, 44, 7], [59, 97, 8, 215], [45, 152, 16, 163], [9, 20, 223, 244], [155, 163, 84, 17], [142, 105, 37, 175], [165, 26, 139, 95], [32, 103, 252, 222], [168, 176, 156, 26], [147, 209, 148, 205], [190, 73, 132, 110], [183, 93, 91, 154], [213, 154, 236, 184], [91, 243, 201, 23], [254, 233, 66, 72], [222, 142, 190, 150], [181, 169, 50, 138], [38, 120, 166, 71], [152, 49, 34, 41], [47, 108, 121, 179], [129, 44, 129, 173], [218, 223, 72, 186], [36, 54, 10, 242], [250, 184, 180, 100], [152, 197, 191, 201], [190, 189, 25, 142], [38, 140, 59, 167], [9, 224, 66, 20], [104, 0, 123, 172], [178, 223, 51, 22], [150, 233, 57, 228], [108, 81, 141, 128], [200, 20, 226, 4], [118, 169, 251, 138], [80, 37, 192, 45], [89, 197, 130, 57], [222, 19, 105, 103], [108, 204, 90, 113], [250, 37, 99, 149], [150, 116, 238, 21], [88, 134, 202, 93], [46, 47, 49, 215], [126, 10, 241, 250], [39, 207, 115, 195], [116, 156, 71, 171], [24, 80, 29, 218], [226, 117, 126, 79], [116, 1, 144, 90], [202, 250, 170, 227], [228, 213, 155, 52], [154, 223, 106, 206], [189, 16, 25, 13], [254, 72, 144, 209], [230, 24, 141, 11], [4, 109, 243, 68], [112, 108, 99, 30]]
[+] Grouping round keys into list of lists
[+] Grouped round keys: [[96, 61, 235, 16, 21, 202, 113, 190, 43, 115, 174, 240, 133, 125, 119, 129], [31, 53, 44, 7, 59, 97, 8, 215, 45, 152, 16, 163, 9, 20, 223, 244], [155, 163, 84, 17, 142, 105, 37, 175, 165, 26, 139, 95, 32, 103, 252, 222], [168, 176, 156, 26, 147, 209, 148, 205, 190, 73, 132, 110, 183, 93, 91, 154], [213, 154, 236, 184, 91, 243, 201, 23, 254, 233, 66, 72, 222, 142, 190, 150], [181, 169, 50, 138, 38, 120, 166, 71, 152, 49, 34, 41, 47, 108, 121, 179], [129, 44, 129, 173, 218, 223, 72, 186, 36, 54, 10, 242, 250, 184, 180, 100], [152, 197, 191, 201, 190, 189, 25, 142, 38, 140, 59, 167, 9, 224, 66, 20], [104, 0, 123, 172, 178, 223, 51, 22, 150, 233, 57, 228, 108, 81, 141, 128], [200, 20, 226, 4, 118, 169, 251, 138, 80, 37, 192, 45, 89, 197, 130, 57], [222, 19, 105, 103, 108, 204, 90, 113, 250, 37, 99, 149, 150, 116, 238, 21], [88, 134, 202, 93, 46, 47, 49, 215, 126, 10, 241, 250, 39, 207, 115, 195], [116, 156, 71, 171, 24, 80, 29, 218, 226, 117, 126, 79, 116, 1, 144, 90], [202, 250, 170, 227, 228, 213, 155, 52, 154, 223, 106, 206, 189, 16, 25, 13], [254, 72, 144, 209, 230, 24, 141, 11, 4, 109, 243, 68, 112, 108, 99, 30]]
[+] Encrypting message: hello world, goodbye world
[+] Message length: 26
[+] Applying PKCS#7 padding
[+] Applying 6 bytes of padding
[+] Padded message: [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 44, 32, 103, 111, 111, 100, 98, 121, 101, 32, 119, 111, 114, 108, 100, 6, 6, 6, 6, 6, 6]
[+] Plaintext blocks: [[104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 44, 32, 103, 111, 111], [100, 98, 121, 101, 32, 119, 111, 114, 108, 100, 6, 6, 6, 6, 6, 6]]
[+] Ciphertext: [[181, 31, 29, 180, 190, 9, 87, 223, 174, 69, 211, 43, 80, 22, 13, 208], [171, 88, 30, 52, 78, 16, 47, 71, 96, 17, 46, 12, 124, 1, 61, 171]]
[+] Decrypting ciphertext...
[+] Verifying and removing PKCS#7 padding
[+] Converting resulting plaintext to ascii
hello world, goodbye world
8.0 References
[1] https://www.youtube.com/watch?v=lXn40BgVGLI, https://www.youtube.com/watch?v=V7E5wpGaLSc [2] https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197-upd1.pdf [3] https://en.wikipedia.org/wiki/Advanced_Encryption_Standard [4] https://en.wikipedia.org/wiki/Federal_Information_Processing_Standards [5] https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38a.pdf [6] https://en.wikipedia.org/wiki/AES_key_schedule [7] https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation [8] https://en.wikipedia.org/wiki/Field_(mathematics)
메타데이터
- post_id
- b4cf9dd5e453
- slug
- complete-guide-to-writing-aes-256-cbc-in-rust-b4cf9dd5e453
- url
- https://medium.com/@cdeclanx90/complete-guide-to-writing-aes-256-cbc-in-rust-b4cf9dd5e453
- canonical_url
- https://medium.com/@cdeclanx90/complete-guide-to-writing-aes-256-cbc-in-rust-b4cf9dd5e453
- author_url
- https://medium.com/@cdeclanx90
- status
- ok
- fetched_at
- 2026-06-25 07:00:49