A Linux systems-forensics project for reconstructing historical process file-descriptor tables from an strace recording.
The project models a batch job whose processes were traced while running. At several checkpoints, the job was frozen and its /proc/<pid>/fd and /proc/<pid>/fdinfo state was captured.
The objective is to replay the system-call history and reproduce the descriptor state that the Linux kernel exposed at each checkpoint.
A file descriptor is not simply:
descriptor number → pathname
A correct reconstruction needs to account for several layers of kernel state:
process
↓
descriptor table
↓
file descriptor
↓
open file description
↓
filesystem object / pipe / socket
Different descriptors can share the same open file description, which means they may also share:
- file position
O_APPENDO_NONBLOCK
At the same time, descriptor-specific state such as close-on-exec remains associated with the descriptor entry itself.
fd-table-rebuild/
├── environment/
├── solution/
├── tests/
├── instruction.md
└── task.toml
Defines the trace inputs, checkpoint semantics, descriptor fields, and required output format.
Contains the reproducible runtime and captured trace data.
Contains the reference reconstruction implementation.
Contains the independent verifier.
Defines task metadata and execution configuration.
The primary evidence is:
/app/data/trace.txt
which contains the system-call trace.
Checkpoint times are provided in:
/app/data/checkpoints.json
Additional capture conventions and initial state are documented in:
/app/data/NOTES.md
The solver must reconstruct descriptor state from these sources rather than inspecting a running copy of the original job.
For every live process at each checkpoint, the reconstruction reports every open descriptor.
Each descriptor contains six values:
target
pos
mode
append
nonblock
cloexec
The name that /proc/<pid>/fd/<fd> would expose.
For regular files this is the current absolute path.
If a file no longer has a filesystem name while a process still holds it open, the target is represented with:
(deleted)
Pipes and sockets use the naming convention defined by the capture notes.
The current file position reported by fdinfo.
For pipes and sockets:
pos = 0
The descriptor access mode:
r
w
rw
Whether O_APPEND is enabled.
Whether O_NONBLOCK is enabled.
Whether the descriptor will be closed during a successful exec.
One of the key Linux concepts in this project is the distinction between a file descriptor and an open file description.
For example:
fd 5 ──┐
├── shared open file description
fd 9 ──┘
│
├── file position
├── O_APPEND
└── O_NONBLOCK
Operations on one descriptor can therefore affect another descriptor when both reference the same underlying open file description.
This commonly happens through:
fork- descriptor duplication
- descriptor transfer between processes
The replay must account for descriptor duplication operations such as:
dup
dup2
dup3
fcntl(F_DUPFD...)
A duplicated descriptor normally refers to the same open file description as its source.
That distinction is critical when reconstructing offsets and file status flags.
Threads and processes do not always have independent descriptor state.
Threads within a process can share a descriptor table, while process creation can either copy or share descriptor-related state depending on the operation involved.
The reconstruction therefore tracks thread-group and process lifetime rather than treating every traced task ID as an independent process.
Threads are not emitted as separate processes in the final artifact.
Process creation and execution affect descriptor state differently.
Relevant behavior includes:
fork / clone
↓
inherit descriptor references
exec
↓
remove descriptors marked close-on-exec
Correct replay must preserve these lifetime transitions.
Unix-domain sockets can transfer descriptors between processes using SCM_RIGHTS.
The receiving process obtains a new descriptor, but the underlying open file description can still be shared with the sender.
This means file position and status changes can propagate across processes even when their descriptor numbers differ.
The trace also includes IPC descriptors such as:
- pipes
- Unix socket pairs
These do not behave like ordinary seekable files.
Their positions remain zero, while status flags such as non-blocking mode can still matter.
An open descriptor refers to the file object, not permanently to the pathname used when it was opened.
For example:
open("/work/current.log")
↓
descriptor references file object
↓
rename("/work/current.log", "/work/archive.log")
↓
same descriptor now resolves to
/work/archive.log
A reconstruction that permanently stores the original open pathname will therefore be incorrect.
An open file can remain accessible after its directory entry is removed.
For example:
open("result.dat")
↓
fd 7
unlink("result.dat")
↓
fd 7 remains valid
The corresponding /proc target is represented as a deleted file while the open reference remains alive.
Relative path resolution also depends on process working-directory state.
Calls such as:
chdir
openat
must therefore be interpreted using the directory context that existed when the operation occurred.
The project distinguishes open-file-description status from per-descriptor flags.
For example:
O_APPEND
O_NONBLOCK
belong to the shared open file description.
By contrast:
FD_CLOEXEC
belongs to an individual file descriptor.
Mixing these two levels can produce descriptor tables that look plausible while still disagreeing with Linux kernel behavior.
The task is fundamentally a state-replay problem.
Conceptually:
initial process state
↓
system call
↓
descriptor mutation
↓
process creation / exit
↓
filesystem mutation
↓
IPC / descriptor transfer
↓
checkpoint
↓
snapshot descriptor tables
The reconstruction continues across the full trace, emitting state whenever a requested checkpoint is reached.
The solver writes:
/app/output/fd_tables.json
The output has one top-level key:
{
"checkpoints": {}
}Each checkpoint maps live process IDs to their descriptor tables.
A simplified example is:
{
"checkpoints": {
"K01": {
"412": {
"0": {
"target": "/dev/null",
"pos": 0,
"mode": "r",
"append": false,
"nonblock": false,
"cloexec": false
}
}
}
}
}Process IDs and descriptor numbers are represented as JSON object keys.
Exited processes are omitted, and threads are not listed separately.
The complete schema is defined by instruction.md.
The project involves reasoning about Linux operations such as:
open / openat
close
close_range
dup / dup2 / dup3
fcntl
ioctl
pipe / pipe2
socketpair
read / write
lseek
rename
unlink
chdir
fork / clone / vfork
exec
exit
sendmsg / recvmsg
A reconstruction does not necessarily need to implement every Linux syscall in existence—only the semantics required by the supplied capture.
The trace records system calls, while the required output describes kernel state.
Those are related but not identical.
A correct solution must infer the persistent state produced by operations over time:
syscall history
↓
kernel-object relationships
↓
process lifetime
↓
descriptor-table state
↓
checkpoint snapshot
Several descriptor numbers can be correct while their shared position, flags, path, or lifetime is still wrong.
This project exercises:
- Linux system calls
- POSIX process semantics
strace/proc- file descriptors
- open file descriptions
- descriptor inheritance
- IPC
- Unix-domain sockets
SCM_RIGHTS- process and thread lifetimes
- filesystem rename/unlink behavior
- event replay
- systems forensics
- Python
The reconstruction is checked against descriptor tables captured directly from the kernel at the requested checkpoints.
Correctness includes:
live processes
descriptor numbers
targets
positions
access modes
append state
non-blocking state
close-on-exec state
The task expects exact discrete state rather than approximate numeric results.
FD Table Rebuild demonstrates how historical process state can be reconstructed from a syscall trace when the original processes no longer exist.
The core principle is:
Replay kernel object relationships, not just descriptor numbers and path strings.
No license is currently specified.