Start now →

Access Control: Akıllı Sözleşmelerde Yetki Kontrol Zafiyetleri

By Mert Çoban · Published April 29, 2026 · 9 min read · Source: Ethereum Tag
EthereumRegulationSecurity
Access Control: Akıllı Sözleşmelerde Yetki Kontrol Zafiyetleri
Press enter or click to view image in full size

Access Control: Akıllı Sözleşmelerde Yetki Kontrol Zafiyetleri

Mert ÇobanMert Çoban8 min read·Just now

--

“Dünyanın ortak bilgisayarı” olarak nitelendirilen EVM (Ethereum Virtual Machine) üzerinde her gün binlerce akıllı sözleşme deploy edilmektedir. Ancak bu sözleşmelerde mantıksal zafiyetler ve hatalı kurgular, sistemlerin çeşitli yöntemlerle “exploit” edilmesine zemin hazırlamaktadır.

Temmuz 2017'de Parity Multisig cüzdanlarındaki initWallet() fonksiyonu korumasız bırakıldı. Bir saldırgan bu fonksiyonu çağırıp cüzdanların sahipliğini aldı ve yaklaşık 30 milyon dolar ele geçirildi. Dört ay sonra bir kullanıcı aynı açığı yanlışlıkla tetikleyerek paylaşılan kütüphane sözleşmesinde selfdestruct çalıştırdı ve yaklaşık 300 milyon dolar kalıcı olarak kilitlendi. Mart 2022'de Ronin Network’te 5/9 validator anahtarı ele geçirildi. Yaklaşık 625 milyon dolar ele geçirildi.

Yazımda sadece teorik anlatımla yetinmeyip ekosistemde milyonlarca dolarlık kayıplara yol açan yetki kontrolü zafiyetlerini kendi kurguladığım senaryolar üzerinden inceleyeceğim. Kod blokları eşliğinde bu zafiyetlerin anatomisini, nasıl çalıştığını ve bu riskten korunmak için hangi somut önlemlerin alınması gerektiğini, AccessControl ve onlyOwner gibi yapıları kullanarak, adım adım analiz edeceğim.

Yazımın içeriği; dört farklı yetki kontrol zafiyeti, her biri için PoC, çözüm yolları, kanıtlanmış testler ve denetim kontrol listesidir.

Sözleşmeler Solidity programlama dilinde, testler ise Foundry framework’ü kullanılarak yazıldı.

Saldırının nasıl çalıştığını anlamak için dört kavramı bilmem gerekmektedir.

msg.sender, çağırılan fonksiyonun hangi adres tarafından çağırıldığı bilgisini vermektedir.

tx.origin, tüm çağrı zincirini başlatan adres bilgisini vermektedir.

Modifier, fonksiyonların çalışması için ön koşula bağlayan bloklardır. Örneğin:

modifier onlyOwner() {
require(msg.sender == owner, "Access denied!");
_;
}

Initializer, proxy pattern kullanan yükseltilebilir sözleşmelerde constructor yerine kullanılan fonksiyondur.

Bölüm 1: Eksik Sahiplik Kontrolü

Aşağıda kodunu paylaştığım bir saldırı incelemesi için basitleştirilmiş kasa sözleşmesidir. Bir kasadan beklenilen 2 temel fonksiyon; kullanıcıların ether’lerini yatırabilmesi için deposit(), yatırdıkları ether’leri çekebilmesi için withdraw(), içermektedir.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract VaultVulnerable {
function deposit() public payable {}

function withdraw() public {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

Sırayla VaultVulnerable.sol şunları yapmaktadır:

function deposit() public payable {}

deposit() fonksiyonu sözleşmeye ether göndermek için kullanılmaktadır.

function withdraw() public {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}

withdraw(), çağıran adrese, msg.sender, kasa sözleşmesindeki tüm bakiyeyi, “address(this).balance”, transfer etmektedir.

Bu örnekte verilen kasa sözleşmesindeki zafiyet, withdraw() fonksiyonunun kim tarafından çağırılacağının ön koşul olarak kontrol edilmemesinden kaynaklanmaktadır. Ether gönderip gönderilmemesine bakılmaksızın herhangi bir kişi withdraw() fonksiyonunu çağırıp sözleşme üzerindeki tüm ether bakiyesini çekebilmektedir.

Testler ile kanıt:

    function setUp() public {
.......

vm.prank(alice);
vaultVulnerable.deposit{value: 1 ether}();
vm.prank(bob);
vaultVulnerable.deposit{value: 1 ether}();
vm.prank(charlie);
vaultVulnerable.deposit{value: 1 ether}();
.......
}
    function test_VulnerableVault_AnyoneCanDrain() public {
vm.prank(attacker);
vaultVulnerable.withdraw();
assertEq(attacker.balance, 3 ether);
assertEq(address(vaultVulnerable).balance, 0);
}

Bu test özelinde başlamadan önce setup() fonksiyonunda kullanıcılara ether verilmiş olup sözleşmeye üç ether deposit edilmiştir. Saldırı bu senaryo üzerine kurulmuştur.

Saldırgan, üç ether bulunan basitleştirilmiş kasa sözleşmesindeki withdraw() fonksiyonunu çağırmaktadır ve fonksiyonun üzerinde hiçbir kontrol bulunmadığından dolayı tüm sözleşmenin içini boşaltmaktadır.

test_VulnerableVault_AnyoneCanDrain() çıktısı

Bölüm 1: Eksik Sahiplik Kontrolü: Çözüm

Yetki kontrolü eklenerek zafiyet engellenmiş VaultFixed.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract VaultFixed {
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {}

function withdraw() public {
require(owner == msg.sender, "Access denied");
(bool ok,) = owner.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}
  1. address public owner; ile owner değişkeni tanımlanmaktadır.
  2. constructor içinde, bu sözleşmeyi deploy eden adres owner değişkenine atanmaktadır.
  3. withdraw() fonksiyonunun çalışması için ön koşul owner == msg.sender olarak yani fonksiyonu çağıran kişinin bu sözleşmeyi deploy eden adresle aynı olması eklenmektedir.

Yapılan bu değişiklik withdraw() fonksiyonunun bir yetkili tarafından çağırılmasını sağlamaktadır ve zafiyet engellenmektedir.

    function test_FixedVault_OnlyOwnerCanWithdraw() public {
vm.prank(attacker);
vm.expectRevert("Access denied");
vaultFixed.withdraw();
uint256 before = address(this).balance;
vaultFixed.withdraw();
assertEq(address(this).balance - before, 3 ether);
}

Verilen önceki test senaryosuna paralel olarak kurulum işlemleri gerçekleşmiştir. Saldırgan withdraw() fonksiyonunu çağırmak ister fakat deploy eden adres test sözleşmesinin adresi olduğundan dolayı owner msg.sender olarak atanmıştır. Saldırgan “require(owner == msg.sender, “Access denied”);” satırına takılır ve fonksiyonu çağıramaz.

Press enter or click to view image in full size
test_FixedVault_OnlyOwnerCanWithdraw çıktısı

Bölüm 2: tx.origin Phishing

Geliştiricinin constructor aşamasında owner atamasını msg.sender yerine tx.origin ile gerçekleştirmesi bir zafiyete sebebiyet vermektedir. Bu saldırı türü, akıllı sözleşmelerin yanı sıra diğer bilişim alanlarında da sıklıkla görülmektedir. Saldırganlar genellikle sosyal mühendislik yöntemleriyle kullanıcıları yanıltarak bilgilerini ele geçirmeyi hedeflemektedir. tx.origin işlem zincirini başlatan adresi temsil ettiğinden, saldırgan bu kontrolün yapıldığı bir fonksiyona deploy eden adresi yönlendirerek kendi sözleşmesi üzerinden zafiyeti istismar edebilmektedir. Yazılan senaryoda saldırgan, kasa sahibine claimAirDrop() fonksiyonunu çalıştırtmaktadır. Bu fonksiyon, tx.origin ile kontrol edilen withdraw() fonksiyonunu tetiklemekte ve kasadaki ether saldırgana aktarılmaktadır. withdraw() fonksiyonu, işlemi başlatan adresi deploy eden adres olarak doğruladığı için ilgili koşul sağlanmaktadır.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract VaultTxOrigin {
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {}

function withdraw() public {
require(tx.origin == owner, "Access denied");
(bool ok,) = tx.origin.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

Oltalama için kullanılan PhishingAttacker.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

interface IVault {
function deposit() external payable;
function withdraw() external;
}

contract PhishingAttacker {
IVault vault;

constructor(address _vault) {
vault = IVault(_vault);
}

function claimAirDrop() public payable {
vault.withdraw();
}
}

Kasa sözleşmesi sahibi saldırgan tarafından oltalanarak claimAirDrop() fonksiyonunu çağırmaktadır ve saldırı gerçekleşmektedir.

Deployer => claimAirDrop() => vault.withdraw() => ether saldırgana gönderildi.
(tx.origin ile deployer adresi doğrulandı).

Testler ile kanıt:

    function test_TxOrigin_PhishingDrainsVault() public {
vm.prank(dave);
vaultTxOrigin.deposit{value: 1 ether}();

vm.prank(dave, dave);
phishingAttacker.claimAirDrop();

assertEq(dave.balance, 1 ether);
assertEq(address(vaultTxOrigin).balance, 0);
}
  1. Dave kasaya bir ether göndermektedir.
  2. Oltanan kasa sahibi claimAirDrop() fonksiyonunu çağırmaktadır.
  3. Kasa saldırgan tarafından boşaltılmaktadır.
Press enter or click to view image in full size
test_TxOrigin_PhishingDrainsVault çıktısı

Bölüm 2: tx.origin Phishing: Çözüm

tx.origin kaldırılarak msg.sender ile yetki kontrolü yapılan düzeltilmiş VaultMsgSender.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract VaultMsgSender {
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {}

function withdraw() public {
require(msg.sender == owner, "Access denied");
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "transfer failed");
}
}

tx.origin yerine msg.sender ile yetki kontrolü yapılarak zafiyet giderilmiştir.

Press enter or click to view image in full size
test_MsgSender_PhishingReverts çıktısı

Bölüm 3: Eksik Rol Modifier’ı

OpenZeppelin, AccessControl isminde bir soyut sözleşmeye sahiptir. Bu sözleşme aracılığıyla adreslere belirli roller atanabilmekte ve fonksiyonların yürütülmesi bu rol koşullarına bağlanabilmektedir. Örneğin; token basımı için tek bir adres yetkilendirilerek mint() fonksiyonunun yalnızca bu tanımlı adres tarafından tetiklenmesi sağlanabilmektedir. Söz konusu rol yönetimi süreçleri, akıllı sözleşmelere gelişmiş bir güvenlik katmanı kazandırmaktadır.

Roller oluşturulur fakat dağıtılmaz veya eksik dağıtılırsa büyük bir zafiyet ortaya çıkar. Buna eksik rol modifier’ı denmektedir. Zafiyet içeren sözleşme RoleVaultVulnerable.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract RoleVaultVulnerable is AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

constructor(address withdrawer, address minter) {
_grantRole(WITHDRAWER_ROLE, withdrawer);
_grantRole(MINTER_ROLE, minter);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}

function mint() public onlyRole(MINTER_ROLE) {}
}

Geliştirici “WITHDRAWER_ROLE” rolünü tanımlamaktadır fakat withdraw() fonksiyonunda modifier olarak yazmamaktadır. Bu eksiklik sebebiyle withdraw() fonksiyonunu herkes çağırabilmektedir.

Testler ile kanıt:


function test_RoleVaultVulnerable_AnyoneCanDrain() public {
vm.prank(eve);
roleVaultVulnerable.deposit{value: 5 ether}();

vm.prank(attacker);
roleVaultVulnerable.withdraw();

assertEq(attacker.balance, 5 ether);
}

Eve sözleşmeye 5 ether göndermektedir. Sözleşmede erişim rolü olmayan saldırgan kasayı boşaltmaktadır.

Press enter or click to view image in full size
test_RoleVaultVulnerable_AnyoneCanDrain çıktısı

Bölüm 3: Eksik Rol Modifier’ı: Çözüm

WITHDRAWER_ROLE verilerek yetki kontrolü yapılan düzeltilmiş RoleVault.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract RoleVault is AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

constructor(address withdrawer, address minter) {
_grantRole(WITHDRAWER_ROLE, withdrawer);
_grantRole(MINTER_ROLE, minter);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public onlyRole(WITHDRAWER_ROLE) {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}

function mint() public onlyRole(MINTER_ROLE) {}
}
Press enter or click to view image in full size
test_RoleVault_OnlyWithdrawerCanWithdraw çıktısı

Bölüm 4: Korumasız Initializer

Yükseltilebilir akıllı sözleşmelerde constructor yapısı kullanılamamaktadır. constructor yalnızca deployment esnasında bir kez yürütülmekte ve verileri implementation sözleşmesinin depolama alanına kaydetmektedir. Bu teknik kısıt nedeniyle başlangıç değerlerini atamak amacıyla initialize() fonksiyonu tercih edilmektedir. Ancak bu fonksiyon standart bir public fonksiyon niteliği taşıdığından dolayı uygun erişim denetimi mekanizmalarıyla korunmadığı takdirde herhangi bir dış aktör tarafından çağrılabilme riski barındırmaktadır.

initialize() fonksiyonunda yetki kontrolü bulunmayan InitVaultVulnerable.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract InitVaultVulnerable is AccessControl {
function initialize() public {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public onlyRole(DEFAULT_ADMIN_ROLE) {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

initialize() fonksiyonunda herhangi bir yetki kontrolü sağlayacak modifier bulunmadığından dolayı saldırıya açık hale gelmektedir.

Testler ile kanıt:

    function test_InitVaultVulnerable_AnyoneCanTakeOwnership() public {
vm.prank(eve);
initVaultVulnerable.initialize();

vm.prank(eve);
initVaultVulnerable.deposit{value: 5 ether}();

vm.prank(attacker);
initVaultVulnerable.initialize();

vm.prank(attacker);
initVaultVulnerable.withdraw();

assertEq(attacker.balance, 5 ether);
assertEq(address(initVaultVulnerable).balance, 0);
}
  1. Eve initialize() fonksiyonunu çalıştırmaktadır ve admin olarak atanmaktadır.
  2. Eve sözleşmeye 5 ether göndermektedir.
  3. initialize() fonksiyonunda yetki kontrolü bulunmadığından dolayı saldırgan initialize() tekrar çağırmakta ve admin olarak atanmaktadır.
  4. Saldırgan withdraw() fonksiyonunun DEFAULT_ADMIN_ROLE kontrolünden geçerek kasayı boşaltmaktadır.
Press enter or click to view image in full size
test_InitVaultVulnerable_AnyoneCanTakeOwnership çıktısı

Bölüm 4: Korumasız Initializer: Çözüm

initialize() fonksiyonuna initializer modifier’ı verilerek tekrar çağırılmasına engel olunmaktadır. Düzeltilmiş InitVaultFixed.sol sözleşmesi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

contract InitVaultFixed is AccessControl, Initializable {
function initialize() public initializer {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public onlyRole(DEFAULT_ADMIN_ROLE) {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

initializer modifier’ı kullanılmasından dolayı initialize() tekrar çağırılamamaktadır ve zafiyet ortadan kalkmaktadır.

Press enter or click to view image in full size
test_InitVaultFixed_CannotReinitialize çıktısı

Bölüm 5: Kendi Denetim Kontrol Listem

  1. Fon transferi, sahiplik değişimi, parametre güncelleme, mint/burn, upgrade yapan tüm fonksiyonlarda açık bir erişim kontrolü var mı?
  2. Yetki kontrolünde msg.sender mı kullanılıyor? tx.origin kullanımı var mı?
  3. Rol tabanlı erişim varsa, tüm yetkili fonksiyonlara ilgili onlyRole modifier’ı uygulanmış mı?
  4. Yükseltilebilir sözleşmelerde initialize() fonksiyonu initializer modifier’ı ile korunuyor mu? Implementation sözleşmesinin constructor’ı _disableInitializers() çağırıyor mu?
  5. En az yetki prensibi uygulanmış mı? Tek bir sahip tüm yetkileri tutuyor mu, yoksa roller ayrılmış mı?
  6. Yetki yükseltme yolları kontrol edilmiş mi? Düşük yetkili bir rol kendine daha yüksek yetki verebilir mi?
  7. Geçici yetkiler (deployment helper, migration script) kullanıldıktan sonra iptal edilmiş mi?

Sonuç olarak, yetki kontrolü zafiyeti yaşanmaması için her geliştiricinin sözleşmelerini yazarken daima yazdığı fonksiyonu kimler çağırabilmeli diye düşünmesi gerekmektedir. Planlı ve düşünülerek yazılmış satırlar ve doğru önlemler ile sözleşmeler daha güvenli hale gelebilmektedir.

Yazıda kullanılan tüm sözleşmeler ve Foundry testleri Github reposunda yer almaktadır.
https://github.com/mertcobn/access-control-vulnerabilities

Akıllı sözleşme güvenliği üzerine daha fazla içerik için beni takip edebilirsiniz:
LinkedIn: https://www.linkedin.com/in/mert-coban/
Mail: [email protected]

Bundan sonraki yazılarda farklı güvenlik açıklarını aynı şekilde incelemeye devam edeceğim.

Bir önceki yazımda reentrancy zafiyetini incelemiştim.
https://medium.com/@mertcoban_/reentrancy-attack

This article was originally published on Ethereum Tag and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →