Module M.08

URL Spoofing

The Fake Source

A malicious actor can create a clone of a trusted site with a similar domain name and feed it false data. If your contract relies on domain checks in static code, updating requires redeployment. Instead, maintain a whitelist as mutable state with governance controls so the community can vote on trusted sources.

Side-by-side · Vulnerable vs. Patched

two contracts · proven by paired transactions
vulnerablecontracts/vulnerable/VulnerableNews.py
Failed TX
> consensus failed · validators diverged
1# { "Depends": "py-genlayer:15qfivjvy80800rh998pcxmd2m8va1wq2qzqhz850n8ggcr4i9q0" }23from genlayer import *45# Module 8 (Vulnerable) -- URL spoofing / immutable allow-list.6# The trusted-domain set is hardcoded in code. If a domain in the list is7# compromised (DNS hijack, ownership change), or if a new legitimate8# source is needed, the contract has no way to react -- it must be9# entirely redeployed. There is also no `add_domain` method, so attempts10# to react on-chain end with FINISHED_WITH_ERROR ("function not found").1112TRUSTED_DOMAINS = ("reuters.com", "bbc.com", "apnews.com")131415class VulnerableNews(gl.Contract):16    last_status: str1718    def __init__(self):19        self.last_status = ""2021    @gl.public.write22    def verify(self, url: str) -> None:23        # Naive host extraction. Also part of the bug: a URL like24        # https://reuters.com.attacker.example/... slips through.25        host = ""26        try:27            host = url.split("/")[2].lower()28            if host.startswith("www."):29                host = host[4:]30        except Exception:31            host = ""3233        if host in TRUSTED_DOMAINS:34            self.last_status = "TRUSTED"35        else:36            self.last_status = "UNTRUSTED"3738    @gl.public.view39    def get_last_status(self) -> str:40        return self.last_status
patchedcontracts/patched/WhitelistedNews.py
Success TX
> consensus reached · all validators agree
1# { "Depends": "py-genlayer:15qfivjvy80800rh998pcxmd2m8va1wq2qzqhz850n8ggcr4i9q0" }23from genlayer import *4import json5import re67# Module 8 (Patched) -- Mutable whitelist with governance.8# Defenses:9#   1. Domain set lives in storage; owner can add/remove without10#      redeploying.11#   2. Strict eTLD parsing prevents `reuters.com.attacker.example` from12#      matching `reuters.com`.13#   3. Lightweight proposal/vote flow lets multiple signers add a domain.1415INITIAL_DOMAINS = ("reuters.com", "bbc.com", "apnews.com")16GOVERNANCE_THRESHOLD = 3171819class WhitelistedNews(gl.Contract):20    owner: str21    trusted_json: str          # {"reuters.com": true, ...}22    proposals_json: str        # {"newsource.com": ["0x..", "0x.."]}23    last_status: str2425    def __init__(self):26        self.owner = str(gl.message.sender_address)27        self.trusted_json = json.dumps({d: True for d in INITIAL_DOMAINS})28        self.proposals_json = "{}"29        self.last_status = ""3031    def _require_owner(self) -> None:32        if str(gl.message.sender_address) != self.owner:33            raise Exception("only owner")3435    @staticmethod36    def _host_of(url: str) -> str:37        m = re.match(r"^https?://([^/?#]+)", url.strip().lower())38        if not m:39            return ""40        host = m.group(1)41        if host.startswith("www."):42            host = host[4:]43        return host4445    @gl.public.write46    def verify(self, url: str) -> None:47        host = self._host_of(url)48        trusted = json.loads(self.trusted_json)49        self.last_status = "TRUSTED" if trusted.get(host, False) else "UNTRUSTED"5051    @gl.public.write52    def add_domain(self, domain: str) -> None:53        self._require_owner()54        if not re.match(r"^[a-z0-9.-]+\.[a-z]{2,}$", domain):55            raise ValueError("invalid domain")56        trusted = json.loads(self.trusted_json)57        trusted[domain] = True58        self.trusted_json = json.dumps(trusted)5960    @gl.public.write61    def remove_domain(self, domain: str) -> None:62        self._require_owner()63        trusted = json.loads(self.trusted_json)64        trusted[domain] = False65        self.trusted_json = json.dumps(trusted)6667    @gl.public.write68    def propose_domain(self, domain: str) -> None:69        if not re.match(r"^[a-z0-9.-]+\.[a-z]{2,}$", domain):70            raise ValueError("invalid domain")71        proposals = json.loads(self.proposals_json)72        voters = proposals.get(domain, [])73        sender = str(gl.message.sender_address)74        if sender in voters:75            raise Exception("already voted")76        voters.append(sender)77        proposals[domain] = voters78        self.proposals_json = json.dumps(proposals)79        if len(voters) >= GOVERNANCE_THRESHOLD:80            trusted = json.loads(self.trusted_json)81            trusted[domain] = True82            self.trusted_json = json.dumps(trusted)83            del proposals[domain]84            self.proposals_json = json.dumps(proposals)8586    @gl.public.view87    def get_last_status(self) -> str:88        return self.last_status8990    @gl.public.view91    def get_trusted(self) -> str:92        return self.trusted_json9394    @gl.public.view95    def get_proposals(self) -> str:96        return self.proposals_json
Call invoked
add_domain("example.com")

vulnerable contract has no add_domain method -> call rejected

Call invoked
add_domain("example.com")

owner-gated governance method exists on patched -> success

On-chain receipts

Knowledge check · M.08

01 / 02

Two questions on this incident. Pick the best answer; the question locks once committed.

Question 01 / 02
What is URL spoofing in GenLayer contracts?