1. Introduction
The filesystem assumes a central role in organizing files on our machine. It is the filesystem that orchestrates the management and retrieval of requested information as we navigate through the files on our device.
In this tutorial, we explore the basic building blocks of a Linux filesystem: superblock, inode, dentry, and file. We’ll also check how we can extract information from the disk, interpret it, and finally reach the content of a file.
2. Disk Organization
The disk’s memory locations are divided into logical blocks, each storing different information. This information is kept in a hierarchical way to reach the contents of a file.
If we look at it from a high level, we’ve got a superblock at the top that stores the structure of the logical blocks in the disk. Then, we’ve got the block group descriptors that hold the usage of these blocks and the location of the inode table. The inode table further contains the metadata and extents about files. These extents hold the location of the block number where the directory entries or dentry are stored. Finally, the dentry stores the file name and the block information, which is where we can find the actual file content.
High-level disk layout:
3. Superblock
The superblock stores the overall filesystem layout in the disk. It starts at an offset of 1024 bytes from the disk’s beginning and spans 1024 bytes. It holds block size, block count, group size, and inode count among other disk layout parameters as described in the superblock data structure.
3.1. Extracting Disk Information
We can extract the superblock information from the disk:
$ df -hT /
Filesystem Type Size Used Avail Use% Mounted on
/dev/sda1 ext4 49G 41G 5.5G 89% /
$ sudo dd if=/dev/sda1 of=superblock.dat bs=1024 count=1 skip=1 status=none
Here, the df command prints utilization and type details for the root partition. The device name is /dev/sda1 and the filesystem type is ext4.
The dd command extracts the superblock data from the disk. It skips 1 block of 1024 bytes to the starting location of the superblock. Then it reads 1 block of size 1024 bytes, which is the superblock information. Finally, the data is saved to superblock.dat.
3.2. Important Fields
From the superblock data structure, we can see the block size information is present at offset 0x18.
Let’s use the hexdump command to read it:
$ hexdump -e '"s_log_block_size: " /4 "%d\n"' -s 0x18 -n 4 superblock.img
s_log_block_size: 2
The options used in the command are:
- –e: specifies the output format string
- /4: indicate to read as 4 byte integer value
- %d: to print as decimal
- \n: to print a new line
- –s: skips 0x18 bytes
- –n: length of bytes to dump
As we can see, the above command prints the block size as 2. It is a log2 value. To convert it to decimal, we can use the formula 2^(10+s_log_block_size). For the value 2, we get 4096 (2^12) as the block size.
Likewise, we can extract the rest of the parameters in the superblock based on the data structure.
Some of the key fields are:
Fields
Offset
Size
Value
Block count
0x4
32
13106688
Block size
0x18
32
0x2
Blocks per group
0x20
32
0x8000 (32768)
Inodes per group
0x28
32
0x2000 (8192)
Inode size
0x58
16
0x100 (256)
From this, we can infer that the memory locations on the disk are logically clubbed together as a block of size 4096. These blocks are further combined into block groups having 32768 blocks. And there are 400 (13106688/32768) block groups.
Once we get these values, we can peek into the block group descriptors to find the inode information as the next step.
4. Block Group Descriptors
A block group descriptor serves as a table of contents for groups. It stores the block numbers where we can find the data block bitmap, the inode bitmaps, the inode table, and various other parameters. Placed in the second block, it stores these data for all the groups available on the disk.
The data in a block group descriptor can be interpreted following the data structure here. The size of a block group descriptor table is 64 bytes. The first 64 bytes are for the first group, the second 64 for the second group, and so on. The location of the descriptor table for any group can be calculated using the formula 64 * (group – 1).
We’re mainly looking for the inode table location and that at offset 0x8:
$ sudo dd if=/dev/sda1 of=bgd.dat bs=4096 count=1 skip=1 status=none
$ hexdump -v -e '"Inode table: " /4 "%d\n"' -s 0x8 -n 4 bgd.dat
Inode table: 1064
The dd command extracts one block of data, skipping the first one. The hexdump command printed the block address of the inode table as 1064.
5. Inodes
Inodes or index nodes serve as essential data structures housing metadata pertinent to files and directories. It encompasses details such as timestamps, permissions, size, and pointers to file content data blocks.
Every file stored on the disk is associated with an inode number. This number facilitates locating its respective inode table and the block storing the file content. The inode numbers from 1 to 10 are reserved for special purposes. Of that, the inode number 2 denotes the root folder (/). And there is no inode zero.
Given an inode number, we can find where the block group resides by:
group = (inode_number - 1) / inodes_per_group
We can also find the index in the inode table by:
index = (inode_number - 1) % inodes_per_group
Hence, for the root folder with inode number 2:
group = (2 - 1) / 8192 = 0
index = (2 -1) % 8192 = 1
Thus the inode table resides in the first group (group 0) and the second entry (index 1) in the inode table corresponds to the root folder.
We’ve learned that for block group 0, the inode table starts at block 1064 and that the size of the inode table is 256 bytes. The first inode is for bad blocks, so we can skip the first 256 bytes. The next entry corresponds to the root folder.
5.1. Reading From the Disk
Let’s extract the inode table content for the root folder:
$ sudo dd if=/dev/sda1 of=root_inode_table.img bs=4096 skip=1064 count=1 status=none
$ hexdump -s 256 -n 256 root_inode_table.img
0000100 41ed 0000 1000 0000 ac16 663b 9277 6037
0000110 9277 6037 0000 0000 0000 001a 0008 0000
0000120 0000 0008 0027 0000 f30a 0001 0004 0000
0000130 0000 0000 0000 0000 0001 0000 2428 0000
Here we’ve used the dd command to save the 1064th block in the root_inode_table.img file. Then, using the hexdump command, we extracted the second entry in the inode table.
5.2. Interpreting the Data
In ext4, bytes are stored in little-endian format.
Let’s look at some important fields:
Field Name
Offset
Length
Value
Description
Mode
0x0
16
0x41ed (40755 octal)
0x4000 represents directory
Size
0x4
32
0x1000 (4096)
Size of the file/folder
Last access time
0x8
32
0x663bac16 (1715186710)
$ date -d @1715186710
Wed May 8 22:15:10 IST 2024
Last data modification time
0x10
32
0x60379277 (1614254711)
Thu Feb 25 17:35:11 IST 2021
Last inode change time
0xc
32
0x60379277 (1614254711)
Thu Feb 25 17:35:11 IST 2021
Running the stat command on the root folder also gives a similar output:
$ stat /
File: /
Size: 4096 Blocks: 8 IO Block: 4096 directory
Device: 801h/2049d Inode: 2 Links: 26
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2024-05-08 22:15:10.186009447 +0530
Modify: 2021-02-25 17:35:11.121430326 +0530
Change: 2021-02-25 17:35:11.121430326 +0530
Birth: -
5.3. Extents
One of the important portions in the inode table is the extent tree section. It has a length of 60 bytes from offset 0x28. At first, a 12-byte-long extent tree header appears. Then, the rest of the 48 bytes can contain either the extent tree index or the extent itself, both of size 12 bytes. Consequently, we can store 4 indexes or 4 extents. If file information cannot be represented in those 4 extents, the extent tree index is stored in all of the four.
The attributes in the extent header and their values from the above dump are:
Offset
Length
Field
Value
0x28
16
Magic number
0xF30A
0x2a
16
Number of entries
0x01
0x2c
16
Max entries
0x4
0x2e
16
Tree depth
0x0
0x30
32
Generation id
0x0
In our case, the tree depth is zero. Hence, the next bytes point to an extent, not an index.
Let’s look at the values in it:
Offset
Length
Field
Value
0x34
32
Logical block
0x0
0x38
16
Number of blocks
01
0x3a
16
High 16-bits of starting block
0x0
0x3c
32
Low 32-bits of starting block
0x2428 (9256)
In the extent details, we find the block number 0x2428 (9256). This block holds the directory information for the root folder.
6. Dentry
The directory entry or dentry maps the inode number to a file name. It includes the filename and its associated metadata, such as the inode number, file type, permissions, timestamps, and pointers to the actual data blocks or inodes for files and directories. We can follow the bytes by looking at the data structure here.
Let’s dump the content:
$ hexdump -s 37912576 -n 512 -C disk_image.img
02428000 02 00 00 00 0c 00 01 02 2e 00 00 00 02 00 00 00 |................|
02428010 0c 00 02 02 2e 2e 00 00 0b 00 00 00 14 00 0a 02 |................|
02428020 6c 6f 73 74 2b 66 6f 75 6e 64 00 00 0c 00 00 00 |lost+found......|
02428030 10 00 08 01 73 77 61 70 66 69 6c 65 01 00 26 00 |....swapfile..&.|
02428040 0c 00 03 02 65 74 63 00 01 00 28 00 10 00 05 02 |....etc...(.....|
...
Here, we can see the directories in the root folder.
Let’s interpret the bytes:
Offset
Length
Field
Value
0x0
32
Inode number
2
0x4
16
Record length
0xc
0x6
8
Name length
1
0x7
8
File type
2
0x8
char
Name
. (the dot directory)
0xc
32
Inode number
2
0x10
16
Record length
0xc
0x12
8
Name length
2
0x13
8
File type
2
0x14
char
Name
..
This is repeated. Thus we see these directories:
- dot – inode 02
- dotdot – inode 02
- lost+found – inode 0b
- swapfile – inode 0c
- etc – inode 00260001 = 2490369
7. File
The file points to the actual content on the disk.
Now we know how to list the root folder. Next, let’s check how we can arrive at a file. We’ll try listing the directories in the /etc folder. And read the content of one of the files in that folder.
7.1. Listing the /etc Folder
We found that the inode number for the /etc folder is 2490369. Next, we need to figure out which block group it belongs to.
Since we’ve 8192 inodes in a group, the inode number 2490369 should fall in the block group (2490369 – 1) / 8192 = 304.
To get more details about the */*etc folder, we need to look at the inode table for group 304. The location of the inode table is stored in the block descriptor group. As we’ve seen earlier the block group descriptor for group 304 will be at offset 304 * 64 = 19456. The block group descriptor is in the second block. Adding the first block size, the final offset becomes 19456 + 4096 = 23552 (0x5c00).
7.2. Reading the Disk
This location comes in the 5th block (23552/4096 = 4.75). Let’s read 5 blocks from the disk and dump the bytes.
Looking at the value of this location:
$ sudo dd if=/dev/sda1 bs=4096 count=5 status=none | hexdump -s 23552 -n 64
0005c00 0000 0098 0010 0098 0020 0098 5dac 135e
0005c10 01b8 0004 0000 0000 9176 7216 1352 8aee
0005c20 0000 0000 0000 0000 0000 0000 0000 0000
0005c30 0000 0000 0000 0000 364b 63b9 0000 0000
The block number of the inode table is at offset 0x8. In the above dump, it is at 0x5c08. And the value is 0x00980020 (9961504).
Let’s extract the bytes at that block:
$ sudo dd if=/dev/sda1 bs=4096 skip=9961504 count=1 status=none | hexdump -n 256
0000000 41ed 0000 3000 0000 4d90 6637 c75e 6620
0000010 c75e 6620 0000 0000 0000 0089 0018 0000
0000020 1000 0008 04f7 0000 f30a 0002 0004 0000
0000030 0000 0000 0000 0000 0001 0000 2020 0098
As we did earlier, we can see the block information in the inode table:
- 0x28:le16:magic number – 0xF30A
- 0x2a:le16:number of entries – 02
- 0x2c:le16:maximum number of entries – 04
- 0x2e:le16:tree depth – 00
- 0x30:le32:tree generation – 00
Since the depth is zero, the next data will be the extent information:
- 0x34:le32:logical block – 00
- 0x38:le16:number of blocks covered – 01
- ox3a:le16:high 16 bits of starting physical block – 0x0
- 0x3c:le32:low 32 bits of starting physical block – 0x00982020 = 9969696
Now, let’s extract the directory info at block 9969696:
$ sudo dd if=/dev/sda1 bs=4096 skip=9969696 count=2 status=none | hexdump -C
00000000 01 00 26 00 0c 00 01 02 2e 00 00 00 02 00 00 00 |..&.............|
00000010 f4 0f 02 02 2e 2e 00 00 00 00 00 00 01 08 00 00 |................|
00000020 fb 01 02 00 01 00 00 00 e0 32 e2 74 02 00 00 00 |.........2.t....|
00000030 49 6d 61 67 65 4d 61 67 69 63 6b 2d 36 00 00 00 |ImageMagick-6...|
00000040 04 00 26 00 18 00 0e 02 4e 65 74 77 6f 72 6b 4d |..&.....NetworkM|
00000050 61 6e 61 67 65 72 00 00 05 00 26 00 14 00 0a 02 |anager....&.....|
...
00000890 6e 74 00 00 80 00 26 00 0c 00 03 02 78 64 67 00 |nt....&.....xdg.|
000008a0 81 00 26 00 14 00 09 01 2e 70 77 64 2e 6c 6f 63 |..&......pwd.loc|
000008b0 6b 00 00 00 82 00 26 00 14 00 0c 01 61 64 64 75 |k.....&.....addu|
000008c0 73 65 72 2e 63 6f 6e 66 83 00 26 00 14 00 0a 01 |ser.conf..&.....|
...
Above, we can see the list of files inside the /etc folder. There it lists the folders first. Further down, it starts listing the files. Let’s look at the adduser.conf file. Immediately after the .pwd.loc filename starts the data for adduser.conf file. The first byte indicates the inode number for that file is 0x00260082 (2490498).
If we run the calculations, we get the following details:
- block group = (2490498 – 1) / 8192 = 304
- earlier we saw the inode table for the 304 group starts at 9961504
- index in inode table = (2490498 – 1) % 8192 = 129
- Byte offset of inode table = 129 * 256 = 33024
- Blocks to omit = 33024 / 4096 = 8
- Final block number = 9961504 + 8 = 9961512
- Offset in block = 33024 % 4096 = 256
7.3. Dumping the File Content
Let’s extract the inode data from the disk:
$ sudo dd if=/dev/sda1 bs=4096 skip=9961512 count=1 status=none | hexdump -s 256 -n 256
0000100 81a4 0000 0bd4 0000 50e2 6637 f45a 5fbd
0000110 17f3 5ae2 0000 0000 0000 0001 0008 0000
0000120 0000 0008 0001 0000 f30a 0001 0004 0000
0000130 0000 0000 0000 0000 0001 0000 8001 0098
...
At offset 0x13c, we get the starting physical block as 0x00988001 = 9994241. And at offset 0x104 we get the size of the file 0xbd4 (3028).
Let’s dump the block at 9994241:
$ sudo dd if=/dev/sda1 bs=4096 skip=9994241 count=1 status=none | hexdump -n 3028 -C
00000000 23 20 2f 65 74 63 2f 61 64 64 75 73 65 72 2e 63 |# /etc/adduser.c|
00000010 6f 6e 66 3a 20 60 61 64 64 75 73 65 72 27 20 63 |onf: `adduser' c|
00000020 6f 6e 66 69 67 75 72 61 74 69 6f 6e 2e 0a 23 20 |onfiguration..# |
00000030 53 65 65 20 61 64 64 75 73 65 72 28 38 29 20 61 |See adduser(8) a|
...
Finally, we arrive at the content of the file.
8. Conclusion
In this article, we’ve seen the different data structures in the ext4 filesystem and how they work together to store the disk structure and file contents. We’ve also explored listing the directories and file content navigating through these data structures.