Linux syscalls open, openat and openat2
On Linux, when you are collecting filesystem events using bpftrace (which uses eBPF), you may get an error something like this:
ERROR: No matches for tracepoint syscalls:sys_exit_openBut open() is a very common system call, so how can this be? Let’s look at the available open syscall tracepoints for when they are called (there's a corresponding exit tracepoints for each that gives the return values).
# grep -i 'sys_enter_*open*' /sys/kernel/tracing/available_events
syscalls:sys_enter_openat2
syscalls:sys_enter_openat
syscalls:sys_enter_open_tree
syscalls:sys_enter_open_by_handle_at
Where is sys_enter_open?
Spoiler: it’s not there because on ARM64 (aarch64) architecture, open() is silently mapped to openat(). open() is still present on x86_64 systems so the tracepoint also exists.
I think it’s worthwhile going through the history of the various open* calls, why they were added, and why there is no open() syscall on aarch64. There’s a lot to learn along the way about how Linux handles files.
In the beginning, there was open()
Let’s look at how Linux kernel 1.0 (March 1994) implemented the open() call, mainly because the code is simple and illustrates the main problem with open (thank you to kernel.org for continuing to host all the historical versions):
asmlinkage int sys_open(const char * filename,int flags,int mode)
{
char * tmp;
int error;
error = getname(filename, &tmp);
if (error)
return error;
error = do_open(tmp,flags,mode);
putname(tmp);
return error;
}
So, it copies the supplied file path into the tmp location, then calls the do_open() function to do the actual work.
Here is do_open:
int do_open(const char * filename,int flags,int mode)
{
struct inode * inode;
struct file * f;
int flag,error,fd;
for(fd=0 ; fd<NR_OPEN ; fd++)
if (!current->filp[fd])
break;
if (fd>=NR_OPEN)
return -EMFILE;
FD_CLR(fd,&current->close_on_exec);
f = get_empty_filp();
if (!f)
return -ENFILE;
current-&gt;filp&#91;fd&#93; = f;
f-&gt;f_flags = flag = flags;
f-&gt;f_mode = (flag+1) &amp; O_ACCMODE;
if (f-&gt;f_mode)
flag++;
if (flag &amp; (O_TRUNC | O_CREAT))
flag |= 2;
error = open_namei(filename,flag,mode,&amp;inode,NULL);
if (error) {
current-&gt;filp&#91;fd&#93;=NULL;
f-&gt;f_count--;
return error;
}
f-&gt;f_inode = inode;
f-&gt;f_pos = 0;
f-&gt;f_reada = 0;
f-&gt;f_op = NULL;
if (inode-&gt;i_op)
f-&gt;f_op = inode-&gt;i_op-&gt;default_file_ops;
if (f-&gt;f_op &amp;&amp; f-&gt;f_op-&gt;open) {
error = f-&gt;f_op-&gt;open(inode,f);
if (error) {
iput(inode);
f-&gt;f_count--;
current-&gt;filp&#91;fd&#93;=NULL;
return error;
}
}
f-&gt;f_flags &amp;= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);
return (fd);
}
This was single-CPU, single-core code in those days, so only one task would be executing at a time, and its context would be represented by current, which is a pointer to a task_struct. If the task sleeps, the context is saved and current is pointed back to it when the task revives. So, some things in this code that could be race conditions in multi-processor situations are not.
We’ll ignore some other problems with this very early kernel code that aren’t relevant to this discussion.
There is one important race condition here that persisted for a long time: the call to open_namei() does some checks that are important for security purposes, and returns an error if the checks fail, for example:
if (inode == (*p)->executable) {
iput(inode);
return -ETXTBSY;
}Which returns a “text file is busy” error if you’re trying to modify an executable file that’s currently running (confusing error message, but it made sense to the authors at the time).
Then later, back in the do_open function, the actual open happens.
f->f_op->open(inode,f);This is called a “Time of check to time of use” (TOCTOU) race condition, and it can lead to security vulnerabilities. An example is CVE-2024-43882 which affects the exec.c call, but the core problem was the same: a permission check happened before the file execution itself (without locking the file first), so an attacker had the opportunity to swap out the benign file with a malicious one before the setuid bit was applied. The fix was to do the permission check and execution inside an inode_lock/inode_unlock block so that it’s atomic.
How was open() fixed?
The race condition was recognized a long time ago, with a fix implemented in kernel version 2.6.16 (March 2006), by supplying a new function called openat(). Both sys_open and sys_openat still called the do_open function, and the guts of the open operations was in the open_namei() function, where critical check/use pairs were enclosed in mutex lock blocks:
mutex_lock(&dir->d_inode->i_mutex);
// check here
// use here
mutex_unlock(&dir->d_inode->i_mutex);This removes the race condition because no one else can modify the file while it's locked.
So by 2.6.16 we have two syscalls, open() and openat(), both of which are safe. This was pre-eBPF times, so there were no corresponding eBPF trace endpoints.
When was ARM64 support added?
ARM64 support entered the kernel starting with Linux 3.7 (December 2012), with an application binary interface (ABI) that included openat(), but not open(). The idea was that open() was already deprecated, so in glibc open() on aarch64 just calls openat() underneath.
When was eBPF added?
Linux 4.1 (June 2015) added the ability to do eBPF using kprobes, but Linux 4.7 (July 2016) added the ability to attach eBPF directly to tracepoints supplied by the kernel. It’s at this point that
syscalls:sys_enter_openat
syscalls:sys_enter_openbecame available on x86_64 systems.
For aarch64, since open() was not actually a syscall there was only the tracepoint
syscalls:sys_enter_openat
from Linux 4.7 until openat2() was added. On aarch64 any call to open() would show up in eBPF as an openat() call instead.
When was openat2() added?
Linux 5.6 (March 2020) added the openat2() call, and changed do_sys_open() into a wrapper for do_sys_openat2(), by preparing the open_how struct using a build_open_how() function.
struct open_how {
__u64 flags;
__u64 mode;
__u64 resolve;
};
inline struct open_how build_open_how(int flags, umode_t mode)
{
struct open_how how = {
.flags = flags & VALID_OPEN_FLAGS,
.mode = mode & S_IALLUGO,
};
/* O_PATH beats everything else. */
if (how.flags & O_PATH)
how.flags &= O_PATH_FLAGS;
/* Modes should only be set for create-like flags. /
if (!WILL_CREATE(how.flags))
how.mode = 0;
return how;
}openat2() added the resolve flags to define how components of the path are resolved. For example, RESOLVE_IN_ROOT ensures that the directory referred to by dirfd is the root, and only files and directories below that root can be accessed. These kinds of options substantially enhance security, and deserve a blog post of their own.
By this time, part of the SYSCALL_DEFINE
macros was to add a tracepoint for the syscall as it was being defined. So the tracepoint for syscall_enter_openat2 also appeared at this time.
So, starting with Linux 5.6 we see
syscalls:sys_enter_openat2
syscalls:sys_enter_openat
syscalls:sys_enter_open // not on aarch64
and that’s still the situation today (July 2026).
Perj
You can add a great description here to make the blog readers visit your landing page.
ESC
Type to search...
Loading...
No results found
↑↓ Navigate
↵ Open
esc Close