Bring in Gus' work and other fixes

This commit is contained in:
Andrew Case
2024-10-24 15:15:22 -05:00
parent 45fcf22a40
commit 833b7c5ab4
5 changed files with 415 additions and 62 deletions
@@ -304,6 +304,8 @@ class ELF_CLASS(IntEnum):
ELFCLASS64 = 2
NSEC_PER_SEC = 1e9
PT_OPT_FLAG_SHIFT = 3
PTRACE_EVENT_FORK = 1
+26 -5
View File
@@ -1,6 +1,7 @@
# This file is Copyright 2021 Volatility Foundation and licensed under the Volatility Software License 1.0
# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0
#
import datetime
from typing import Any, Callable, Iterable, List, Tuple
from volatility3.framework import interfaces, renderers
@@ -9,15 +10,16 @@ from volatility3.framework.objects import utility
from volatility3.framework.renderers import format_hints
from volatility3.framework.symbols import intermed
from volatility3.framework.symbols.linux.extensions import elf
from volatility3.plugins import timeliner
from volatility3.plugins.linux import elfs
class PsList(interfaces.plugins.PluginInterface):
class PsList(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface):
"""Lists the processes present in a particular linux memory image."""
_required_framework_version = (2, 0, 0)
_version = (2, 2, 1)
_version = (2, 3, 0)
@classmethod
def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]:
@@ -81,7 +83,7 @@ class PsList(interfaces.plugins.PluginInterface):
@classmethod
def get_task_fields(
cls, task: interfaces.objects.ObjectInterface, decorate_comm: bool = False
) -> Tuple[int, int, int, str]:
) -> Tuple[int, int, int, int, str, datetime.datetime]:
"""Extract the fields needed for the final output
Args:
@@ -96,13 +98,14 @@ class PsList(interfaces.plugins.PluginInterface):
tid = task.pid
ppid = task.parent.tgid if task.parent else 0
name = utility.array_to_string(task.comm)
start_time = task.get_create_time()
if decorate_comm:
if task.is_kernel_thread:
name = f"[{name}]"
elif task.is_user_thread:
name = f"{{{name}}}"
task_fields = (task.vol.offset, pid, tid, ppid, name)
task_fields = (task.vol.offset, pid, tid, ppid, name, start_time)
return task_fields
def _get_file_output(self, task: interfaces.objects.ObjectInterface) -> str:
@@ -177,7 +180,9 @@ class PsList(interfaces.plugins.PluginInterface):
else:
file_output = "Disabled"
offset, pid, tid, ppid, name = self.get_task_fields(task, decorate_comm)
offset, pid, tid, ppid, name, creation_time = self.get_task_fields(
task, decorate_comm
)
yield 0, (
format_hints.Hex(offset),
@@ -185,6 +190,7 @@ class PsList(interfaces.plugins.PluginInterface):
tid,
ppid,
name,
creation_time or renderers.NotAvailableValue(),
file_output,
)
@@ -233,8 +239,23 @@ class PsList(interfaces.plugins.PluginInterface):
("TID", int),
("PPID", int),
("COMM", str),
("CREATION TIME", datetime.datetime),
("File output", str),
]
return renderers.TreeGrid(
columns, self._generator(filter_func, include_threads, decorate_comm, dump)
)
def generate_timeline(self):
pids = self.config.get("pid")
filter_func = self.create_pid_filter(pids)
for task in self.list_tasks(
self.context, self.config["kernel"], filter_func, include_threads=True
):
offset, user_pid, user_tid, _user_ppid, name, creation_time = (
self.get_task_fields(task)
)
description = f"Process {user_pid}/{user_tid} {name} ({offset})"
yield (description, timeliner.TimeLinerType.CREATED, creation_time)
+11 -2
View File
@@ -23,6 +23,7 @@ class TimeLinerType(enum.IntEnum):
MODIFIED = 2
ACCESSED = 3
CHANGED = 4
BOOTTIME = 5
class TimeLinerInterface(metaclass=abc.ABCMeta):
@@ -171,6 +172,10 @@ class Timeliner(interfaces.plugins.PluginInterface):
TimeLinerType.CHANGED,
renderers.NotApplicableValue(),
),
times.get(
TimeLinerType.BOOTTIME,
renderers.NotApplicableValue(),
),
],
)
)
@@ -178,11 +183,11 @@ class Timeliner(interfaces.plugins.PluginInterface):
# Write each entry because the body file doesn't need to be sorted
if fp:
times = self.timeline[(plugin_name, item)]
# Body format is: MD5|name|inode|mode_as_string|UID|GID|size|atime|mtime|ctime|crtime
# Body format is: MD5|name|inode|mode_as_string|UID|GID|size|atime|mtime|ctime|crtime|boottime
if self._any_time_present(times):
fp.write(
"|{} - {}|0|0|0|0|0|{}|{}|{}|{}\n".format(
"|{} - {}|0|0|0|0|0|{}|{}|{}|{}|{}\n".format(
plugin_name,
self._sanitize_body_format(item),
self._text_format(
@@ -197,6 +202,9 @@ class Timeliner(interfaces.plugins.PluginInterface):
self._text_format(
times.get(TimeLinerType.CREATED, "0")
),
self._text_format(
times.get(TimeLinerType.BOOTTIME, "0")
),
)
)
except Exception as e:
@@ -320,6 +328,7 @@ class Timeliner(interfaces.plugins.PluginInterface):
("Modified Date", datetime.datetime),
("Accessed Date", datetime.datetime),
("Changed Date", datetime.datetime),
("Boot Date", datetime.datetime),
],
generator=self._generator(plugins_to_run),
)
@@ -3,11 +3,15 @@
#
import math
import contextlib
import datetime
import dataclasses
from abc import ABC, abstractmethod
from typing import Iterator, List, Tuple, Optional, Union
from volatility3 import framework
from volatility3.framework import constants, exceptions, interfaces, objects
from volatility3.framework.renderers import conversion
from volatility3.framework.constants.linux import NSEC_PER_SEC
from volatility3.framework.objects import utility
from volatility3.framework.symbols import intermed
from volatility3.framework.symbols.linux import extensions
@@ -832,3 +836,97 @@ class PageCache(object):
page = self.vmlinux.object("page", offset=page_addr, absolute=True)
if page:
yield page
@dataclasses.dataclass
class TimespecVol3(object):
"""Internal helper class to handle all required timespec operations, convertions and
adjustments.
NOTE: This is intended for exclusive use with get_boottime() and its related functions.
"""
tv_sec: int = 0
tv_nsec: int = 0
@classmethod
def new_from_timespec(cls, timespec) -> "TimespecVol3":
"""Creates a new instance from a TimespecVol3 or timespec64 object"""
if not isinstance(timespec, (TimespecVol3, extensions.timespec64)):
raise TypeError("It requires either a TimespecVol3 or timespec64 type")
tv_sec = int(timespec.tv_sec)
tv_nsec = int(timespec.tv_nsec)
return cls(tv_sec=tv_sec, tv_nsec=tv_nsec)
@classmethod
def new_from_nsec(cls, nsec) -> "TimespecVol3":
"""Creates a new instance from an integer in nanoseconds"""
# Based on ns_to_timespec64()
if nsec > 0:
tv_sec = nsec // NSEC_PER_SEC
tv_nsec = nsec % NSEC_PER_SEC
elif nsec < 0:
tv_sec = -((-nsec - 1) // NSEC_PER_SEC) - 1
rem = (-nsec - 1) % NSEC_PER_SEC
tv_nsec = NSEC_PER_SEC - rem - 1
else:
tv_sec = tv_nsec = 0
return cls(tv_sec=tv_sec, tv_nsec=tv_nsec)
def to_datetime(self) -> datetime.datetime:
"""Converts this TimespecVol3 to a UTC aware datetime"""
return conversion.unixtime_to_datetime(
self.tv_sec + self.tv_nsec / NSEC_PER_SEC
)
def to_timedelta(self) -> datetime.timedelta:
"""Converts this TimespecVol3 to timedelta"""
return datetime.timedelta(seconds=self.tv_sec + self.tv_nsec / NSEC_PER_SEC)
def __add__(self, timespec) -> "TimespecVol3":
"""Returns a new TimespecVol3 object that sums the current values with those
in the timespec argument"""
if not isinstance(timespec, (TimespecVol3, extensions.timespec64)):
raise TypeError("Cannot add a TimespecVol3 to this object")
result = TimespecVol3(
tv_sec=self.tv_sec + timespec.tv_sec,
tv_nsec=self.tv_nsec + timespec.tv_nsec,
)
result.normalize()
return result
def __sub__(self, timespec) -> "TimespecVol3":
"""Returns a new TimespecVol3 object that subtracts the values in the timespec
argument from the current object's values"""
if not isinstance(timespec, (TimespecVol3, extensions.timespec64)):
raise TypeError("Cannot add a TimespecVol3 to this object")
result = TimespecVol3(
tv_sec=self.tv_sec - timespec.tv_sec,
tv_nsec=self.tv_nsec - timespec.tv_nsec,
)
result.normalize()
return result
def normalize(self):
"""Normalize any overflow in tv_sec and tv_nsec after previous addition or subtractions"""
# Based on kernel's set_normalized_timespec64()
while self.tv_nsec >= NSEC_PER_SEC:
self.tv_nsec -= NSEC_PER_SEC
self.tv_sec += 1
while self.tv_nsec < 0:
self.tv_nsec += NSEC_PER_SEC
self.tv_sec -= 1
def negate(self):
"""Negates the sign of both tv_sec and tv_nsec"""
self.tv_sec = -self.tv_sec
self.tv_nsec = -self.tv_nsec
@@ -7,7 +7,7 @@ import logging
import functools
import binascii
import stat
from datetime import datetime
import datetime
import socket as socket_module
from typing import Generator, Iterable, Iterator, Optional, Tuple, List, Union, Dict
@@ -35,6 +35,30 @@ class module(generic.GenericIntelProcess):
super().__init__(*args, **kwargs)
self._mod_mem_type = None # Initialize _mod_mem_type to None for memoization
def is_valid(self):
layer = self._context.layers[self.vol.layer_name]
# Make sure the entire module content is readable
if not layer.is_valid(self.vol.offset, self.vol.size):
return False
core_size = self.get_core_size()
if not (
1 <= core_size <= 20000000
and core_size + self.get_init_size() >= 4096
and 1 <= self.get_core_text_size() <= 20000000
):
return False
if not (
self.mkobj
and self.mkobj.mod
and self.mkobj.mod.is_readable()
and self.mkobj.mod == self.vol.offset
):
return False
return True
@property
def mod_mem_type(self):
"""Return the mod_mem_type enum choices if available or an empty dict if not"""
@@ -55,88 +79,90 @@ class module(generic.GenericIntelProcess):
self._mod_mem_type = {}
return self._mod_mem_type
def _get_mem_type(self, mod_mem_type_name):
module_mem_index = self.mod_mem_type.get(mod_mem_type_name)
if module_mem_index is None:
raise AttributeError(f"Unknown module memory type '{mod_mem_type_name}'")
if not (0 <= module_mem_index < self.mem.count):
raise AttributeError(
f"Invalid module memory type index '{module_mem_index}'"
)
return self.mem[module_mem_index]
def _get_mem_size(self, mod_mem_type_name):
return self._get_mem_type(mod_mem_type_name).size
def _get_mem_base(self, mod_mem_type_name):
return self._get_mem_type(mod_mem_type_name).base
def get_module_base(self):
if self.has_member("mem"): # kernels 6.4+
try:
return self.mem[self.mod_mem_type["MOD_TEXT"]].base
except KeyError:
raise AttributeError(
"module -> get_module_base: Unable to get module base. Cannot read base from MOD_TEXT."
)
return self._get_mem_base("MOD_TEXT")
elif self.has_member("core_layout"):
return self.core_layout.base
elif self.has_member("module_core"):
return self.module_core
raise AttributeError("module -> get_module_base: Unable to get module base")
raise AttributeError("Unable to get module base")
def get_init_size(self):
if self.has_member("mem"): # kernels 6.4+
try:
return (
self.mem[self.mod_mem_type["MOD_INIT_TEXT"]].size
+ self.mem[self.mod_mem_type["MOD_INIT_DATA"]].size
+ self.mem[self.mod_mem_type["MOD_INIT_RODATA"]].size
)
except KeyError:
raise AttributeError(
"module -> get_init_size: Unable to determine .init section size of module. Cannot read size of MOD_INIT_TEXT, MOD_INIT_DATA, and MOD_INIT_RODATA"
)
return (
self._get_mem_size("MOD_INIT_TEXT")
+ self._get_mem_size("MOD_INIT_DATA")
+ self._get_mem_size("MOD_INIT_RODATA")
)
elif self.has_member("init_layout"):
return self.init_layout.size
elif self.has_member("init_size"):
return self.init_size
raise AttributeError(
"module -> get_init_size: Unable to determine .init section size of module"
)
raise AttributeError("Unable to determine .init section size of module")
def get_core_size(self):
if self.has_member("mem"): # kernels 6.4+
try:
return (
self.mem[self.mod_mem_type["MOD_TEXT"]].size
+ self.mem[self.mod_mem_type["MOD_DATA"]].size
+ self.mem[self.mod_mem_type["MOD_RODATA"]].size
+ self.mem[self.mod_mem_type["MOD_RO_AFTER_INIT"]].size
)
except KeyError:
raise AttributeError(
"module -> get_core_size: Unable to determine core size of module. Cannot read size of MOD_TEXT, MOD_DATA, MOD_RODATA, and MOD_RO_AFTER_INIT."
)
return (
self._get_mem_size("MOD_TEXT")
+ self._get_mem_size("MOD_DATA")
+ self._get_mem_size("MOD_RODATA")
+ self._get_mem_size("MOD_RO_AFTER_INIT")
)
elif self.has_member("core_layout"):
return self.core_layout.size
elif self.has_member("core_size"):
return self.core_size
raise AttributeError(
"module -> get_core_size: Unable to determine core size of module"
)
raise AttributeError("Unable to determine core size of module")
def get_core_text_size(self):
if self.has_member("mem"): # kernels 6.4+
return self._get_mem_size("MOD_TEXT")
elif self.has_member("core_layout"):
return self.core_layout.text_size
elif self.has_member("core_text_size"):
return self.core_text_size
raise AttributeError("Unable to determine core text size of module")
def get_module_core(self):
if self.has_member("mem"): # kernels 6.4+
try:
return self.mem[self.mod_mem_type["MOD_TEXT"]].base
except KeyError:
raise AttributeError(
"module -> get_module_core: Unable to get module core. Cannot read base from MOD_TEXT."
)
return self._get_mem_base("MOD_TEXT")
elif self.has_member("core_layout"):
return self.core_layout.base
elif self.has_member("module_core"):
return self.module_core
raise AttributeError("module -> get_module_core: Unable to get module core")
raise AttributeError("Unable to get module core")
def get_module_init(self):
if self.has_member("mem"): # kernels 6.4+
try:
return self.mem[self.mod_mem_type["MOD_INIT_TEXT"]].base
except KeyError:
raise AttributeError(
"module -> get_module_core: Unable to get module init. Cannot read base from MOD_INIT_TEXT."
)
return self._get_mem_base("MOD_INIT_TEXT")
elif self.has_member("init_layout"):
return self.init_layout.base
elif self.has_member("module_init"):
return self.module_init
raise AttributeError("module -> get_module_init: Unable to get module init")
raise AttributeError("Unable to get module init")
def get_name(self):
"""Get the name of the module as a string"""
@@ -382,6 +408,203 @@ class task_struct(generic.GenericIntelProcess):
threads_seen.add(task.vol.offset)
yield task
def _get_task_start_time(self) -> datetime.timedelta:
"""Returns the task's monotonic start_time as a timedelta.
Returns:
The task's start time as a timedelta object.
"""
for member_name in ("start_boottime", "real_start_time", "start_time"):
if self.has_member(member_name):
start_time_obj = self.member(member_name)
start_time_obj_type = start_time_obj.vol.type_name
start_time_obj_type_name = start_time_obj_type.split(constants.BANG)[1]
if start_time_obj_type_name != "timespec":
# kernels >= 3.17 real_start_time and start_time are u64
# kernels >= 5.5 uses start_boottime which is also a u64
start_time = linux.TimespecVol3.new_from_nsec(start_time_obj)
else:
# kernels < 3.17 real_start_time and start_time are timespec
start_time = linux.TimespecVol3.new_from_timespec(start_time_obj)
# This is relative to the boot time so it makes sense to be a timedelta.
return start_time.to_timedelta()
raise AttributeError("Unsupported task_struct start_time member")
def get_time_namespace(self) -> Optional[interfaces.objects.ObjectInterface]:
"""Returns the task's time namespace"""
vmlinux = linux.LinuxUtilities.get_module_from_volobj_type(self._context, self)
if not self.has_member("nsproxy"):
# kernels < 2.6.19: ab516013ad9ca47f1d3a936fa81303bfbf734d52
return None
if not vmlinux.get_type("nsproxy").has_member("time_ns"):
# kernels < 5.6 769071ac9f20b6a447410c7eaa55d1a5233ef40c
return None
return self.nsproxy.time_ns
def get_time_namespace_id(self) -> int:
"""Returns the task's time namespace ID."""
time_ns = self.get_time_namespace()
if not time_ns:
# kernels < 5.6
return None
# We are good. ns_common (ns) was introduced in kernels 3.19. So by the time the
# time namespace was added in kernels 5.6, it already included the ns member.
return time_ns.ns.inum
def _get_time_namespace_offsets(
self,
) -> Optional[interfaces.objects.ObjectInterface]:
"""Returns the time offsets from the task's time namespace."""
time_ns = self.get_time_namespace()
if not time_ns:
# kernels < 5.6
return None
if not time_ns.has_member("offsets"):
# kernels < 5.6 af993f58d69ee9c1f421dfc87c3ed231c113989c
return None
return time_ns.offsets
def get_time_namespace_monotonic_offset(
self,
) -> Optional[interfaces.objects.ObjectInterface]:
"""Gets task's time namespace monotonic offset
Returns:
a kernel's timespec64 object with the monotonic offset
"""
time_namespace_offsets = self._get_time_namespace_offsets()
if not time_namespace_offsets:
return None
return time_namespace_offsets.monotonic
def _get_time_namespace_boottime_offset(
self,
) -> Optional[interfaces.objects.ObjectInterface]:
"""Gets task's time namespace boottime offset
Returns:
a kernel's timespec64 object with the boottime offset
"""
time_namespace_offsets = self._get_time_namespace_offsets()
if not time_namespace_offsets:
return None
return time_namespace_offsets.boottime
def _get_boottime_raw(self) -> "linux.TimespecVol3":
"""Returns the boot time in a TimespecVol3."""
vmlinux = linux.LinuxUtilities.get_module_from_volobj_type(self._context, self)
if vmlinux.has_symbol("tk_core"):
# kernels >= 3.17 | tk_core | 3fdb14fd1df70325e1e91e1203a699a4803ed741
tk_core = vmlinux.object_from_symbol("tk_core")
timekeeper = tk_core.timekeeper
if not timekeeper.offs_real.has_member("tv64"):
# kernels >= 4.10 - Tested on Ubuntu 6.8.0-41
boottime_nsec = timekeeper.offs_real - timekeeper.offs_boot
else:
# 3.17 <= kernels < 4.10 - Tested on Ubuntu 4.4.0-142
boottime_nsec = timekeeper.offs_real.tv64 - timekeeper.offs_boot.tv64
return linux.TimespecVol3.new_from_nsec(boottime_nsec)
elif vmlinux.has_symbol("timekeeper") and vmlinux.get_type(
"timekeeper"
).has_member("wall_to_monotonic"):
# 3.4 <= kernels < 3.17 - Tested on Ubuntu 3.13.0-185
timekeeper = vmlinux.object_from_symbol("timekeeper")
# timekeeper.wall_to_monotonic is timespec
boottime = linux.TimespecVol3.new_from_timespec(
timekeeper.wall_to_monotonic
)
boottime += timekeeper.total_sleep_time
boottime.negate()
boottime.normalize()
return boottime
elif vmlinux.has_symbol("wall_to_monotonic"):
# kernels < 3.4 - Tested on Debian7 3.2.0-4 (3.2.57-3+deb7u2)
wall_to_monotonic = vmlinux.object_from_symbol("wall_to_monotonic")
boottime = linux.TimespecVol3.new_from_timespec(wall_to_monotonic)
if vmlinux.has_symbol("total_sleep_time"):
# 2.6.23 <= kernels < 3.4 7c3f1a573237b90ef331267260358a0ec4ac9079
total_sleep_time = vmlinux.object_from_symbol("total_sleep_time")
full_type_name = total_sleep_time.vol.type_name
type_name = full_type_name.split(constants.BANG)[1]
if type_name == "timespec":
# kernels >= 2.6.32 total_sleep_time is a timespec
boottime += total_sleep_time
else:
# kernels < 2.6.32 total_sleep_time is an unsigned long as seconds
boottime.tv_sec += total_sleep_time
boottime.negate()
boottime.normalize()
return boottime
raise exceptions.VolatilityException("Unsupported")
def get_boottime(self, root_time_namespace: bool = True) -> datetime.datetime:
"""Returns the boot time in UTC as a datetime.
Args:
root_time_namespace: If True, it returns the boot time as seen from the root
time namespace. Otherwise, it returns the boot time relative to the
task's time namespace.
Returns:
A datetime with the UTC boot time.
"""
boottime = self._get_boottime_raw()
if not boottime:
return None
if not root_time_namespace:
# Shift boot timestamp according to the task's time namespace offset
boottime_offset_timespec = self._get_time_namespace_boottime_offset()
if boottime_offset_timespec:
# Time namespace support is from kernels 5.6
boottime -= boottime_offset_timespec
return boottime.to_datetime()
def get_create_time(self) -> datetime.datetime:
"""Retrieves the task's start time from its time namespace.
Args:
context: The context to retrieve required elements (layers, symbol tables) from
vmlinux_module_name: The name of the kernel module on which to operate
task: A reference task
Returns:
A datetime with task's start time
"""
# Typically, we want to see the creation time seen from the root time namespace
boottime = self.get_boottime(root_time_namespace=True)
# The kernel exports only tv_sec to procfs, see kernel's show_stat().
# This means user-space tools, like those in the procps package (e.g., ps, top, etc.),
# only use the boot time seconds to compute dates relatives to this.
boottime = boottime.replace(microsecond=0)
task_start_time_timedelta = self._get_task_start_time()
# NOTE: Do NOT apply the task's time namespace offsets here. While the kernel uses
# timens_add_boottime_ns(), it's not needed here since we're seeing it from the
# root time namespace, not within the task's own time namespace
return boottime + task_start_time_timedelta
@property
def is_being_ptraced(self) -> bool:
"""Returns True if this task is being traced using ptrace"""
@@ -1004,7 +1227,7 @@ class list_head(objects.StructType, collections.abc.Iterable):
return self.to_list(self.vol.parent.vol.type_name, self.vol.member_name)
class hlist_head(objects.StructType, collections.abc.Iterable):
class hlist_head(objects.StructType):
def to_list(
self,
symbol_type: str,
@@ -1978,7 +2201,7 @@ class kernel_cap_t(kernel_cap_struct):
class timespec64(objects.StructType):
def to_datetime(self) -> datetime:
def to_datetime(self) -> datetime.datetime:
"""Returns the respective aware datetime"""
dt = conversion.unixtime_to_datetime(self.tv_sec + self.tv_nsec / 1e9)
@@ -2054,7 +2277,7 @@ class inode(objects.StructType):
else:
return None
def _time_member_to_datetime(self, member) -> datetime:
def _time_member_to_datetime(self, member) -> datetime.datetime:
if self.has_member(f"{member}_sec") and self.has_member(f"{member}_nsec"):
# kernels >= 6.11 it's i_*_sec -> time64_t and i_*_nsec -> u32
# Ref Linux commit 3aa63a569c64e708df547a8913c84e64a06e7853
@@ -2073,7 +2296,7 @@ class inode(objects.StructType):
"Unsupported kernel inode type implementation"
)
def get_access_time(self) -> datetime:
def get_access_time(self) -> datetime.datetime:
"""Returns the inode's last access time
This is updated when inode contents are read
@@ -2082,7 +2305,7 @@ class inode(objects.StructType):
"""
return self._time_member_to_datetime("i_atime")
def get_modification_time(self) -> datetime:
def get_modification_time(self) -> datetime.datetime:
"""Returns the inode's last modification time
This is updated when the inode contents change
@@ -2092,7 +2315,7 @@ class inode(objects.StructType):
return self._time_member_to_datetime("i_mtime")
def get_change_time(self) -> datetime:
def get_change_time(self) -> datetime.datetime:
"""Returns the inode's last change time
This is updated when the inode metadata changes