Özet
2 Ağustos 2022'de Nomad Bridge adlı bir zincirler arası köprü saldırıya uğradı ve yaklaşık 200 milyon dolarlık kayba yol açtı. Temel neden, zincir üstü akıllı sözleşmenin yükseltilmiş sürümündeki hatalı kontroldür.
Arka Plan
Nomad Bridge, sahtecilik kanıtına dayalı tasarım kullanan yeni nesil bir zincirler arası varlık köprüsüdür. Şu şekilde çalışır:
- Nomad, zincirler arası mesajların posta kutusu olarak desteklenen her blok zincirinde Replica adlı bir çekirdek sözleşme dağıtır.
- Zincir dışı ajanlar, zincirler arası mesajları bir Merkle ağacında aktarır ve düzenler; imzalı yeni ağaç kök karmasını bu sözleşmeye göndererek ağaç kökünü günceller.
- Zincir üzerinde onaylanması gereken yeni mesajlar hem
prove()hem deprocess()prosedüründen geçmek zorundadır.prove()prosedürü, mesajı ve Merkle ağacındaki kanıtı doğrular, ardından mesajı kanıtlanmış olarak işaretler.process()prosedürü, mesaj daha önce kanıtlanmışsa ve ilgili ağaç kökü onaylanmışsa mesajı kontrol eder ve çalıştırır.
Kod
Ethereum'da Replica, 0x5d94309e5a0090b165fa4181519701637b6daeba adresinde dağıtılmış bir Beacon proxy'sidir. Mantıksal sözleşmenin iki sürümü mevcuttur: birinci sürüm 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b adresinde, ikinci sürüm ise 0xb92336759618f55bd0f8313bd843604592e27bd8 adresinde dağıtılmıştır.
Önce mantıksal sözleşmenin önceki sürümünü, özellikle process() fonksiyonunu inceliyoruz:
function process(bytes memory _message) public returns (bool _success) {
bytes29 _m = _message.ref(0);
// mesajın bu alan için olduğundan emin ol
require(_m.destination() == localDomain, "!destination");
// mesajın kanıtlandığından emin ol
bytes32 _messageHash = _m.keccak();
require(messages[_messageHash] == MessageStatus.Proven, "!proven");
// yeniden giriş korumasını kontrol et
require(entered == 1, "!reentrant");
entered = 0;
// mesaj durumunu işlenmiş olarak güncelle
messages[_messageHash] = MessageStatus.Processed;
Bu fonksiyonun yalnızca bir bölümünü gösteriyoruz. Bu kod parçasında mesaj karması hesaplanır ve karmanın daha önce kanıtlanıp kanıtlanmadığını kontrol etmek için messages eşlemesiyle karşılaştırılır, ardından yeniden giriş kontrolü yapılır ve mesaj durumu güncellenir.
Eski prove() fonksiyonunu da kısaca inceliyoruz:
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// mesajın kanıtlanmamış veya işlenmemiş olduğundan emin ol
require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
// kanıta dayalı beklenen kökü hesapla
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// kök geçerliyse durumu Kanıtlandı olarak değiştir
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = MessageStatus.Proven;
return true;
}
return false;
}
Burada özel bir şey yok: yineleme kontrolü, ağaç kökü hesaplama, kabul edilebilirse kanıtlanmış olarak işaretleme. Yani Replica sözleşmesinin eski sürümünde, kanıtlanan tüm mesajlar için özel bir işaret (MessageStatus.Proven = 1) bulunmaktadır.
Şimdi mantıksal sözleşmenin ikinci sürümünü inceleyelim. Yeni sürümde önce prove() fonksiyonunu kontrol ediyoruz:
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// mesajın işlenmemiş olduğundan emin ol
// Bunun yeni bir kök altında yeniden kanıtlamaya izin verdiğine dikkat edin.
require(
messages[_leaf] != LEGACY_STATUS_PROCESSED,
"already processed"
);
// kanıta dayalı beklenen kökü hesapla
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// kök geçerliyse durumu Kanıtlandı olarak değiştir
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = _calculatedRoot;
return true;
}
return false;
}
Burada önemli bir değişiklik hemen göze çarpıyor: geliştiriciler bir nedenden ötürü özel bir işaret yerine hesaplanan kökü kanıtlanmış durum olarak kaydetmeye karar vermiş. Bu fonksiyon açısından bir sorun yok, çünkü Merkle ağacı kök karmasının sıfır olmadığı garanti edilmiştir. Bu aynı zamanda makul bir yaklaşımdır; zira ağaç kökü onaylandığı anda, bu ağaç köküyle kanıtlanan tüm yeni mesajlar çalıştırılmaya hazır hale gelir.
Ardından yeni sürümdeki process() fonksiyonunu inceliyoruz:
function process(bytes memory _message) public returns (bool _success) {
// mesajın bu alan için olduğundan emin ol
bytes29 _m = _message.ref(0);
require(_m.destination() == localDomain, "!destination");
// mesajın kanıtlandığından emin ol
bytes32 _messageHash = _m.keccak();
require(acceptableRoot(messages[_messageHash]), "!proven");
// yeniden giriş korumasını kontrol et
require(entered == 1, "!reentrant");
entered = 0;
// mesaj durumunu işlenmiş olarak güncelle
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
messages[_messageHash] satırına dikkat ediyoruz. Var olmayan bir eşleme girdisinin sıfır döndürmesi yaygın bir tuzaktır. Bu bağlamda, bu mesaj karmasıyla ilişkili Merkle ağacı kökünün sıfır olduğu anlamına gelir. Bu sıfırın sonucunu daha ayrıntılı incelememiz gerekiyor. Bu nedenle yeni acceptableRoot() fonksiyonunu dikkatle incelememiz gerekiyor.
function acceptableRoot(bytes32 _root) public view returns (bool) {
// bu, önceki sürümler altında kanıtlanan/işlenen
// mesajlar için geriye dönük uyumluluk sağlar
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;
uint256 _time = confirmAt[_root];
if (_time == 0) {
return false;
}
return block.timestamp >= _time;
}
Bu fonksiyon temel olarak Merkle ağacı kökünün onaylanıp onaylanmadığını kontrol etmek için confirmAt eşlemesini kontrol eder.
Ne yazık ki, Replica sözleşmesinin HER İKİ sürümünde de başlatıcıda sıfır karma değeri 1'e ayarlanmıştır:
function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot, // bu başlangıçta sıfırdır
uint256 _optimisticSeconds
) public initializer {
__NomadBase_initialize(_updater);
// depolama değişkenlerini ayarla
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
// taahhüt edilen kökü önceden onayla.
confirmAt[_committedRoot] = 1;
_setOptimisticTimeout(_optimisticSeconds);
}
Replica sözleşmesinin eski sürümünde bu tamamen sorunsuzdur: prove() fonksiyonunda hiçbir ağaç kök karması sıfır olamayacağından, confirmAt eşlemesinde sıfır karma girdisini 1'e ayarlamak güvenlidir.
Ancak yeni sürümde, yeni bir mesaj için messages[_messageHash] sıfır döndürür. Ardından acceptableRoot, confirmAt eşlemesindeki sıfır karma girdisine erişir ve true döndürür.
Saldırı
Yukarıdaki kod analizinden, daha önce görülmemiş herhangi bir mesajın doğrulama mantığını atlayarak çalıştırılabileceğini biliyoruz. Bu nedenle sahte bir mesaj oluşturup process() fonksiyonunu çağırmak yeterlidir.
İlginç bir şekilde, bu sözleşmedeki process() fonksiyonuna yapılan ilk çağrı yalnızca iki gün önce (15249565. blokta) 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0 işleminde gerçekleşti.
Aşağıdaki şekilde, bu mesaj için messages durum değişkeninin depolama slotunun başlangıçta sıfır olduğunu görebiliriz; bu da Replica sözleşmesinin bu mesajı daha önce tanımadığı anlamına gelir.

Ardından bu slot iki değerine ayarlandı (yani bu mesajın işlendiğini belirten LEGACY_STATUS_PROCESSED durumu). Bu, geçersiz bir mesajın prove() mantığını atlayarak doğrudan işlendiğine işaret etmektedir.
Sonuç
Bu, eşlemeden alınan kontrol edilmemiş dönüş değerini kullanan bir başka klasik saldırıdır. Solidity geliştiricileri, beklenmedik sonuçlardan kaçınmak için eşlemelerle çalışırken özellikle dikkatli olmalıdır.



