Poke

In this episode, we finally play with an update call. Unlike query calls, any changes to the canister have permanent effects. In wasm terms, this means writes to globals, memory, and the table.

We demonstrate an update call in perhaps the most barbaric fashion possible: we build a canister that lets users overwrite any byte in its memory! Bear in mind that wasm keeps memory isolated from everything else, so perhaps this feature has less destructive potential than you might expect.

Our peek method reads the byte at a given address, and our poke methods writes a given byte to a given address. We use the Candid format, because we need some way to pair up an address and a byte over the wire, and also so it gives our code something less trivial to do!

#define IMPORT(m,n) __attribute__((import_module(m))) __attribute__((import_name(n)));
#define EXPORT(n) asm(n) __attribute__((visibility("default")))
typedef unsigned u32;
void reply_append(void*, u32)  IMPORT("ic0", "msg_reply_data_append");
void reply(void)               IMPORT("ic0", "msg_reply");
void reject(void*, u32)        IMPORT("ic0", "msg_reject");
u32 arg_size(void)             IMPORT("ic0", "msg_arg_data_size");
void arg_copy(void*, u32, u32) IMPORT("ic0", "msg_arg_data_copy");
unsigned char buf[13], *p;

void err() {
  char msg[] = "bad input";
  reject(msg, sizeof(msg) - 1);
}

#define WANT(ch) if (*p++ != ch) return err()

void peek() EXPORT("canister_query peek");
void peek() {
  p = buf;
  if (arg_size() != 11) return err();
  arg_copy(buf, 0, 11);
  WANT('D'); WANT('I'); WANT('D'); WANT('L');  // Candid header.
  WANT(0); WANT(1); WANT(0x79);  // No type table, 1 arg: nat32.
  u32 addr = 0;  // Read a nat32 address.
  for (u32 sh = 0; sh != 32; sh += 8) addr |= *p++ << sh;
  p = buf + 6;  // Reuse first 6 bytes of message.
  *p++ = 0x7b;  // Return type: nat8.
  *p = *((unsigned char *) addr);  // Peek!
  reply_append(buf, 8);
  reply();
}

void poke() EXPORT("canister_update poke");
void poke() {
  p = buf;
  if (arg_size() != 13) return err();
  arg_copy(buf, 0, 13);
  WANT('D'); WANT('I'); WANT('D'); WANT('L');  // Candid header.
  WANT(0); WANT(2); WANT(0x79); WANT(0x7b);  // No type table, 2 args: nat32, nat8.
  u32 addr = 0;  // Read a nat32 address.
  for (u32 sh = 0; sh != 32; sh += 8) addr |= *p++ << sh;
  *((unsigned char *) addr) = *p;  // Poke!
  p = buf + 5;  // Reuse first 5 bytes of message.
  *p = 0;  // Empty reply.
  reply_append(buf, 6);
  reply();
}

We turn it into a canister, and deploy:

$ clang --target=wasm32 -c poke.c
$ wasm-ld --export-dynamic --allow-undefined --no-entry poke.o -o poke.wasm
$ touch did.not
$ cat > dfx.json << EOF
{"canisters":{"poke":
  {"type":"custom"
  ,"build":""
  ,"candid":"did.not"
  ,"wasm":"poke.wasm"
}}}
EOF
$ dfx deploy

Now we can read and write memory on this canister:

$ dfx canister call poke peek '(123 : nat32)'
(0 : nat8)

We expect this because unused wasm memory is initialized to 0. We change the byte:

$ dfx canister call poke poke '(123 : nat32, 42 : nat8)'
()

Then confirm the mutation sticks around:

$ dfx canister call poke peek '(123 : nat32)'
(42 : nat8)

If we had used the export name "canister_query poke", the above would still work, but the mutation would be immediately reverted after the canister reply, and the reply to our peek would be (0 : nat8) again.

Error Handling

Our code demonstrates rudimentary error handling. In event of an invalid input Candid message, the canister calls ic0.msg_reject. For example, we might try giving an empty argument to peek:

$ dfx canister call poke peek
Error: The Replica returned an error: code 4, message: "bad input"

Our code also demonstrates relying on the default error handling mechanism. Let’s try modifying a byte past the end of memory:

$ dfx canister call poke poke '(1234567890 : nat32, 42 : nat8)'
Error: The Replica returned an error: code 5, message: "Canister rrkah-fqaaa-aaaaa-aaaaq-cai trapped: heap out of bounds"

Thus the wasm virtual machine has our back: it handles serious errors even if we neglect to do so ourselves.

Let’s take a look at the tail end of the wasm2wat dump of the canister:

$ wasm2wat poke.wasm | tail
    local.get 0
    i32.const 16
    i32.add
    global.set 0)
  (memory (;0;) 2)
  (global (;0;) (mut i32) (i32.const 66592))
  (export "memory" (memory 0))
  (export "canister_query peek" (func $canister_query_peek))
  (export "canister_update poke" (func $canister_update_poke))
  (data (;0;) (i32.const 1024) "bad input\00"))

We see this canister only has 2 pages of memory (this can be adjusted wtih the --initial-memory option of wasm-ld). Each page is 65536 bytes, which means needn’t have gone so high to go out of bounds:

$ dfx canister call poke peek '(131071 : nat32)'
(0 : nat8)
$ dfx canister call poke peek '(131072 : nat32)'
Error: The Replica returned an error: code 5, message: "Canister rrkah-fqaaa-aaaaa-aaaaq-cai trapped: heap out of bounds"

We also see our custom erorr message is store at byte 1024, so we can corrupt it:

$ dfx canister call poke poke '(1024 : nat32, 114 : nat8)'
()
$ dfx canister call poke poke
Error: The Replica returned an error: code 4, message: "rad input"

A Chat Room

Despite its simplicity, perhaps our canister could be considered an online chat room. There are 131072 different channels, each of which can remember exactly 1 message that is 1 byte in length. (It’s actually less: examining the wasm reveals that we use the 13 bytes starting from 1036 to store the incoming Candid message.)

We wrote a small C file and a few commands later we have a robust online service. What would it take to deploy the same app on a typical cloud?


💡