From ad28104a024e66dccaa9e9f6a2ab633d419f6d72 Mon Sep 17 00:00:00 2001 From: Seungwan98 Date: Thu, 26 Feb 2026 11:55:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(week-02):=20SimpleStorage=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=9C=20=EC=99=84=EB=A3=8C=20=EB=B0=8F=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EB=8B=B5=EC=95=88=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- week-02/dev/src/SimpleStorage.sol | 24 ++--- week-02/quiz/quiz-02-solution.md | 140 ++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 week-02/quiz/quiz-02-solution.md diff --git a/week-02/dev/src/SimpleStorage.sol b/week-02/dev/src/SimpleStorage.sol index 322647d..7a23b9a 100644 --- a/week-02/dev/src/SimpleStorage.sol +++ b/week-02/dev/src/SimpleStorage.sol @@ -45,29 +45,17 @@ contract SimpleStorage { /// @notice ETH를 입금합니다 /// @dev msg.value는 함수 호출 시 전송된 ETH 양입니다 function deposit() public payable { - // TODO: 입금 로직을 구현하세요 - // 1. balances[msg.sender]에 msg.value를 더합니다 - // 2. Deposited 이벤트를 발생시킵니다 - // - // 힌트: - // balances[msg.sender] += msg.value; - // emit Deposited(msg.sender, msg.value); + balances[msg.sender] += msg.value; + emit Deposited(msg.sender, msg.value); } /// @notice ETH를 출금합니다 /// @param amount 출금할 금액 (wei 단위) /// @dev 잔액이 충분한지 확인 후, ETH를 전송합니다 function withdraw(uint256 amount) public { - // TODO: 출금 로직을 구현하세요 - // 1. 사용자의 잔액이 amount 이상인지 확인합니다 (require 사용) - // 2. balances[msg.sender]에서 amount를 뺍니다 - // 3. msg.sender에게 ETH를 전송합니다 - // 4. Withdrawn 이벤트를 발생시킵니다 - // - // 힌트: - // require(balances[msg.sender] >= amount, "Insufficient balance"); - // balances[msg.sender] -= amount; - // payable(msg.sender).transfer(amount); - // emit Withdrawn(msg.sender, amount); + require(balances[msg.sender] >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + payable(msg.sender).transfer(amount); + emit Withdrawn(msg.sender, amount); } } diff --git a/week-02/quiz/quiz-02-solution.md b/week-02/quiz/quiz-02-solution.md new file mode 100644 index 0000000..19f3c4a --- /dev/null +++ b/week-02/quiz/quiz-02-solution.md @@ -0,0 +1,140 @@ +# Week 2 퀴즈 답안 + +## 문제 1: 트랜잭션 필드 + +**답변: B** + +gasPrice는 가스 1개당 내야 할 wei 가격이고, gasLimit은 최대로 쓸 수 있는 가스양이다. + +총 가스 비용은 이렇게 계산한다: +총 비용 = 실제 사용한 gas × gasPrice + +예를 들어 21,000 gas를 쓰고 gasPrice가 20 Gwei면 +21,000 × 20 = 420,000 Gwei = 0.00042 ETH가 든다. + +--- + +## 문제 2: nonce의 역할 + +**답변: B** + +이더리움은 nonce 순서대로 트랜잭션을 처리한다. TX-A(nonce=5)가 먼저 처리돼야 TX-B(nonce=6)가 처리된다. + +gasPrice가 높아도 nonce 순서를 뛰어넘을 순 없다. nonce는 계정이 본인 트랜잭션을 몇 번 냈는지 카운트하는 거라서, 한 번 쓰면 못 쓴다. 이걸로 누가 내 트랜잭션을 복사해서 여러 번 볂는 재사용 공격을 막을 수 있다. + +--- + +## 문제 3: 디지털 서명 + +**답변: A (인증)** + +디지털 서명이 보장하는 세 가지는 이렇다: + +1. 인증 - 누가 서명했는지 확인 가능. 내 개인키로만 서명할 수 있어서 남이 위조 못 한다 +2. 무결성 - 데이터가 중간에 변조됐는지 확인 가능 +3. 부인 방지 - 서명한 사람이 나중에 부인 못 함 + +여기서 묻는 위조 방지는 인증에 해당한다. + +--- + +## 문제 4: 키 유도 + +**답변: C** + +Private Key에서 Public Key를 만들고, Public Key에서 Address를 만든다. 반대로는 못 만든다. + +왜 반대로 못 하냐면: +- Private Key -> Public Key: 타원곡선 수학으로 만드는데, 계산은 쉬워도 역으로 푸는 건 지금 컴퓨터로는 불가능함 (이산로그 문제) +- Public Key -> Address: 해시 함수 쓰는데, 해시는 원래 역으로 못 푸는 구조 + +그래서 Address만 가지고는 Public Key도 못 알아내고, Public Key만으로도 Private Key 못 알아낸다. + +--- + +## 문제 5: nonce가 필요한 이유 + +nonce가 없으면 내가 본낸 트랜잭션을 누가 복사해서 여러 번 네트워크에 뿌릴 수 있다. 내 서명은 유효하니까 같은 트랜잭션이 계속 실행되서 돈이 계속 빠져나갈 수 있다. + +nonce는 내 계정이 트랜잭션을 몇 번 냈는지 세는 역할을 한다. 한 번 쓴 nonce는 다시 못 쓰게 해서 이런 재사용 공격을 막는다. + +--- + +## 문제 6: Private Key 보안 + +은행은 비밀번호 유출되면 계좌 동결시키고 거래 취소할 수 있다. 블록체인은 그게 안 된다. + +블록체인은 코드가 곧 법이라서, 한 번 실행된 거래는 되돌릴 수 없다. Private Key 가진 사람이 계좌 주인이고, 그 사람이 한 거래는 무조건 유효하다. 그래서 Private Key 유출되면 그냥 돈 다 털리는 거고 막을 방법이 없다. + +--- + +## 문제 7: EIP-1559 + +예전에는 사용자가 gasPrice를 직접 정해서 내야 했다. 네트워크 막히면 비싸게 내야 되는데, 얼마나 내야 할지 맞추기 어려웠다. + +EIP-1559 이후에는 baseFee와 priorityFee로 나뉘었다: +- baseFee: 네트워크 상황에 따라 자동으로 정해지는 기본 수수료. 이건 burn(소각)된다 +- priorityFee: 채굴자한테 주는 팁 + +총 수수료 = baseFee + priorityFee + +이제 사용자는 maxFeePerGas랑 maxPriorityFeePerGas만 설정하면 되서 훨씬 편해졌다. + +--- + +## 문제 8: SimpleStorage 테스트 + +```solidity +function test_DepositUpdatesBalance() public { + vm.prank(user); + storage_.deposit{value: 1 ether}(); + assertEq(storage_.getBalance(user), 1 ether); +} +``` + +- vm.prank(user): 다음 호출을 user가 하는 것처럼 만들어줌 +- deposit{value: 1 ether}(): ETH 1개를 같이 볂는 문법 +- 1 ether: Solidity에서 1 ether = 10^18 wei + +--- + +## 문제 9: require 조건 + +문제점: withdraw 함수에 잔액 확인하는 게 없다 + +왜 문제냐면: 잔액 1 ether만 있어도 withdraw(100 ether) 호출할 수 있다. Solidity 0.8 이전에는 언더플로우나서 오히려 잔액이 엄청 늘어나는 심각한 버그였다. + +고친 코드: +```solidity +function withdraw(uint256 amount) public { + require(balances[msg.sender] >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + payable(msg.sender).transfer(amount); +} +``` + +--- + +## 문제 10: 테스트 실패 이유 + +질문 1: 입금 안 하고 바로 출금해서 잔액이 0인데 1 ether 출금하려 해서 Insufficient balance 에러로 실패한다. + +질문 2: 이렇게 고치면 된다 +```solidity +function test_WithdrawFails() public { + vm.expectRevert("Insufficient balance"); + storage_.withdraw(1 ether); +} +``` + +vm.expectRevert를 쓰면 다음 호출이 저 에러 메시지로 실패할 것을 기대한다. 실제로 실패하면 테스트 통과, 안 실패하면 테스트 실패다. + +--- + +## 자기 평가 + +- 트랜잭션 필드 이해함 +- 디지털 서명 세 가지 보장 설명 가능 +- Private Key 보안 중요성 이해함 +- Foundry 테스트 패턴 사용 가능 +- require 조건 필요성 이해함 From 45006a4df50665bfd0b1ec0c56793f26f6f2aca9 Mon Sep 17 00:00:00 2001 From: Seungwan98 Date: Thu, 5 Mar 2026 01:20:11 +0900 Subject: [PATCH 2/2] feat(week-03): complete week-03 assignment --- week-03/dev/src/VaultSecure.sol | 12 +- week-03/quiz/quiz-03-solution.md | 223 +++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 week-03/quiz/quiz-03-solution.md diff --git a/week-03/dev/src/VaultSecure.sol b/week-03/dev/src/VaultSecure.sol index 170bf64..7000d92 100644 --- a/week-03/dev/src/VaultSecure.sol +++ b/week-03/dev/src/VaultSecure.sol @@ -91,7 +91,8 @@ contract VaultSecure { /// /// 힌트: Vault.sol의 deposit()과 동일하게 구현하면 됩니다 function deposit() public payable { - // TODO: 구현하세요 + balances[msg.sender] += msg.value; + emit Deposited(msg.sender, msg.value); } /// @notice 예치한 ETH를 출금합니다 @@ -110,7 +111,14 @@ contract VaultSecure { /// CEI 패턴 사용 시 순서: Checks -> Effects -> Interactions /// ReentrancyGuard 사용 시: nonReentrant modifier 추가 function withdraw(uint256 amount) public { - // TODO: 구현하세요 + require(balances[msg.sender] >= amount, "Insufficient balance"); + + balances[msg.sender] -= amount; + + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + + emit Withdrawn(msg.sender, amount); } // ============================================ diff --git a/week-03/quiz/quiz-03-solution.md b/week-03/quiz/quiz-03-solution.md new file mode 100644 index 0000000..f74bd99 --- /dev/null +++ b/week-03/quiz/quiz-03-solution.md @@ -0,0 +1,223 @@ +# Week 3 퀴즈 답안 + +## 문제 1: EVM 개념 + +**답변: B** + +EVM이 결정론적이어야 하는 이유는 모든 노드가 같은 입력에 대해 같은 결과를 얻어야 합의가 가능하기 때문이다. + +랜덤 함수나 외부 API 호출이 금지된 이유는 이것들이 각 노드마다 다른 결과를 낼 수 있어서다. 예를 들어 어떤 노드는 API에서 A를 받고 다른 노드는 B를 받으면 상태가 달라져서 합의가 깨진다. 그래서 블록체인 안에서는 모든 것이 예측 가능하고 재현 가능해야 한다. + +--- + +## 문제 2: Storage vs Memory + +**답변: B** + +data는 Memory에 저장되며 함수 종료 시 삭제된다. + +**Storage/Memory/Stack 비용:** +- Storage: 영구 저장, 가장 비쌈 (SSTORE는 20,000 gas, SLOAD는 2,100 gas) +- Memory: 임시 저장, Storage보다 싸지만 Stack보다는 비쌈 +- Stack: 가장 싸고 빠름, 하지만 크기 제한 있음 (최대 1024 slots) + +Storage가 비싼 이유는 블록체인에 영구적으로 기록되어 모든 노드가 저장해야 하기 때문이다. + +--- + +## 문제 3: Gas 비용 + +**답변: D (SSTORE)** + +Storage 쓰기가 가장 비싸다. + +왜 Storage가 비싼가: +- Storage에 저장된 데이터는 블록체인에 영구적으로 기록된다 +- 모든 풀 노드가 이 데이터를 저장해야 한다 +- SSTORE는 처음 쓸 때 20,000 gas, 수정할 때 5,000 gas가 든다 +- 반면 ADD는 3 gas, MUL은 5 gas, SLOAD는 2,100 gas라서 훨씬 싸다 + +--- + +## 문제 4: CEI 패턴 + +**답변:** + +Effects(상태 변경)가 Interactions(외부 호출)보다 먼저 와야 하는 이유는 외부 호출에서 재진입이 일어날 수 있기 때문이다. + +재진입 공격 시나리오: +1. 공격자가 withdraw() 호출 +2. require는 통과 (잔액 충분) +3. call()로 ETH 전송 → 공격자의 receive() 실행됨 +4. receive()에서 다시 withdraw() 호출 +5. 이때 balances가 차감되지 않았으면 require 또 통과 +6. 반복해서 돈을 계속 빼갈 수 있음 + +Effects를 먼저 하면 4단계에서 다시 withdraw()가 호출되더라도 이미 balances가 0이라 require에서 막힌다. + +--- + +## 문제 5: The DAO 사건 교훈 + +**답변:** + +**기술적 교훈:** 외부 호출 전에 반드시 상태를 업데이트하라 (CEI 패턴). 코드 한 줄 순서가 6천만 달러를 날릴 수 있다. + +**생태계 교훈:** 이더리움은 원칙적으로 되돌릴 수 없지만, 피해 규모가 너무 커서 커뮤니티가 하드포크를 결정했다. 이로 인해 이더리움(ETH)과 이더리움 클래식(ETC)로 분리됐다. + +이 사건 이후로 OpenZeppelin 같은 검증된 라이브러리 사용과 보안 감사(security audit)가 표준으로 자리잡았다. + +--- + +## 문제 6: 재진입 공격 식별 + +**1) 발견한 취약점:** + +재진입(Reentrancy) 취약점이다. withdraw 함수에서 ETH 전송(call)을 하고 나서야 balances를 차감하고 있다. + +**2) 왜 이것이 문제인가:** + +1. 공격자가 Attacker 컨트랙트로 1 ETH 입금 +2. withdraw(1 ether) 호출 +3. require 통과 (balances >= 1 ether) +4. call()로 ETH 전송 → Attacker의 receive() 실행 +5. receive()에서 다시 withdraw(1 ether) 호출 +6. 아직 balances가 차감 안 됐으므로 require 또 통과 +7. 반복해서 Vault의 모든 돈을 빼갈 수 있음 + +**3) 올바른 수정 방법 (CEI 패턴):** + +```solidity +function withdraw(uint256 amount) public { + require(balances[msg.sender] >= amount, "Insufficient balance"); + + balances[msg.sender] -= amount; // Effects 먼저 + + (bool success, ) = msg.sender.call{value: amount}(""); // Interactions 나중에 + require(success, "Transfer failed"); +} +``` + +--- + +## 문제 7: CEI 패턴 구현 + +**답변:** + +```solidity +function secureWithdraw(uint256 amount) public { + // 1. Checks - 조건 확인 + require(balances[msg.sender] >= amount, "Insufficient balance"); + + // 2. Effects - 상태 변경 (외부 호출 전에!) + balances[msg.sender] -= amount; + + // 3. Interactions - 외부 호출 (마지막에!) + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); +} +``` + +**왜 이 순서가 중요한가:** + +외부 호출(call)이 실행되면 상대방 컨트랙트의 코드가 실행된다. 만약 Effects를 나중에 하면 그 사이에 상대방이 다시 withdraw를 호출했을 때 아직 balances가 차감되지 않았으므로 잔액 체크를 통과할 수 있다. + +Effects를 먼저 하면 재진입이 시도되더라도 이미 잔액이 0이 되어 require에서 걸려서 막힌다. + +--- + +## 문제 8: tx.origin 취약점 + +**1) 발견한 취약점:** + +tx.origin을 사용한 권한 체크다. tx.origin은 최초 트랜잭션을 시작한 EOA를 가리키고, msg.sender는 직접 호출한 주소를 가리킨다. + +**2) 공격 시나리오:** + +1. 공격자가 피싱 컨트랙트를 만든다 +2. 피해자(owner)에게 특정 함수를 호출하도록 유도한다 +3. 피해자가 공격자 컨트랙트 함수를 호출하면 그 컨트랙트 낶에서 transferOwnership을 호출한다 +4. 이때 tx.origin은 여전히 피해자이지만 msg.sender는 공격자 컨트랙트다 +5. require(tx.origin == owner)가 통과해서 ownership이 공격자에게 넘어간다 + +**3) 올바른 수정 방법:** + +```solidity +contract Secure { + address public owner; + + constructor() { + owner = msg.sender; + } + + function transferOwnership(address newOwner) public { + require(msg.sender == owner, "Not owner"); // tx.origin 대신 msg.sender 사용 + owner = newOwner; + } +} +``` + +--- + +## 문제 9: ReentrancyGuard 적용 + +**답변:** + +```solidity +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract SecureVault is ReentrancyGuard { + mapping(address => uint256) public balances; + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) public nonReentrant { + require(balances[msg.sender] >= amount, "Insufficient"); + balances[msg.sender] -= amount; + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Failed"); + } +} +``` + +**CEI 패턴 vs ReentrancyGuard:** + +- **CEI 패턴**: 가스 효율적, 외부 의존성 없음. 하지만 개발자가 순서를 직접 관리해야 해서 실수할 가능성이 있다. +- **ReentrancyGuard**: nonReentrant modifier로 자동으로 재진입을 막아준다. 명시적이고 안전하지만 약간의 가스 오버헤드가 있다. + +**언제 무엇을 사용하나:** +- 학습용/간단한 컨트랙트: CEI 패턴 +- 프로덕션/복잡한 컨트랙트: CEI + ReentrancyGuard 둘 다 사용하는 것이 가장 안전하다 + +--- + +## 문제 10: 재진입 공격 흐름 해석 + +**질문 1: 6번에서 require 체크가 통과하는 이유는 무엇인가요?** + +balances[attacker] 차감이 아직 안 일어났기 때문이다. call()로 ETH를 볂은 직후라서 상태 변경이 뒤에 있어서 잔액이 여전히 1 ETH로 남아있다. + +**질문 2: CEI 패턴을 적용하면 6번에서 어떻게 되나요?** + +Effects를 먼저 하면 3번(call) 전에 balances[attacker]가 이미 0이 된다. 그래서 5번에서 다시 withdraw를 호출핼 때 require(balances[attacker] >= 1 ether)에서 0 >= 1 ether는 false라서 revert된다. + +**질문 3: 공격자가 총 몇 ETH를 탈취할 수 있나요?** + +공격자는 자신이 예치한 1 ETH만 돌려받을 수 있고, 다른 사람의 돈은 탈취할 수 없다. + +Vault 총 잔액이 10 ETH(공격자 1 ETH + 다른 사람 9 ETH)일 때: +- 취약한 코드: 공격자가 10 ETH 모두 탈취 가능 +- CEI 적용 코드: 공격자는 자신의 1 ETH만 출금 가능, 나머지 9 ETH는 안전 + +--- + +## 자기 평가 + +- EVM의 결정론적 실행 필요성을 이해했다 +- Storage/Memory/Stack의 차이와 비용을 알고 있다 +- 재진입 공격의 원리를 설명할 수 있다 +- CEI 패턴으로 재진입 공격을 방어할 수 있다 +- tx.origin vs msg.sender의 보안 차이를 알고 있다 +- ReentrancyGuard를 적용할 수 있다