| // Copyright 2024 The ChromiumOS Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "secagentd/image_cache.h" |
| |
| #include <sys/stat.h> |
| #include <sys/types.h> |
| #include <unistd.h> |
| |
| #include <algorithm> |
| #include <cinttypes> |
| #include <cstdint> |
| #include <list> |
| #include <memory> |
| #include <utility> |
| |
| #include "absl/status/status.h" |
| #include "absl/status/statusor.h" |
| #include "base/containers/lru_cache.h" |
| #include "base/files/file.h" |
| #include "base/files/file_path.h" |
| #include "base/logging.h" |
| #include "base/strings/strcat.h" |
| #include "base/strings/string_number_conversions.h" |
| #include "base/strings/string_tokenizer.h" |
| #include "base/strings/string_util.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/synchronization/lock.h" |
| #include "brillo/secure_blob.h" |
| #include "openssl/sha.h" |
| #include "secagentd/bpf/bpf_types.h" |
| namespace { |
| |
| static const char kErrorFailedToRead[] = "Error reading file "; |
| static const char kErrorSslSha[] = "SSL SHA error"; |
| static const char kErrorBytesRead[] = |
| "Failed to read the expected number of bytes from the file. "; |
| } // namespace |
| |
| namespace secagentd { |
| |
| constexpr ImageCache::InternalImageCacheType::size_type kImageCacheMaxSize = |
| 256; |
| |
| // Allow a 10 millisecond delta for nanosec. |
| const u_int64_t kEpsilonNs = 10000000; |
| |
| absl::StatusOr<ImageCacheInterface::HashValue> |
| ImageCache::VerifyStatAndGenerateImageHash( |
| const ImageCacheInterface::ImageCacheKeyType& image_key, |
| bool force_full_sha256, |
| const base::FilePath& image_path_in_current_ns) { |
| auto hash = GenerateImageHash(image_path_in_current_ns, force_full_sha256); |
| if (!hash.ok()) { |
| return hash.status(); |
| } |
| base::stat_wrapper_t image_stat; |
| if (base::File::Stat(image_path_in_current_ns, &image_stat) || |
| (image_stat.st_dev != image_key.inode_device_id) || |
| (image_stat.st_ino != image_key.inode) || |
| (image_stat.st_mtim.tv_sec != image_key.mtime.tv_sec) || |
| std::abs(image_stat.st_mtim.tv_nsec - image_key.mtime.tv_nsec) > |
| kEpsilonNs || |
| (image_stat.st_ctim.tv_sec != image_key.ctime.tv_sec) || |
| std::abs(image_stat.st_ctim.tv_nsec - image_key.ctime.tv_nsec) > |
| kEpsilonNs) { |
| return absl::NotFoundError( |
| base::StrCat({"Failed to match stat of image hashed at ", |
| image_path_in_current_ns.value(), |
| "\nExpected values:\n", |
| " inode_device_id: ", |
| base::NumberToString(image_key.inode_device_id), |
| "\n inode: ", |
| base::NumberToString(image_key.inode), |
| "\n mtime: ", |
| base::NumberToString(image_key.mtime.tv_sec), |
| ".", |
| base::NumberToString(image_key.mtime.tv_nsec), |
| "\n ctime: ", |
| base::NumberToString(image_key.ctime.tv_sec), |
| ".", |
| base::NumberToString(image_key.ctime.tv_nsec), |
| "\nActual values:\n", |
| " st_dev: ", |
| base::NumberToString(image_stat.st_dev), |
| "\n st_ino: ", |
| base::NumberToString(image_stat.st_ino), |
| "\n st_mtime: ", |
| base::NumberToString(image_stat.st_mtim.tv_sec), |
| ".", |
| base::NumberToString(image_stat.st_mtim.tv_nsec), |
| "\n st_ctime: ", |
| base::NumberToString(image_stat.st_ctim.tv_sec), |
| ".", |
| base::NumberToString(image_stat.st_ctim.tv_nsec)})); |
| } |
| return hash; |
| } |
| |
| // The function determines whether to compute a full or partial hash based on |
| // file size and force_full_sha. For a partial hash, it divides the file into |
| // chunks, processes a fixed-size portion of each chunk, and handles any |
| // remaining bytes in the last chunk separately. For a full hash, it reads and |
| // processes the entire file in chunks of a specified size. It updates the hash |
| // with each chunk of data and finalizes the computation. The result indicates |
| // if the hash was for the full file or just a part. |
| absl::StatusOr<ImageCacheInterface::HashValue> ImageCache::GenerateImageHash( |
| const base::FilePath& image_path_in_current_ns, bool force_full_sha) { |
| base::File file(image_path_in_current_ns, |
| base::File::FLAG_OPEN | base::File::FLAG_READ); |
| if (!file.IsValid()) { |
| return absl::NotFoundError( |
| base::StrCat({kErrorFailedToRead, image_path_in_current_ns.value()})); |
| } |
| |
| base::TimeTicks start = base::TimeTicks::Now(); |
| SHA256_CTX ctx; |
| if (!SHA256_Init(&ctx)) { |
| return absl::InternalError(kErrorSslSha); |
| } |
| |
| int64_t file_size = file.GetLength(); |
| if (file_size < 0) { |
| return absl::AbortedError(base::StrCat( |
| {"Could not get file length:", image_path_in_current_ns.value()})); |
| } |
| |
| bool is_partial = |
| !force_full_sha && (file_size > (max_file_size_for_full_sha_)); |
| |
| size_t chunk_size; |
| |
| if (is_partial) { |
| size_t chunk_count = max_file_size_for_full_sha_ / sha_chunk_size_; |
| chunk_size = file_size / chunk_count; |
| } else { |
| chunk_size = sha_chunk_size_; |
| } |
| |
| // If last chunk is less that the chunk_count, we would end up |
| // computing full hash, even though partial is needed, updating is_partial |
| // correctly. |
| is_partial = |
| is_partial && |
| (file_size > (max_file_size_for_full_sha_ + |
| ((max_file_size_for_full_sha_ / sha_chunk_size_) - 1))); |
| |
| brillo::SecureBlob buf(sha_chunk_size_); |
| size_t offset = 0; |
| |
| while (offset < file_size) { |
| // Read bytes from the file. |
| int bytes_read = file.Read(offset, buf.char_data(), sha_chunk_size_); |
| if (bytes_read < 0) { |
| return absl::AbortedError( |
| base::StrCat({kErrorBytesRead, image_path_in_current_ns.value()})); |
| } |
| // Update SHA256 context with the read data. |
| if (!SHA256_Update(&ctx, buf.data(), bytes_read)) { |
| return absl::InternalError(kErrorSslSha); |
| } |
| |
| offset += chunk_size; // Move to the next position. |
| } |
| |
| // Finalize the SHA calculation |
| std::array<unsigned char, SHA256_DIGEST_LENGTH> final_hash; |
| if (!SHA256_Final(final_hash.data(), &ctx)) { |
| return absl::InternalError(kErrorSslSha); |
| } |
| |
| // Convert hash to a hexadecimal string and return. |
| return ImageCacheInterface::HashValue{ |
| .sha256 = base::HexEncode(final_hash.data(), SHA256_DIGEST_LENGTH), |
| .sha256_is_partial = is_partial, |
| .file_size = static_cast<size_t>(file_size), |
| .compute_time = base::TimeTicks::Now() - start}; |
| } |
| |
| absl::StatusOr<base::FilePath> ImageCache::SafeAppendAbsolutePath( |
| const base::FilePath& path, const base::FilePath& abs_component) { |
| // TODO(b/279213783): abs_component is expected to be an absolute and |
| // resolved path. But that's sometimes not the case. If the path references |
| // parent it likely won't resolve and possibly may attempt to escape the |
| // pid_mnt_root namespace. So err on the side of safety. Similarly, if the |
| // path is not absolute, it likely won't resolve because we don't have its |
| // CWD. |
| if (!abs_component.IsAbsolute() || abs_component.ReferencesParent()) { |
| return absl::InvalidArgumentError(base::StrCat( |
| {"Refusing to translate relative or parent-referencing path ", |
| abs_component.value()})); |
| } |
| return path.Append( |
| base::StrCat({base::FilePath::kCurrentDirectory, abs_component.value()})); |
| } |
| |
| ImageCache::ImageCache(base::FilePath path, |
| size_t sha_chunk_size, |
| size_t max_file_size_for_full_sha) |
| : root_path_(path), |
| sha_chunk_size_(sha_chunk_size), |
| max_file_size_for_full_sha_(max_file_size_for_full_sha), |
| cache_(std::make_unique<InternalImageCacheType>(kImageCacheMaxSize)) {} |
| ImageCache::ImageCache() : ImageCache(base::FilePath("/")) {} |
| |
| absl::StatusOr<ImageCacheInterface::HashValue> ImageCache::InclusiveGetImage( |
| const ImageCacheKeyType& image_key, |
| bool force_full_sha256, |
| uint64_t pid_for_setns, |
| const base::FilePath& image_path_in_pids_ns) { |
| base::AutoLock lock(cache_lock_); |
| auto it = cache_->Get(image_key); |
| if (it != cache_->end()) { |
| if (it->first.mtime.tv_sec == 0 || it->first.ctime.tv_sec == 0) { |
| // Invalidate entry and force checksum if its cached ctime or mtime |
| // seems missing. |
| cache_->Erase(it); |
| it = cache_->end(); |
| } else { |
| return it->second; |
| } |
| } |
| |
| absl::StatusOr<HashValue> statusorhash; |
| { |
| base::AutoUnlock unlock(cache_lock_); |
| // First try our own (i.e root) namespace. This will almost always work |
| // because minijail mounts are 1:1. Stat will save us from false positive |
| // matches. |
| auto statusorpath = |
| SafeAppendAbsolutePath(root_path_, image_path_in_pids_ns); |
| if (statusorpath.ok()) { |
| statusorhash = VerifyStatAndGenerateImageHash( |
| image_key, force_full_sha256, *statusorpath); |
| } |
| // If !statusorpath.ok() then GetPathInCurrentMountNs will call |
| // SafeAppendAbsolutePath with the same image_path_in_pids_ns which will |
| // return the same status. No point in trying. |
| if (statusorpath.ok() && !statusorhash.ok()) { |
| statusorpath = |
| GetPathInCurrentMountNs(pid_for_setns, image_path_in_pids_ns); |
| if (statusorpath.ok()) { |
| statusorhash = VerifyStatAndGenerateImageHash( |
| image_key, force_full_sha256, *statusorpath); |
| } |
| } |
| |
| if (!statusorpath.ok() || !statusorhash.ok()) { |
| LOG(ERROR) << "Failed to hash " << image_path_in_pids_ns |
| << " in mnt ns of pid " << pid_for_setns << ": " |
| << (!statusorpath.ok() ? statusorpath.status() |
| : statusorhash.status()); |
| return absl::InternalError("Failed to hash"); |
| } |
| } |
| it = cache_->Put(image_key, std::move(*statusorhash)); |
| return it->second; |
| } |
| |
| absl::StatusOr<base::FilePath> ImageCache::GetPathInCurrentMountNs( |
| uint64_t pid_for_setns, const base::FilePath& image_path_in_pids_ns) const { |
| const base::FilePath pid_mnt_root = |
| root_path_.Append(base::StringPrintf("proc/%" PRIu64, pid_for_setns)) |
| .Append("root"); |
| return ImageCache::SafeAppendAbsolutePath(pid_mnt_root, |
| image_path_in_pids_ns); |
| } |
| } // namespace secagentd |