diff --git a/core/lib/Drupal/Core/Render/ProtectedRenderArray.php b/core/lib/Drupal/Core/Render/ProtectedRenderArray.php
new file mode 100644
index 0000000000..b37175f417
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/ProtectedRenderArray.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\Core\Render;
+
+/**
+ * Wraps a render array in an object where indexes are protected from writing.
+ *
+ * This class is used to wrap render arrays before they are passed to Twig, so
+ * that they can not be edited from within the template.
+ *
+ * @see \Drupal\Core\Template\TwigEnvironment::wrapContext
+ *
+ * @ingroup utility
+ */
+class ProtectedRenderArray extends \ArrayObject implements RenderableInterface {
+
+  /**
+   * ProtectedRenderArray constructor.
+   *
+   * @param array $input
+   *   The render array to wrap into a protected object.
+   */
+  public function __construct(array $input = []) {
+    parent::__construct($input, 0, ProtectedRenderArrayIterator::class);
+  }
+
+  /**
+   * Determines if a given array is a render array.
+   *
+   * @param mixed $input
+   *   A variable that may be a render array.
+   *
+   * @return boolean
+   *   Whether or not the given array is a render array.
+   */
+  public static function isRenderArray($input) {
+    if (!is_array($input)) {
+      return FALSE;
+    }
+    // Recurse over all keys in the array to determine if any are properties.
+    // This is done as the render system allows deeply nested arrays that may
+    // not contain property keys in the first few levels.
+    $iterator = new \RecursiveArrayIterator($input);
+    $recursive = new \RecursiveIteratorIterator(
+      $iterator,
+      \RecursiveIteratorIterator::SELF_FIRST
+    );
+    foreach ($recursive as $key => $value) {
+      if (Element::property($key)) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toRenderable() {
+    return $this->getArrayCopy();
+  }
+
+  /**
+   * Magic getter since Twig sometimes tries to access properties.
+   *
+   * @param mixed $index
+   *   The index with the value.
+   *
+   * @return \Drupal\Core\Render\ProtectedRenderArray|mixed
+   */
+  public function __get($index) {
+    return $this->offsetGet($index);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetGet($index) {
+    $value = parent::offsetGet($index);
+    if (self::isRenderArray($value)) {
+      $value = new ProtectedRenderArray($value);
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIterator() {
+    return new ProtectedRenderArrayIterator($this->getArrayCopy());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetSet($index, $newval) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function append($values) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function unserialize($serialized) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exchangeArray($serialized) {}
+
+}
diff --git a/core/lib/Drupal/Core/Render/ProtectedRenderArrayIterator.php b/core/lib/Drupal/Core/Render/ProtectedRenderArrayIterator.php
new file mode 100644
index 0000000000..1b6c10abd4
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/ProtectedRenderArrayIterator.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\Core\Render;
+
+/**
+ * Provides an ArrayIterator object where indexes are protected.
+ *
+ * This class is used to iterate over protected render arrays, preventing index
+ * changes even in deeply nested arrays.
+ *
+ * @see \Drupal\Core\Render\ProtectedRenderArray
+ *
+ * @ingroup utility
+ */
+class ProtectedRenderArrayIterator extends \ArrayIterator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetGet($index) {
+    $value = parent::offsetGet($index);
+    if (ProtectedRenderArray::isRenderArray($value)) {
+      $value = new ProtectedRenderArray($value);
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function current() {
+    $value = parent::current();
+    if (ProtectedRenderArray::isRenderArray($value)) {
+      $value = new ProtectedRenderArray($value);
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetSet($index, $newval) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function append($values) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function unserialize($serialized) {}
+
+}
diff --git a/core/lib/Drupal/Core/Template/TwigEnvironment.php b/core/lib/Drupal/Core/Template/TwigEnvironment.php
index 21755a5057..c6e62f5bb9 100644
--- a/core/lib/Drupal/Core/Template/TwigEnvironment.php
+++ b/core/lib/Drupal/Core/Template/TwigEnvironment.php
@@ -4,6 +4,7 @@
 
 use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Render\Markup;
+use Drupal\Core\Render\ProtectedRenderArray;
 use Drupal\Core\State\StateInterface;
 
 /**
@@ -140,7 +141,46 @@ public function getTemplateClass($name, $index = NULL) {
   public function renderInline($template_string, array $context = []) {
     // Prefix all inline templates with a special comment.
     $template_string = '{# inline_template_start #}' . $template_string;
+    $this->wrapContext($context);
     return Markup::create($this->loadTemplate($template_string, NULL)->render($context));
   }
 
+  /**
+   * Wraps arrays with protected objects, to prevent modification in Twig.
+   *
+   * If users were allowed to create or modify render arrays in Twig, code
+   * execution would be possible by abusing render array callbacks.
+   *
+   * @param array &$context
+   *   An array of parameters.
+   */
+  public function wrapContext(&$context) {
+    if (is_array($context)) {
+      foreach ($context as $key => $value) {
+        if (ProtectedRenderArray::isRenderArray($context[$key])) {
+          $context[$key] = new ProtectedRenderArray($context[$key]);
+        }
+      }
+    }
+  }
+
+  /**
+   * Unwraps protected objects into render arrays.
+   *
+   * @param array &$context
+   *   An array of parameters.
+   */
+  public function unWrapContext(&$context) {
+    if (is_object($context) && $context instanceof ProtectedRenderArray) {
+      $context = $context->getArrayCopy();
+    }
+    if (is_array($context)) {
+      foreach ($context as $key => $value) {
+        if (is_object($context[$key]) && $context[$key] instanceof ProtectedRenderArray) {
+          $context[$key] = $context[$key]->getArrayCopy();
+        }
+      }
+    }
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Template/TwigExtension.php b/core/lib/Drupal/Core/Template/TwigExtension.php
index 377efe6dde..215809f9d5 100644
--- a/core/lib/Drupal/Core/Template/TwigExtension.php
+++ b/core/lib/Drupal/Core/Template/TwigExtension.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Render\AttachmentsInterface;
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Render\Markup;
+use Drupal\Core\Render\ProtectedRenderArray;
 use Drupal\Core\Render\RenderableInterface;
 use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Routing\UrlGeneratorInterface;
@@ -247,7 +248,7 @@ public function getPath($name, $parameters = [], $options = []) {
    *   (optional) An associative array of additional options. The 'absolute'
    *   option is forced to be TRUE.
    *
-   * @return string
+   * @return \Drupal\Core\Render\ProtectedRenderArray
    *   The generated absolute URL for the given route.
    *
    * @todo Add an option for scheme-relative URLs.
@@ -260,7 +261,7 @@ public function getUrl($name, $parameters = [], $options = []) {
     // Return as render array, so we can bubble the bubbleable metadata.
     $build = ['#markup' => $generated_url->getGeneratedUrl()];
     $generated_url->applyTo($build);
-    return $build;
+    return new ProtectedRenderArray($build);
   }
 
   /**
@@ -273,7 +274,7 @@ public function getUrl($name, $parameters = [], $options = []) {
    * @param array|\Drupal\Core\Template\Attribute $attributes
    *   An optional array or Attribute object of link attributes.
    *
-   * @return array
+   * @return \Drupal\Core\Render\ProtectedRenderArray
    *   A render array representing a link to the given URL.
    */
   public function getLink($text, $url, $attributes = []) {
@@ -300,7 +301,7 @@ public function getLink($text, $url, $attributes = []) {
       '#title' => $text,
       '#url' => $url,
     ];
-    return $build;
+    return new ProtectedRenderArray($build);
   }
 
   /**
@@ -451,6 +452,12 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $
     elseif (is_object($arg)) {
       if ($arg instanceof RenderableInterface) {
         $arg = $arg->toRenderable();
+        // Early return if this element was pre-rendered (no need to re-render).
+        if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
+          return $arg['#markup'];
+        }
+        $arg['#printed'] = FALSE;
+        return $this->renderer->render($arg);
       }
       elseif (method_exists($arg, '__toString')) {
         $return = (string) $arg;
@@ -465,6 +472,9 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $
         throw new \Exception('Object of type ' . get_class($arg) . ' cannot be printed.');
       }
     }
+    else {
+      throw new \Exception('Variable of type ' . gettype($arg) . ' cannot be printed.');
+    }
 
     // We have a string or an object converted to a string: Autoescape it!
     if (isset($return)) {
@@ -478,16 +488,6 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $
       }
       return twig_escape_filter($env, $return, $strategy, $charset, $autoescape);
     }
-
-    // This is a normal render array, which is safe by definition, with
-    // special simple cases already handled.
-
-    // Early return if this element was pre-rendered (no need to re-render).
-    if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
-      return $arg['#markup'];
-    }
-    $arg['#printed'] = FALSE;
-    return $this->renderer->render($arg);
   }
 
   /**
@@ -566,6 +566,13 @@ public function renderVar($arg) {
       $this->bubbleArgMetadata($arg);
       if ($arg instanceof RenderableInterface) {
         $arg = $arg->toRenderable();
+        // This is a render array, with special simple cases already handled.
+        // Early return if this element was pre-rendered (no need to re-render).
+        if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
+          return $arg['#markup'];
+        }
+        $arg['#printed'] = FALSE;
+        return $this->renderer->render($arg);
       }
       elseif (method_exists($arg, '__toString')) {
         return (string) $arg;
@@ -580,14 +587,7 @@ public function renderVar($arg) {
         throw new \Exception('Object of type ' . get_class($arg) . ' cannot be printed.');
       }
     }
-
-    // This is a render array, with special simple cases already handled.
-    // Early return if this element was pre-rendered (no need to re-render).
-    if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
-      return $arg['#markup'];
-    }
-    $arg['#printed'] = FALSE;
-    return $this->renderer->render($arg);
+    throw new \Exception('Variable of type ' . gettype($arg) . ' cannot be printed.');
   }
 
   /**
diff --git a/core/modules/forum/forum.module b/core/modules/forum/forum.module
index b668956746..b4ee0a934b 100644
--- a/core/modules/forum/forum.module
+++ b/core/modules/forum/forum.module
@@ -7,8 +7,10 @@
 
 use Drupal\comment\CommentInterface;
 use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
+use Drupal\Component\Utility\Xss;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Render\Markup;
 use Drupal\Core\Url;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
@@ -548,7 +550,9 @@ function template_preprocess_forum_list(&$variables) {
   $row = 0;
   // Sanitize each forum so that the template can safely print the data.
   foreach ($variables['forums'] as $id => $forum) {
-    $variables['forums'][$id]->description = ['#markup' => $forum->description->value];
+    // Work-around the fact that this is an object instead of a render array
+    // by setting the property to a filtered Markup object so it's not escaped.
+    $variables['forums'][$id]->description = Markup::create(Xss::filter($forum->description->value));
     $variables['forums'][$id]->link = forum_uri($forum);
     $variables['forums'][$id]->name = $forum->label();
     $variables['forums'][$id]->is_container = !empty($forum->forum_container->value);
diff --git a/core/modules/system/src/Tests/Theme/TwigProtectedRenderArrayTest.php b/core/modules/system/src/Tests/Theme/TwigProtectedRenderArrayTest.php
new file mode 100644
index 0000000000..bf6265c3e2
--- /dev/null
+++ b/core/modules/system/src/Tests/Theme/TwigProtectedRenderArrayTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\system\Tests\Theme;
+
+use Drupal\simpletest\WebTestBase;
+
+/**
+ * Tests that variables passed to Twig cannot be modified.
+ *
+ * @group Theme
+ */
+class TwigProtectedRenderArrayTest extends WebTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['twig_theme_test'];
+
+  /**
+   * Tests that Twig variables are protected.
+   */
+  public function testProtectedTwigVariables() {
+    $filter_test = [
+      '#theme' => 'twig_theme_test_protected',
+      '#renderable' => [
+        '#markup' => 'Original',
+      ],
+    ];
+
+    // To modify a ProtectedRenderArray, Twig will convert it to an array and
+    // attempt to use that for rendering. This should throw an error.
+    try {
+      $result = \Drupal::service('renderer')->renderRoot($filter_test);
+    }
+    catch (\Exception $e) {}
+    $this->assertFalse(isset($result), 'Variables cannot be modified.');
+  }
+
+}
diff --git a/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.protected.html.twig b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.protected.html.twig
new file mode 100644
index 0000000000..4bfeec8afb
--- /dev/null
+++ b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.protected.html.twig
@@ -0,0 +1,2 @@
+{% set renderable = renderable|merge({'#markup': 'Modified'}) %}
+{{ renderable }}
diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
index a8b4086203..a700b92e16 100644
--- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
+++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
@@ -73,6 +73,12 @@ function twig_theme_test_theme($existing, $type, $theme, $path) {
     ],
     'template' => 'twig_theme_test.renderable',
   ];
+  $items['twig_theme_test_protected'] = [
+    'variables' => [
+      'renderable' => NULL,
+    ],
+    'template' => 'twig_theme_test.protected',
+  ];
   return $items;
 }
 
diff --git a/core/tests/Drupal/Tests/Core/Render/ProtectedRenderArrayTest.php b/core/tests/Drupal/Tests/Core/Render/ProtectedRenderArrayTest.php
new file mode 100644
index 0000000000..da69ee86b1
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Render/ProtectedRenderArrayTest.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\Tests\Core\Render;
+
+use Drupal\Core\Render\ProtectedRenderArray;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests that protected render arrays can not be modified.
+ *
+ * @coversDefaultClass \Drupal\Core\Render\ProtectedRenderArray
+ *
+ * @group Render
+ */
+class ProtectedRenderArrayTest extends UnitTestCase {
+
+  /**
+   * Tests that sub-arrays are also protected.
+   *
+   * @covers ::offsetGet
+   * @dataProvider providerTestProtectedRenderArray
+   */
+  public function testSubArrayAccess(ProtectedRenderArray $protected_array) {
+    $this->assertTrue($protected_array['sub_array'] instanceof ProtectedRenderArray);
+    $this->assertTrue($protected_array['sub_array']['sub_sub_array'] instanceof ProtectedRenderArray);
+    $this->assertTrue(is_array($protected_array['sub_non_render_array']));
+    $this->assertTrue($protected_array['sub_odd_array']['foo']['bar'] instanceof ProtectedRenderArray);
+  }
+
+  /**
+   * Tests that sub-arrays are protected when accessed by an iterator.
+   *
+   * @covers ::getIterator
+   * @dataProvider providerTestProtectedRenderArray
+   */
+  public function testIteratedSubArrayAccess(ProtectedRenderArray $protected_array) {
+    foreach ($protected_array as $key => $value) {
+      if ($key === 'sub_array' || $key === 'sub_odd_array') {
+        $this->assertTrue($value instanceof ProtectedRenderArray);
+      }
+      if ($key === 'sub_non_render_array') {
+        $this->assertTrue(is_array($value));
+      }
+    }
+  }
+
+  /**
+   * Tests protected render arrays cannot be edited.
+   *
+   * @covers ::offsetSet
+   * @dataProvider providerTestProtectedRenderArray
+   */
+  public function testArrayProtected(ProtectedRenderArray $protected_array) {
+    // Test that new keys cannot be set.
+    $protected_array['new_key'] = 'new_value';
+    $this->assertArrayNotHasKey('new_key', $protected_array);
+
+    // Test that existing values cannot be changed.
+    $protected_array['sub_markup']['#markup'] = 'Hello world';
+    $this->assertTrue($protected_array['sub_markup']['#markup'] === 'Test');
+
+    // Test that sub arrays have the same protection.
+    $sub_array = $protected_array['sub_array'];
+    $sub_array['#type'] = 'inline_template';
+    $this->assertTrue($sub_array['#type'] === 'container');
+  }
+
+  /**
+   * @return array
+   */
+  public function providerTestProtectedRenderArray() {
+    return [
+      [
+        new ProtectedRenderArray([
+          '#type' => 'container',
+          'sub_array' => [
+            '#type' => 'container',
+            'sub_sub_array' => [
+              '#markup' => 'Really nested!'
+            ],
+          ],
+          'sub_markup' => [
+            '#markup' => 'Test',
+          ],
+          'sub_non_render_array' => [
+            'foo' => 'bar',
+            'baz',
+          ],
+          // This appears malformed, but is valid to the render system.
+          'sub_odd_array' => [
+            'foo' => [
+              'bar' => [
+                'baz' => [
+                  '#markup' => 'Really, really nested!'
+                ]
+              ],
+            ],
+          ],
+        ])
+      ],
+    ];
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php
index a872e305d4..fa8f6cc469 100644
--- a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php
+++ b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\Core\Template;
 
 use Drupal\Core\GeneratedLink;
+use Drupal\Core\Render\ProtectedRenderArray;
 use Drupal\Core\Render\RenderableInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Template\Loader\StringLoader;
@@ -225,16 +226,16 @@ public function testSafeJoin() {
     $markup->__toString()->willReturn('<em>will be markup</em>');
     $markup = $markup->reveal();
 
-    $items = [
+    $items = new ProtectedRenderArray([
       '<em>will be escaped</em>',
       $markup,
       ['#markup' => '<strong>will be rendered</strong>'],
-    ];
+    ]);
     $result = $this->systemUnderTest->safeJoin($twig_environment, $items, '<br/>');
     $this->assertEquals('&lt;em&gt;will be escaped&lt;/em&gt;<br/><em>will be markup</em><br/><strong>will be rendered</strong>', $result);
 
     // Ensure safe_join Twig filter supports Traversable variables.
-    $items = new \ArrayObject([
+    $items = new ProtectedRenderArray([
       '<em>will be escaped</em>',
       $markup,
       ['#markup' => '<strong>will be rendered</strong>'],
diff --git a/core/themes/engines/twig/twig.engine b/core/themes/engines/twig/twig.engine
index 177e01a511..e5b1244d85 100644
--- a/core/themes/engines/twig/twig.engine
+++ b/core/themes/engines/twig/twig.engine
@@ -52,7 +52,7 @@ function twig_init(Extension $theme) {
  *   The output generated by the template, plus any debug information.
  */
 function twig_render_template($template_file, array $variables) {
-  /** @var \Twig_Environment $twig_service */
+  /** @var \Drupal\Core\Template\TwigEnvironment $twig_service */
   $twig_service = \Drupal::service('twig');
   $output = [
     'debug_prefix' => '',
@@ -61,7 +61,9 @@ function twig_render_template($template_file, array $variables) {
     'debug_suffix' => '',
   ];
   try {
+    $twig_service->wrapContext($variables);
     $output['rendered_markup'] = $twig_service->loadTemplate($template_file)->render($variables);
+    $twig_service->unWrapContext($variables);
   }
   catch (\Twig_Error_Runtime $e) {
     // In case there is a previous exception, re-throw the previous exception,
