mesecons/mesecons_luacontroller/init.lua
Christopher Head 2b096f050d Limit and optimize digiline_send (#379)
* Close vulnerability and optimize digiline_send

`digiline_send` as it previously existed was vulnerable to a
time-of-check-to-time-of-use vulnerability in which a table could be
sent, size-checked, and then modified after the send but before
delivery. This would allow larger tables to be sent. It was also slow
because it called `minetest.serialize`. Fix both of these by
implementing custom message cleanup logic which simultaneously computes
the message’s cost.

* Clean up interaction with Digilines

Use `minetest.global_exists` to avoid an undefined global variable
warning when operating a Luacontroller with Digilines not available. Use
the new `digilines` table in preference to the old `digiline` table.

* Copy received messages

When a Digiline message is received at a Luacontroller, copy it so that
local modifications made by the Luacontroller code will not modify
copies of the table that are being passed to other nodes on the Digiline
network.
2018-01-13 22:27:00 +03:00

792 lines
22 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ______
-- |
-- |
-- | __ ___ _ __ _ _
-- | | | | | |\ | | |_| | | | | |_ |_|
-- |___| |______ |__| | \| | | \ |__| |_ |_ |_ |\
-- |
-- |
--
-- Reference
-- ports = get_real_port_states(pos): gets if inputs are powered from outside
-- newport = merge_port_states(state1, state2): just does result = state1 or state2 for every port
-- set_port(pos, rule, state): activates/deactivates the mesecons according to the port states
-- set_port_states(pos, ports): Applies new port states to a Luacontroller at pos
-- run(pos): runs the code in the controller at pos
-- reset_meta(pos, code, errmsg): performs a software-reset, installs new code and prints error messages
-- resetn(pos): performs a hardware reset, turns off all ports
--
-- The Sandbox
-- The whole code of the controller runs in a sandbox,
-- a very restricted environment.
-- Actually the only way to damage the server is to
-- use too much memory from the sandbox.
-- You can add more functions to the environment
-- (see where local env is defined)
-- Something nice to play is is appending minetest.env to it.
local BASENAME = "mesecons_luacontroller:luacontroller"
local rules = {
a = {x = -1, y = 0, z = 0, name="A"},
b = {x = 0, y = 0, z = 1, name="B"},
c = {x = 1, y = 0, z = 0, name="C"},
d = {x = 0, y = 0, z = -1, name="D"},
}
------------------
-- Action stuff --
------------------
-- These helpers are required to set the port states of the luacontroller
local function update_real_port_states(pos, rule_name, new_state)
local meta = minetest.get_meta(pos)
if rule_name == nil then
meta:set_int("real_portstates", 1)
return
end
local n = meta:get_int("real_portstates") - 1
local L = {}
for i = 1, 4 do
L[i] = n % 2
n = math.floor(n / 2)
end
-- (0,-1) (-1,0) (1,0) (0,1)
local pos_to_side = { 4, 1, nil, 3, 2 }
if rule_name.x == nil then
for _, rname in ipairs(rule_name) do
local port = pos_to_side[rname.x + (2 * rname.z) + 3]
L[port] = (newstate == "on") and 1 or 0
end
else
local port = pos_to_side[rule_name.x + (2 * rule_name.z) + 3]
L[port] = (new_state == "on") and 1 or 0
end
meta:set_int("real_portstates",
1 +
1 * L[1] +
2 * L[2] +
4 * L[3] +
8 * L[4])
end
local port_names = {"a", "b", "c", "d"}
local function get_real_port_states(pos)
-- Determine if ports are powered (by itself or from outside)
local meta = minetest.get_meta(pos)
local L = {}
local n = meta:get_int("real_portstates") - 1
for _, name in ipairs(port_names) do
L[name] = ((n % 2) == 1)
n = math.floor(n / 2)
end
return L
end
local function merge_port_states(ports, vports)
return {
a = ports.a or vports.a,
b = ports.b or vports.b,
c = ports.c or vports.c,
d = ports.d or vports.d,
}
end
local function generate_name(ports)
local d = ports.d and 1 or 0
local c = ports.c and 1 or 0
local b = ports.b and 1 or 0
local a = ports.a and 1 or 0
return BASENAME..d..c..b..a
end
local function set_port(pos, rule, state)
if state then
mesecon.receptor_on(pos, {rule})
else
mesecon.receptor_off(pos, {rule})
end
end
local function clean_port_states(ports)
ports.a = ports.a and true or false
ports.b = ports.b and true or false
ports.c = ports.c and true or false
ports.d = ports.d and true or false
end
local function set_port_states(pos, ports)
local node = minetest.get_node(pos)
local name = node.name
clean_port_states(ports)
local vports = minetest.registered_nodes[name].virtual_portstates
local new_name = generate_name(ports)
if name ~= new_name and vports then
-- Problem:
-- We need to place the new node first so that when turning
-- off some port, it won't stay on because the rules indicate
-- there is an onstate output port there.
-- When turning the output off then, it will however cause feedback
-- so that the luacontroller will receive an "off" event by turning
-- its output off.
-- Solution / Workaround:
-- Remember which output was turned off and ignore next "off" event.
local meta = minetest.get_meta(pos)
local ign = minetest.deserialize(meta:get_string("ignore_offevents")) or {}
if ports.a and not vports.a and not mesecon.is_powered(pos, rules.a) then ign.A = true end
if ports.b and not vports.b and not mesecon.is_powered(pos, rules.b) then ign.B = true end
if ports.c and not vports.c and not mesecon.is_powered(pos, rules.c) then ign.C = true end
if ports.d and not vports.d and not mesecon.is_powered(pos, rules.d) then ign.D = true end
meta:set_string("ignore_offevents", minetest.serialize(ign))
minetest.swap_node(pos, {name = new_name, param2 = node.param2})
if ports.a ~= vports.a then set_port(pos, rules.a, ports.a) end
if ports.b ~= vports.b then set_port(pos, rules.b, ports.b) end
if ports.c ~= vports.c then set_port(pos, rules.c, ports.c) end
if ports.d ~= vports.d then set_port(pos, rules.d, ports.d) end
end
end
-----------------
-- Overheating --
-----------------
local function burn_controller(pos)
local node = minetest.get_node(pos)
node.name = BASENAME.."_burnt"
minetest.swap_node(pos, node)
minetest.get_meta(pos):set_string("lc_memory", "");
-- Wait for pending operations
minetest.after(0.2, mesecon.receptor_off, pos, mesecon.rules.flat)
end
local function overheat(pos, meta)
if mesecon.do_overheat(pos) then -- If too hot
burn_controller(pos)
return true
end
end
------------------------
-- Ignored off events --
------------------------
local function ignore_event(event, meta)
if event.type ~= "off" then return false end
local ignore_offevents = minetest.deserialize(meta:get_string("ignore_offevents")) or {}
if ignore_offevents[event.pin.name] then
ignore_offevents[event.pin.name] = nil
meta:set_string("ignore_offevents", minetest.serialize(ignore_offevents))
return true
end
end
-------------------------
-- Parsing and running --
-------------------------
local function safe_print(param)
print(dump(param))
end
local function safe_date()
return(os.date("*t",os.time()))
end
-- string.rep(str, n) with a high value for n can be used to DoS
-- the server. Therefore, limit max. length of generated string.
local function safe_string_rep(str, n)
if #str * n > mesecon.setting("luacontroller_string_rep_max", 64000) then
debug.sethook() -- Clear hook
error("string.rep: string length overflow", 2)
end
return string.rep(str, n)
end
-- string.find with a pattern can be used to DoS the server.
-- Therefore, limit string.find to patternless matching.
local function safe_string_find(...)
if (select(4, ...)) ~= true then
debug.sethook() -- Clear hook
error("string.find: 'plain' (fourth parameter) must always be true in a Luacontroller")
end
return string.find(...)
end
local function remove_functions(x)
local tp = type(x)
if tp == "function" then
return nil
end
-- Make sure to not serialize the same table multiple times, otherwise
-- writing mem.test = mem in the Luacontroller will lead to infinite recursion
local seen = {}
local function rfuncs(x)
if seen[x] then return end
seen[x] = true
if type(x) ~= "table" then return end
for key, value in pairs(x) do
if type(key) == "function" or type(value) == "function" then
x[key] = nil
else
if type(key) == "table" then
rfuncs(key)
end
if type(value) == "table" then
rfuncs(value)
end
end
end
end
rfuncs(x)
return x
end
local function get_interrupt(pos)
-- iid = interrupt id
local function interrupt(time, iid)
if type(time) ~= "number" then return end
local luac_id = minetest.get_meta(pos):get_int("luac_id")
mesecon.queue:add_action(pos, "lc_interrupt", {luac_id, iid}, time, iid, 1)
end
return interrupt
end
-- Given a message object passed to digiline_send, clean it up into a form
-- which is safe to transmit over the network and compute its "cost" (a very
-- rough estimate of its memory usage).
--
-- The cleaning comprises the following:
-- 1. Functions (and userdata, though user scripts ought not to get hold of
-- those in the first place) are removed, because they break the model of
-- Digilines as a network that carries basic data, and they could exfiltrate
-- references to mutable objects from one Luacontroller to another, allowing
-- inappropriate high-bandwidth, no-wires communication.
-- 2. Tables are duplicated because, being mutable, they could otherwise be
-- modified after the send is complete in order to change what data arrives
-- at the recipient, perhaps in violation of the previous cleaning rule or
-- in violation of the message size limit.
--
-- The cost indication is only approximate; its not a perfect measurement of
-- the number of bytes of memory used by the message object.
--
-- Parameters:
-- msg -- the message to clean
-- back_references -- for internal use only; do not provide
--
-- Returns:
-- 1. The cleaned object.
-- 2. The approximate cost of the object.
local function clean_and_weigh_digiline_message(msg, back_references)
local t = type(msg)
if t == "string" then
-- Strings are immutable so can be passed by reference, and cost their
-- length plus the size of the Lua object header (24 bytes on a 64-bit
-- platform) plus one byte for the NUL terminator.
return msg, #msg + 25
elseif t == "number" then
-- Numbers are passed by value so need not be touched, and cost 8 bytes
-- as all numbers in Lua are doubles.
return msg, 8
elseif t == "boolean" then
-- Booleans are passed by value so need not be touched, and cost 1
-- byte.
return msg, 1
elseif t == "table" then
-- Tables are duplicated. Check if this table has been seen before
-- (self-referential or shared table); if so, reuse the cleaned value
-- of the previous occurrence, maintaining table topology and avoiding
-- infinite recursion, and charge zero bytes for this as the object has
-- already been counted.
back_references = back_references or {}
local bref = back_references[msg]
if bref then
return bref, 0
end
-- Construct a new table by cleaning all the keys and values and adding
-- up their costs, plus 8 bytes as a rough estimate of table overhead.
local cost = 8
local ret = {}
back_references[msg] = ret
for k, v in pairs(msg) do
local k_cost, v_cost
k, k_cost = clean_and_weigh_digiline_message(k, back_references)
v, v_cost = clean_and_weigh_digiline_message(v, back_references)
if k ~= nil and v ~= nil then
-- Only include an element if its key and value are of legal
-- types.
ret[k] = v
end
-- If we only counted the cost of a table element when we actually
-- used it, we would be vulnerable to the following attack:
-- 1. Construct a huge table (too large to pass the cost limit).
-- 2. Insert it somewhere in a table, with a function as a key.
-- 3. Insert it somewhere in another table, with a number as a key.
-- 4. The first occurrence doesnt pay the cost because functions
-- are stripped and therefore the element is dropped.
-- 5. The second occurrence doesnt pay the cost because its in
-- back_references.
-- By counting the costs regardless of whether the objects will be
-- included, we avoid this attack; it may overestimate the cost of
-- some messages, but only those that wont be delivered intact
-- anyway because they contain illegal object types.
cost = cost + k_cost + v_cost
end
return ret, cost
else
return nil, 0
end
end
local function get_digiline_send(pos)
if not minetest.global_exists("digilines") then return end
return function(channel, msg)
-- Make sure channel is string, number or boolean
if (type(channel) ~= "string" and type(channel) ~= "number" and type(channel) ~= "boolean") then
return false
end
local msg_cost
msg, msg_cost = clean_and_weigh_digiline_message(msg)
if msg == nil or msg_cost > mesecon.setting("luacontroller_digiline_maxlen", 50000) then
return false
end
minetest.after(0, digilines.receptor_send, pos, digilines.rules.default, channel, msg)
return true
end
end
local safe_globals = {
"assert", "error", "ipairs", "next", "pairs", "select",
"tonumber", "tostring", "type", "unpack", "_VERSION"
}
local function create_environment(pos, mem, event)
-- Gather variables for the environment
local vports = minetest.registered_nodes[minetest.get_node(pos).name].virtual_portstates
local vports_copy = {}
for k, v in pairs(vports) do vports_copy[k] = v end
local rports = get_real_port_states(pos)
-- Create new library tables on each call to prevent one Luacontroller
-- from breaking a library and messing up other Luacontrollers.
local env = {
pin = merge_port_states(vports, rports),
port = vports_copy,
event = event,
mem = mem,
heat = mesecon.get_heat(pos),
heat_max = mesecon.setting("overheat_max", 20),
print = safe_print,
interrupt = get_interrupt(pos),
digiline_send = get_digiline_send(pos),
string = {
byte = string.byte,
char = string.char,
format = string.format,
len = string.len,
lower = string.lower,
upper = string.upper,
rep = safe_string_rep,
reverse = string.reverse,
sub = string.sub,
find = safe_string_find,
},
math = {
abs = math.abs,
acos = math.acos,
asin = math.asin,
atan = math.atan,
atan2 = math.atan2,
ceil = math.ceil,
cos = math.cos,
cosh = math.cosh,
deg = math.deg,
exp = math.exp,
floor = math.floor,
fmod = math.fmod,
frexp = math.frexp,
huge = math.huge,
ldexp = math.ldexp,
log = math.log,
log10 = math.log10,
max = math.max,
min = math.min,
modf = math.modf,
pi = math.pi,
pow = math.pow,
rad = math.rad,
random = math.random,
sin = math.sin,
sinh = math.sinh,
sqrt = math.sqrt,
tan = math.tan,
tanh = math.tanh,
},
table = {
concat = table.concat,
insert = table.insert,
maxn = table.maxn,
remove = table.remove,
sort = table.sort,
},
os = {
clock = os.clock,
difftime = os.difftime,
time = os.time,
datetable = safe_date,
},
}
env._G = env
for _, name in pairs(safe_globals) do
env[name] = _G[name]
end
return env
end
local function timeout()
debug.sethook() -- Clear hook
error("Code timed out!", 2)
end
local function create_sandbox(code, env)
if code:byte(1) == 27 then
return nil, "Binary code prohibited."
end
local f, msg = loadstring(code)
if not f then return nil, msg end
setfenv(f, env)
-- Turn off JIT optimization for user code so that count
-- events are generated when adding debug hooks
if rawget(_G, "jit") then
jit.off(f, true)
end
return function(...)
-- Use instruction counter to stop execution
-- after luacontroller_maxevents
local maxevents = mesecon.setting("luacontroller_maxevents", 10000)
debug.sethook(timeout, "", maxevents)
local ok, ret = pcall(f, ...)
debug.sethook() -- Clear hook
if not ok then error(ret, 0) end
return ret
end
end
local function load_memory(meta)
return minetest.deserialize(meta:get_string("lc_memory")) or {}
end
local function save_memory(pos, meta, mem)
local memstring = minetest.serialize(remove_functions(mem))
local memsize_max = mesecon.setting("luacontroller_memsize", 100000)
if (#memstring <= memsize_max) then
meta:set_string("lc_memory", memstring)
else
print("Error: Luacontroller memory overflow. "..memsize_max.." bytes available, "
..#memstring.." required. Controller overheats.")
burn_controller(pos)
end
end
local function run(pos, event)
local meta = minetest.get_meta(pos)
if overheat(pos) then return end
if ignore_event(event, meta) then return end
-- Load code & mem from meta
local mem = load_memory(meta)
local code = meta:get_string("code")
-- Create environment
local env = create_environment(pos, mem, event)
-- Create the sandbox and execute code
local f, msg = create_sandbox(code, env)
if not f then return msg end
local success, msg = pcall(f)
if not success then return msg end
if type(env.port) ~= "table" then
return "Ports set are invalid."
end
-- Actually set the ports
set_port_states(pos, env.port)
-- Save memory. This may burn the luacontroller if a memory overflow occurs.
save_memory(pos, meta, env.mem)
end
mesecon.queue:add_function("lc_interrupt", function (pos, luac_id, iid)
-- There is no luacontroller anymore / it has been reprogrammed / replaced / burnt
if (minetest.get_meta(pos):get_int("luac_id") ~= luac_id) then return end
if (minetest.registered_nodes[minetest.get_node(pos).name].is_burnt) then return end
run(pos, {type="interrupt", iid = iid})
end)
local function reset_meta(pos, code, errmsg)
local meta = minetest.get_meta(pos)
meta:set_string("code", code)
code = minetest.formspec_escape(code or "")
errmsg = minetest.formspec_escape(tostring(errmsg or ""))
meta:set_string("formspec", "size[12,10]"..
"background[-0.2,-0.25;12.4,10.75;jeija_luac_background.png]"..
"textarea[0.2,0.2;12.2,9.5;code;;"..code.."]"..
"image_button[4.75,8.75;2.5,1;jeija_luac_runbutton.png;program;]"..
"image_button_exit[11.72,-0.25;0.425,0.4;jeija_close_window.png;exit;]"..
"label[0.1,9;"..errmsg.."]")
meta:set_int("luac_id", math.random(1, 65535))
end
local function reset(pos)
set_port_states(pos, {a=false, b=false, c=false, d=false})
end
-----------------------
-- Node Registration --
-----------------------
local output_rules = {}
local input_rules = {}
local node_box = {
type = "fixed",
fixed = {
{-8/16, -8/16, -8/16, 8/16, -7/16, 8/16}, -- Bottom slab
{-5/16, -7/16, -5/16, 5/16, -6/16, 5/16}, -- Circuit board
{-3/16, -6/16, -3/16, 3/16, -5/16, 3/16}, -- IC
}
}
local selection_box = {
type = "fixed",
fixed = { -8/16, -8/16, -8/16, 8/16, -5/16, 8/16 },
}
local digiline = {
receptor = {},
effector = {
action = function(pos, node, channel, msg)
msg = clean_and_weigh_digiline_message(msg)
run(pos, {type = "digiline", channel = channel, msg = msg})
end
}
}
local function get_program(pos)
local meta = minetest.get_meta(pos)
return meta:get_string("code")
end
local function set_program(pos, code)
reset(pos)
reset_meta(pos, code)
local err = run(pos, {type="program"})
if err then
reset_meta(pos, code, err)
return false, err
end
return true
end
local function on_receive_fields(pos, form_name, fields, sender)
if not fields.program then
return
end
local name = sender:get_player_name()
if minetest.is_protected(pos, name) and not minetest.check_player_privs(name, {protection_bypass=true}) then
minetest.record_protection_violation(pos, name)
return
end
local ok, err = set_program(pos, fields.code)
if not ok then
-- it's not an error from the server perspective
minetest.log("action", "Lua controller programming error: " .. err)
end
end
for a = 0, 1 do -- 0 = off 1 = on
for b = 0, 1 do
for c = 0, 1 do
for d = 0, 1 do
local cid = tostring(d)..tostring(c)..tostring(b)..tostring(a)
local node_name = BASENAME..cid
local top = "jeija_luacontroller_top.png"
if a == 1 then
top = top.."^jeija_luacontroller_LED_A.png"
end
if b == 1 then
top = top.."^jeija_luacontroller_LED_B.png"
end
if c == 1 then
top = top.."^jeija_luacontroller_LED_C.png"
end
if d == 1 then
top = top.."^jeija_luacontroller_LED_D.png"
end
local groups
if a + b + c + d ~= 0 then
groups = {dig_immediate=2, not_in_creative_inventory=1, overheat = 1}
else
groups = {dig_immediate=2, overheat = 1}
end
output_rules[cid] = {}
input_rules[cid] = {}
if a == 1 then table.insert(output_rules[cid], rules.a) end
if b == 1 then table.insert(output_rules[cid], rules.b) end
if c == 1 then table.insert(output_rules[cid], rules.c) end
if d == 1 then table.insert(output_rules[cid], rules.d) end
if a == 0 then table.insert( input_rules[cid], rules.a) end
if b == 0 then table.insert( input_rules[cid], rules.b) end
if c == 0 then table.insert( input_rules[cid], rules.c) end
if d == 0 then table.insert( input_rules[cid], rules.d) end
local mesecons = {
effector = {
rules = input_rules[cid],
action_change = function (pos, _, rule_name, new_state)
update_real_port_states(pos, rule_name, new_state)
run(pos, {type=new_state, pin=rule_name})
end,
},
receptor = {
state = mesecon.state.on,
rules = output_rules[cid]
},
luacontroller = {
get_program = get_program,
set_program = set_program,
},
}
minetest.register_node(node_name, {
description = "Luacontroller",
drawtype = "nodebox",
tiles = {
top,
"jeija_microcontroller_bottom.png",
"jeija_microcontroller_sides.png",
"jeija_microcontroller_sides.png",
"jeija_microcontroller_sides.png",
"jeija_microcontroller_sides.png"
},
inventory_image = top,
paramtype = "light",
is_ground_content = false,
groups = groups,
drop = BASENAME.."0000",
sunlight_propagates = true,
selection_box = selection_box,
node_box = node_box,
on_construct = reset_meta,
on_receive_fields = on_receive_fields,
sounds = default.node_sound_stone_defaults(),
mesecons = mesecons,
digiline = digiline,
-- Virtual portstates are the ports that
-- the node shows as powered up (light up).
virtual_portstates = {
a = a == 1,
b = b == 1,
c = c == 1,
d = d == 1,
},
after_dig_node = function (pos, node)
mesecon.do_cooldown(pos)
mesecon.receptor_off(pos, output_rules)
end,
is_luacontroller = true,
on_blast = mesecon.on_blastnode,
})
end
end
end
end
------------------------------
-- Overheated Luacontroller --
------------------------------
minetest.register_node(BASENAME .. "_burnt", {
drawtype = "nodebox",
tiles = {
"jeija_luacontroller_burnt_top.png",
"jeija_microcontroller_bottom.png",
"jeija_microcontroller_sides.png",
"jeija_microcontroller_sides.png",
"jeija_microcontroller_sides.png",
"jeija_microcontroller_sides.png"
},
inventory_image = "jeija_luacontroller_burnt_top.png",
is_burnt = true,
paramtype = "light",
is_ground_content = false,
groups = {dig_immediate=2, not_in_creative_inventory=1},
drop = BASENAME.."0000",
sunlight_propagates = true,
selection_box = selection_box,
node_box = node_box,
on_construct = reset_meta,
on_receive_fields = on_receive_fields,
sounds = default.node_sound_stone_defaults(),
virtual_portstates = {a = false, b = false, c = false, d = false},
mesecons = {
effector = {
rules = mesecon.rules.flat,
action_change = function(pos, _, rule_name, new_state)
update_real_port_states(pos, rule_name, new_state)
end,
},
},
on_blast = mesecon.on_blastnode,
})
------------------------
-- Craft Registration --
------------------------
minetest.register_craft({
output = BASENAME.."0000 2",
recipe = {
{'mesecons_materials:silicon', 'mesecons_materials:silicon', 'group:mesecon_conductor_craftable'},
{'mesecons_materials:silicon', 'mesecons_materials:silicon', 'group:mesecon_conductor_craftable'},
{'group:mesecon_conductor_craftable', 'group:mesecon_conductor_craftable', ''},
}
})